drive the Claude pane over stream-json with native prompts
Replaces the Claude pane's tmux-TUI + transcript-tail backend with Claude Code's stream-json control protocol (D-77/D-78). A StreamJsonSession owns the `claude` process: its event stream feeds the existing ConversationController, and permission / AskUserQuestion prompts arrive as can_use_tool control_requests. Those surface as a ToolPrompt in the composer zone — the pane swaps the text input for an Allow/Deny card or an option picker while a prompt is open, so interaction stays out of the conversation stream and the prompt buttons don't fight the message-card hover chrome. The decision is written back as a control_response (allow echoes updatedInput; AskUserQuestion answers go in updatedInput.answers). Unsupported control subtypes are answered with an error so a turn never hangs. Session continuity is --resume (existing transcript) vs --session-id (new); /clear and /resume respawn the process. The transcript reader still backs the sidebar/status/team surfaces. T-165, T-166. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,11 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
// Route permission asks + AskUserQuestion to us over the control
|
||||
// channel as `can_use_tool` requests. Without `stdio` the CLI silently
|
||||
// auto-denies anything needing approval (D-78).
|
||||
'--permission-prompt-tool',
|
||||
'stdio',
|
||||
...sessionArgs,
|
||||
],
|
||||
workingDirectory: cwd,
|
||||
@@ -73,8 +78,69 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/// An interactive prompt Claude is blocked on, from the stream-json control
|
||||
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
|
||||
/// an `AskUserQuestion`. Pure data; the decision goes back via
|
||||
/// [StreamJsonSession.resolvePrompt]. Not a conversation item — prompts are
|
||||
/// *interaction*, surfaced in the composer zone, not the transcript (D-78).
|
||||
class ToolPrompt {
|
||||
const ToolPrompt({
|
||||
required this.promptId,
|
||||
required this.toolName,
|
||||
required this.displayName,
|
||||
required this.input,
|
||||
this.description,
|
||||
this.toolUseId = '',
|
||||
});
|
||||
|
||||
/// The control_request `request_id` — the key passed to [StreamJsonSession.resolvePrompt].
|
||||
final String promptId;
|
||||
|
||||
/// The gated `tool_use_id` (matches the preceding assistant tool_use).
|
||||
final String toolUseId;
|
||||
|
||||
/// Tool being requested, e.g. `Write` or `AskUserQuestion`.
|
||||
final String toolName;
|
||||
|
||||
/// Human label (`display_name`), falls back to [toolName].
|
||||
final String displayName;
|
||||
|
||||
/// Optional one-line summary (`description`).
|
||||
final String? description;
|
||||
|
||||
/// The tool's proposed input — echoed back (possibly modified) on allow.
|
||||
final Map<String, dynamic> input;
|
||||
|
||||
/// AskUserQuestion is answered through the same channel (D-78).
|
||||
bool get isQuestion => toolName == 'AskUserQuestion';
|
||||
}
|
||||
|
||||
/// A decision returned for a [ToolPrompt] over the control channel (D-78).
|
||||
sealed class ToolDecision {
|
||||
const ToolDecision();
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
/// Allow the tool. [updatedInput] is REQUIRED by the protocol — pass the
|
||||
/// request's input unchanged to allow as-is, or modified to alter the call.
|
||||
/// For AskUserQuestion, include the `answers` map (question text → label).
|
||||
final class AllowTool extends ToolDecision {
|
||||
const AllowTool(this.updatedInput);
|
||||
final Map<String, dynamic> updatedInput;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'behavior': 'allow', 'updatedInput': updatedInput};
|
||||
}
|
||||
|
||||
/// Deny the tool with a user-facing [message] (required by the protocol).
|
||||
final class DenyTool extends ToolDecision {
|
||||
const DenyTool(this.message);
|
||||
final String message;
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
|
||||
}
|
||||
|
||||
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
||||
/// and sends user messages.
|
||||
/// answers control-channel prompts, and sends user messages.
|
||||
class StreamJsonSession {
|
||||
StreamJsonSession(this._proc);
|
||||
|
||||
@@ -85,6 +151,18 @@ class StreamJsonSession {
|
||||
SessionStatus _status = const SessionStatus();
|
||||
int _localSeq = 0;
|
||||
|
||||
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
|
||||
/// is the one currently shown in the composer zone.
|
||||
final _queue = <ToolPrompt>[];
|
||||
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
|
||||
|
||||
/// The prompt currently awaiting a decision (queue head), or null.
|
||||
ToolPrompt? get pendingPrompt => _queue.isEmpty ? null : _queue.first;
|
||||
|
||||
/// Emits the current pending prompt (or null) whenever it changes — the
|
||||
/// composer zone swaps between the prompt UI and the text input on this.
|
||||
Stream<ToolPrompt?> get pendingPromptStream => _pendingCtl.stream;
|
||||
|
||||
/// Conversation items, in arrival order (assistant turns + the local echo
|
||||
/// of the user's own messages).
|
||||
Stream<ConversationItem> get items => _items.stream;
|
||||
@@ -100,6 +178,18 @@ class StreamJsonSession {
|
||||
void _onLine(String line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || !trimmed.startsWith('{')) return;
|
||||
final Map<String, dynamic> ev;
|
||||
try {
|
||||
ev = (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
// Control-channel requests (permission asks, AskUserQuestion) must be
|
||||
// routed out of the normal event stream and answered (D-78).
|
||||
if (ev['type'] == 'control_request') {
|
||||
_onControlRequest(ev);
|
||||
return;
|
||||
}
|
||||
// Items + assistant model/tokens reuse the transcript parser (identical
|
||||
// message.content shapes).
|
||||
final parsed = parseTranscriptChunk(trimmed);
|
||||
@@ -108,17 +198,51 @@ class StreamJsonSession {
|
||||
}
|
||||
// The `init` event carries permission mode (no `permission-mode` record
|
||||
// exists in stream-json); fold it in alongside the parsed deltas.
|
||||
_mergeStatus(parsed.status.merge(_statusFromEvent(trimmed)));
|
||||
_mergeStatus(parsed.status.merge(_statusFromEvent(ev)));
|
||||
}
|
||||
|
||||
SessionStatus _statusFromEvent(String line) {
|
||||
Object? j;
|
||||
try {
|
||||
j = jsonDecode(line);
|
||||
} catch (_) {
|
||||
return const SessionStatus();
|
||||
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
|
||||
/// item the UI resolves; every other subtype is answered with an error so
|
||||
/// the turn never hangs waiting on us (D-78).
|
||||
void _onControlRequest(Map<String, dynamic> ev) {
|
||||
final rid = ev['request_id'] as String?;
|
||||
final request = ev['request'];
|
||||
if (rid == null || request is! Map) return;
|
||||
if (request['subtype'] == 'can_use_tool') {
|
||||
final toolName = request['tool_name'] as String? ?? '';
|
||||
final input = (request['input'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
|
||||
_queue.add(ToolPrompt(
|
||||
promptId: rid,
|
||||
toolName: toolName,
|
||||
displayName: request['display_name'] as String? ?? toolName,
|
||||
description: request['description'] as String?,
|
||||
toolUseId: request['tool_use_id'] as String? ?? '',
|
||||
input: input,
|
||||
));
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
return; // awaits resolvePrompt
|
||||
}
|
||||
if (j is! Map) return const SessionStatus();
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
|
||||
}));
|
||||
}
|
||||
|
||||
/// Answer a [ToolPrompt] over the control channel, by its
|
||||
/// [ToolPrompt.promptId]. No-op if unknown or already resolved. Advances the
|
||||
/// queue so the next pending prompt (if any) surfaces.
|
||||
void resolvePrompt(String promptId, ToolDecision decision) {
|
||||
final before = _queue.length;
|
||||
_queue.removeWhere((p) => p.promptId == promptId);
|
||||
if (_queue.length == before) return; // unknown / already resolved
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'control_response',
|
||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||
}));
|
||||
_pendingCtl.add(pendingPrompt);
|
||||
}
|
||||
|
||||
SessionStatus _statusFromEvent(Map<String, dynamic> j) {
|
||||
if (j['type'] == 'system' && j['subtype'] == 'init') {
|
||||
return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?);
|
||||
}
|
||||
@@ -155,5 +279,6 @@ class StreamJsonSession {
|
||||
await _proc.kill();
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
await _pendingCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user