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:
2026-05-25 08:40:18 +02:00
co-authored by Claude Opus 4.7
parent 8310730506
commit 39f9ed018c
8 changed files with 635 additions and 261 deletions
+134 -9
View File
@@ -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();
}
}