diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 612165ef..6cec1d7d 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2223,3 +2223,7 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-161', 'status', 'backlog', 'done', NULL, '2026-05-24 09:45:07', '2026-05-24 09:45:07', '2026-05-24 09:45:07', NULL, 'd7b99c3dff59fc5a70a7866567fbfe0f', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-165', 'status', 'backlog', 'in_progress', NULL, '2026-05-24 16:32:59', '2026-05-24 16:32:59', '2026-05-24 16:32:59', NULL, '1dbcae2366b429ad26e65f1a9cfebc33', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-173', 'status', 'backlog', 'in_progress', NULL, '2026-05-24 17:00:14', '2026-05-24 17:00:14', '2026-05-24 17:00:14', NULL, 'c0306e7ca9f755e69c76cdba93bca934', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-173', 'status', 'in_progress', 'done', NULL, '2026-05-24 17:10:07', '2026-05-24 17:10:07', '2026-05-24 17:10:07', NULL, 'f5831d8bfa3e484546cd5dd0c75bbf22', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-166', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 06:23:55', '2026-05-25 06:23:55', '2026-05-25 06:23:55', NULL, '36cc84f2de6519355a55014b107ecea8', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-165', 'status', 'in_progress', 'done', NULL, '2026-05-25 06:29:25', '2026-05-25 06:29:25', '2026-05-25 06:29:25', NULL, '34961afff4d3384bced64de93c977460', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-166', 'status', 'in_progress', 'done', NULL, '2026-05-25 06:31:08', '2026-05-25 06:31:08', '2026-05-25 06:31:08', NULL, '3d6b8f30317a82d0aa810b6da47896be', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 5300b9b7..ac8a4330 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2723,3 +2723,26 @@ FUTURE-PROOFING (the point): MIGRATION: route the existing switch in _ConversationTurn through the primitive; the three shapes become variants. Lands independently of the stream-json transport (works on the current conversation_view) but is the base T-168 builds on. Acceptance: every turn type renders through the one primitive; hover-copy yields the right raw text per type; collapsible types expand/collapse (tool-result/thinking default-collapsed); actions are keyboard-focusable + Semantics-labelled (a11y); widget tests for copy, collapse, each variant, and an injected custom action; coverage >= floor. Feeds T-168.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:45:40', '2026-05-24 17:00:14', NULL, '315d3cc6c134fc5a48a8527e62290554', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-173', 'task', 'T-164', 'Conversation message-card primitive: hover copy + collapse/expand, extensible base', 'Replace the three ad-hoc card shapes in conversation_view.dart (_messageCard stripe / _labelled / _card bordered) with ONE base primitive every turn renders through, with hover affordances wired into the base so all message types (now + future) get them consistently. + +PRIMITIVE — ConversationCard (lib/builtin/claude/src/conversation_card.dart): +- Props: variant (stripe | bordered | bare — preserves the user/claude vs tool/thinking visual distinction), accent (Color), label (String), body (Widget content slot), copyText (raw text the copy action yields), actions (List — extensible), collapsible (bool) + collapsedByDefault (bool). +- Chrome: the label row + a hover-revealed action bar (top-right): a copy button (ClideClipboard, copies copyText), a collapse/expand toggle when collapsible, and an extensible slot for future per-message actions. Body sits below. +- Behaviors: actions reveal on hover (MouseRegion) but are ALWAYS reachable for keyboard/a11y (Semantics button + focusable, per the keyboard-operability work — never hover-only); collapse renders a short preview + an expand affordance; default-collapsed for noisy types (tool results, thinking, very long bodies); coexists with the surrounding SelectionArea (button-copy AND drag-select both work — no nested SelectionArea). + +FUTURE-PROOFING (the point): +- MessageAction { icon, label, onInvoke } is an open list — copy is just the first. Future actions (fork-from-here, retry, edit, jump-to, save) drop in without touching the template; ties directly into D-77''s unified-session model (e.g. fork-from-here = spawn a managed session seeded from this turn). +- The card is DECOUPLED from ConversationItem — _ConversationTurn maps each item to (variant, accent, label, body, copyText, actions). So T-168''s richer typed cards (Edit/Write diff, Bash command+output, Read/Grep) reuse the SAME chrome with a different body + copyText + maybe extra actions; nothing in the template hardcodes a message kind. +- Per-card collapse policy (not global) so prose stays open while tool noise collapses. +- Variant enum keeps today''s look while unifying the implementation. + +MIGRATION: route the existing switch in _ConversationTurn through the primitive; the three shapes become variants. Lands independently of the stream-json transport (works on the current conversation_view) but is the base T-168 builds on. + +Acceptance: every turn type renders through the one primitive; hover-copy yields the right raw text per type; collapsible types expand/collapse (tool-result/thinking default-collapsed); actions are keyboard-focusable + Semantics-labelled (a11y); widget tests for copy, collapse, each variant, and an injected custom action; coverage >= floor. Feeds T-168.', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:45:40', '2026-05-24 17:10:07', NULL, '40e4f102209bfacd4b437a5ee268a9d7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-174', 'task', 'T-164', 'ConversationCard actions: keyboard-focusable + always-reachable (not hover-only)', 'Follow-up split out of T-173. The ConversationCard action bar (copy button + custom MessageActions) currently renders only while the card is hovered (lib/builtin/claude/src/conversation_card.dart, _header: ''if (_hover) ..._actions''). The collapse/expand caret is always visible, but the action buttons are not keyboard/AT reachable when not hovering. + +Make the actions always reachable per T-173''s acceptance: keep them in the tree always (reveal visually on hover OR focus via opacity), add Semantics onTap so assistive tech can activate them, and wire keyboard focus traversal + Enter/Space activation through clide''s keyboard-operability framework (the _tap helper uses a bare GestureDetector today, which is mouse/AT-tap only, not focus-traversable). Applies to both the action buttons and the caret. + +Acceptance: copy + custom actions are reachable and activatable by keyboard alone (Tab to focus, Enter/Space to invoke) with no mouse; Semantics button labels present; widget test drives a copy via focus+activation (no hover); a11y gate green.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-24 17:11:17', '2026-05-24 17:11:17', NULL, '314da812ab94cd8467d14310d8ceaedf', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-165', 'task', 'T-164', 'P1: stream-json transport — drive the Claude pane via the control protocol', 'Spawn the Claude pane as ''claude --input-format stream-json --output-format stream-json --verbose'' (+ --include-partial-messages later) instead of an interactive TUI in tmux. Multiplex its stdin/stdout; parse the event stream (system/init, assistant, tool_use, tool_result, result, rate_limit_event); feed the existing ConversationController/MessageBus from events rather than tailing the transcript JSONL. Isolate the protocol behind one module (the drift-containment point, like the transcript reader). Acceptance: a Claude pane converses end-to-end over stream-json (send a prompt, stream the reply) with no tmux session; events drive the native conversation view; unit tests against canned event fixtures. Blocks the rest of phase 1. Evolves D-75 (rendering now sourced from events).', 'done', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:15', '2026-05-25 06:29:25', NULL, '7412d6cc50f12cf6744b233a693fe0d7', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-166', 'task', 'T-164', 'P1: native permission + AskUserQuestion prompts via canUseTool', 'Handle interactive prompts that the TUI model couldn''t surface. Wire Claude''s permission requests and the AskUserQuestion tool through the control mechanism (--permission-prompt-tool / SDK-style canUseTool: toolName + input, return allow/deny or the chosen answers). Render NATIVE prompt cards in the Claude pane — allow/deny for permissions, an option picker (matching AskUserQuestion''s questions/options) for AskUserQuestion — and return the user''s choice over the protocol. Acceptance: asking Claude for an AskUserQuestion shows selectable options inline and the selection is delivered; a tool needing permission shows allow/deny and the decision flows back; widget tests for the prompt cards + a fake control channel. Closes the prompt gap that motivated D-77. Blocked by T-165.', 'done', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:23', '2026-05-25 06:31:08', NULL, 'f977efccacc49cfbf0c83a465c137479', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/docs/spikes/cc-stream-json-control-protocol-2.1.150.md b/docs/spikes/cc-stream-json-control-protocol-2.1.150.md new file mode 100644 index 00000000..bee7e827 --- /dev/null +++ b/docs/spikes/cc-stream-json-control-protocol-2.1.150.md @@ -0,0 +1,210 @@ +# Spike: Claude Code stream-json control protocol (T-165 / T-166) + +**Pinned to:** claude **2.1.150**. Undocumented, version-drifting internal contracts +(per D-75/D-77) — re-validate on a CC bump, keyed off the transcript/event `version` +field and `claude_code_version` in the `init` event. + +**Method:** drove the real `claude` binary as a subprocess over stdin/stdout +(`docs/spikes/` driver scripts were throwaway; the captured logs are the source of +truth) **and** read the shipped implementation directly — the CLI is a ~238 MB node +SEA at `~/.local/share/claude/versions/2.1.150`; `strings` on it exposes the zod +schemas and the request/response builders. Every shape below was confirmed live +(a real prompt round-tripped) unless marked otherwise. Cost a handful of paid turns. + +--- + +## 0. TL;DR for the implementation + +Spawn: + +``` +claude --input-format stream-json --output-format stream-json --verbose \ + --permission-prompt-tool stdio [ --resume | --session-id ] +``` + +- **`--permission-prompt-tool stdio` is mandatory** to receive permission prompts. + Without it, any tool that resolves to "ask" is **auto-denied** (no prompt reaches + the client) — confirmed: a `Write` came back as `permission_denials` + an + `is_error` tool_result "you haven't granted it yet", never a control request. + `stdio` is a hidden value (not in `--help`); the SDK uses it (`f.sdkUrl?"stdio":…`). +- Read stdout as line-delimited JSON. Most lines are normal stream events + (`system`/`assistant`/`user`/`result`/`rate_limit_event`); some are + `control_request`s you **must answer** or Claude hangs. +- Send a prompt as `{"type":"user","message":{"role":"user","content":"…"}}`. + stream-json does **not** echo your user messages back unless + `--replay-user-messages` — local-echo them yourself. + +--- + +## 1. Output event stream (stdout) + +One JSON object per line. Types seen: + +- `system`/`subtype:"hook_started"|"hook_progress"|"hook_response"` — session hooks + (e.g. SessionStart). Informational; ignore for rendering. +- `system`/`subtype:"init"` — **the config goldmine.** Carries `session_id`, `cwd`, + `model` (`claude-opus-4-7[1m]`), `permissionMode`, `tools[]`, `mcp_servers[]`, + `slash_commands[]`, `skills[]`, `agents[]`, `output_style`, `claude_code_version`, + `apiKeySource`, `plugins[]`. (This is a strong source for `ClaudeConfig` / status.) +- `assistant` — `{message:{model,id,content:[…],usage:{…}}, uuid, session_id, request_id}`. + **Content blocks are emitted as separate `assistant` events sharing one `message.id`** + (e.g. a `text` block, then a `tool_use` block). Block shapes are identical to the + transcript: `text` / `thinking` (+`signature`) / `tool_use`(`id`,`name`,`input`). + `message.usage` carries `input_tokens` + `cache_read_input_tokens` + + `cache_creation_input_tokens` (→ context-token count). +- `user` — tool results Claude received: `message.content:[{type:"tool_result", + tool_use_id,content,is_error}]` plus a top-level `tool_use_result` with richer + structured data (e.g. Bash `{stdout,stderr,interrupted,…}`). +- `result` — terminal turn summary: `result` (final text), `usage`, `total_cost_usd`, + `permission_denials[]`, `num_turns`, **`modelUsage..contextWindow`** (e.g. + `1000000`) **and `maxOutputTokens`** — i.e. the context-window *size* IS exposed + here (relevant to the T-158 budget gap; the remaining-budget % still is not). +- `rate_limit_event` — `{rate_limit_info:{status,resetsAt,rateLimitType,…}}`. + +The existing `parseTranscriptChunk` (transcript_reader.dart) parses the +`assistant`/`user` events as-is (same `message.content` shapes; missing +`uuid`/`timestamp` default harmlessly). Permission mode comes from `init`, not a +`permission-mode` record. + +## 2. Control protocol (bidirectional, same stdin/stdout) + +### Envelope +Claude → client: +```json +{"type":"control_request","request_id":"","request":{"subtype":"…", …}} +``` +Client → Claude (the reply): +```json +{"type":"control_response","response":{"subtype":"success","request_id":"","response":{…}}} +``` +On failure use `{"subtype":"error","request_id":"…","error":"…"}`. **`request_id` +must echo exactly.** An unanswered `control_request` hangs the turn. + +### `can_use_tool` (the permission ask) — confirmed live +Request: +```json +{"type":"control_request","request_id":"70c8…","request":{ + "subtype":"can_use_tool","tool_name":"Write","display_name":"Write", + "input":{"file_path":"…","content":"…"}, + "description":"banana.txt", + "permission_suggestions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}], + "tool_use_id":"toolu_…"}} +``` +**ALLOW** — `updatedInput` is **required** (a bare `{"behavior":"allow"}` is rejected +with a ZodError: *"updatedInput: expected record, received undefined"*). Echo the +`input` back unchanged to allow as-is, or modify it to alter the call: +```json +{"type":"control_response","response":{"subtype":"success","request_id":"70c8…", + "response":{"behavior":"allow","updatedInput":{ …the tool input… }}}} +``` +**DENY** — `message` is required: +```json +{"type":"control_response","response":{"subtype":"success","request_id":"70c8…", + "response":{"behavior":"deny","message":"User declined."}}} +``` +(Decision zod union also allows optional `updatedPermissions` on allow.) + +### AskUserQuestion — confirmed live (the non-obvious one) +AskUserQuestion is **not** answered with a `tool_result` (that path is rejected as a +dismissal — it even shows up in `permission_denials`). It is **permission-gated and +answered through the same `can_use_tool` channel**. Its `input` is exactly clide's +own AskUserQuestion shape: +`{questions:[{question,header,multiSelect,options:[{label,description}]}]}`. +Return the user's choice by injecting an **`answers` map into `updatedInput`**: +`answers : record(question-text → chosen-label)` (multi-select = comma-separated labels). +```json +{"behavior":"allow","updatedInput":{ + "questions":[ …echoed… ], + "answers":{"Do you prefer cats or dogs?":"Dogs"}}} +``` +Confirmed: Claude then reported *"You chose Dogs"*; the resulting tool_use_result was +`{questions:[…],answers:{"Do you prefer cats or dogs?":"Dogs"}}`. Leaving `answers` +empty makes Claude say "your selection didn't come through". + +### Other `control_request` subtypes (from the binary's zod schemas) +Client→Claude requests it accepts: `initialize`, `interrupt`, +`set_permission_mode {mode}`, `mcp_message {server_name,message}`. +Claude→client requests you may receive: `can_use_tool`, `hook_callback +{callback_id,input,tool_use_id?}`. **Answer every inbound control_request** (even +unknown subtypes — reply `error` "Unsupported…") or the turn stalls. There is also a +`control_cancel_request` for in-flight cancellation. + +### `initialize` handshake — optional, but a config goldmine +Sending it first is **not** what enables permissions (the `stdio` flag does that — +verified: initialize-then-Write was still auto-denied). But the response is rich: +```json +// → {"type":"control_request","request_id":"init-1","request":{"subtype":"initialize","hooks":{},"sdkMcpServers":[]}} +// ← response.response = {commands:[{name,description,argumentHint,aliases?}…], +// agents:[…], models:[…], output_style, available_output_styles, +// account:{email,organization,subscriptionType,apiProvider}, pid} +``` +`commands[]` carries **descriptions + argumentHints** the `init` event's bare +`slash_commands[]` lacks — better source for the typeahead (T-152) and ClaudeConfig. + +## 3. Implications for the tickets +- **T-165:** spawn with the flags in §0; feed `parseTranscriptChunk` from the + `assistant`/`user` events; pull `permissionMode`/`model` from `init`; local-echo + user sends. Isolate all protocol framing in one module (drift-containment, D-77). +- **T-166:** wire `can_use_tool` → native prompt card (allow/deny, using + `display_name`/`description`/`permission_suggestions`); AskUserQuestion → option + picker, returning `updatedInput.answers`. Answer **every** control_request. +- **ClaudeConfig (D-76):** prefer the `initialize` response's `commands[]` (has + descriptions) over the `init` `slash_commands[]`. + +## 4. MCP vs the control channel — which layer does what +These are **complementary, not competing** — a recurring point of confusion: +- **The conversation + permissions + AskUserQuestion ride the stream-json control + channel** (`can_use_tool` over stdin/stdout). This is the SDK-blessed transport and + what `canUseTool` maps to. T-165/T-166 use it. **MCP is not involved here.** +- **MCP is how you give Claude extra *tools/capabilities*.** That's exactly what the + VSCode/JetBrains extensions do: they host an MCP *server* exposing IDE features to + Claude (`mcp__ide__getDiagnostics`, `mcp__ide__executeCode`, openDiff) — they do + **not** route the conversation or permissions through MCP; permissions still go + through the CLI/control channel. D-77's clide-hosted MCP broker (T-170) is the same + idea: provide team messaging / task-sync tools to the agents. +- **One overlap exists:** `--permission-prompt-tool` accepts *either* the special + `stdio` value (control channel — what we use) *or* an **MCP tool name** (Claude + calls your MCP tool to get the allow/deny). So permissions *could* be routed through + MCP — but it's strictly more machinery than `stdio`, needs a server, and loses the + structured `permission_suggestions`/`display_name` the control request carries. + **Recommendation: keep permissions on `stdio`; reserve MCP for capability/tool + provision (IDE context later, the team broker in T-170).** + +## 5. Resilience — the stdio channel is brittle; the fallback menu (no empty slate) +**Decision (this spike):** carry the conversation + permissions + AskUserQuestion on +the **stdio control channel** (`--permission-prompt-tool stdio` + `can_use_tool`). +Rationale: most direct — no extra process, no loopback "networking", and Claude hands +us structured prompt metadata for free. **But it is an undocumented internal contract; +Anthropic can change or remove it on any version bump.** This section exists so that +if it shifts we start from a researched menu, not a blank page. + +**How we'd detect a break (canaries):** +- Pin `claude_code_version` (here **2.1.150**); diff it on every CC upgrade. +- Symptoms of regression: tools **auto-denied despite** `--permission-prompt-tool + stdio`; `can_use_tool` requests missing/renamed; `ZodError` tool_results rejecting + our `control_response` (e.g. the `updatedInput`-required quirk changing). +- The captured spike logs (this doc's source) double as **regression fixtures** — wire + them into the parser/control-handler tests so a CC bump that breaks shapes goes red. + +**Fallback menu, in rough preference order if `stdio` is withdrawn:** +1. **MCP permission tool** — `--permission-prompt-tool mcp__clide__approve` against the + clide-hosted MCP server (T-170 builds that server anyway). Claude calls our MCP + tool to get the allow/deny. More layers, but **MCP is a stable, public protocol** — + the strongest fallback precisely because the broker will already exist. +2. **Permission modes** — degrade to `--permission-mode acceptEdits` / `dontAsk` / + `bypassPermissions` for a reduced-fidelity UX (no per-call prompt) as a stopgap. + Public, stable flags; buys time to build a better path. +3. **Agent SDK** — if Anthropic ships/keeps a stable public `canUseTool` SDK surface + (TS/Python), shell out to or port it. Supported, but a heavier dep + language seam. +4. **ACP (Agent Client Protocol)** — if the ecosystem converges on ACP + (`session/request_permission`, Zed's path) as the blessed editor-integration + standard, adopt its adapter. Open standard; would reframe the whole transport. + +**Containment:** all protocol framing lives behind one module (per D-77), so swapping +transports is a one-seam change. Re-capture fixtures per pinned version. + +## Not done / open +- `hook_callback` and `mcp_message` round-trips not exercised live (shapes from the + binary only) — needed for the clide-hosted MCP broker (T-170). +- Image/file paste intake over stream-json `content` blocks not tested here. +- Remaining-usage budget % still not exposed (only `contextWindow` size, in `result`). diff --git a/governance/README.md b/governance/README.md index b0d8f3a2..f9ee1ea5 100644 --- a/governance/README.md +++ b/governance/README.md @@ -119,6 +119,7 @@ You might also want, project-permitting: - [D-75: Claude rendered natively from transcripts; terminal retained as general tool only](decisions/architecture.md#d-75-claude-rendered-natively-from-transcripts-terminal-retained-as-general-tool-only) — _architecture_ - [D-76: ClaudeConfig — Claude's config surface is clide's app settings (builtin-owned, watched, probe-cached per version)](decisions/architecture.md#d-76-claudeconfig--claudes-config-surface-is-clides-app-settings-builtin-owned-watched-probe-cached-per-version) — _architecture_ - [D-77: Drive Claude via the stream-json control protocol; teams become a clide-owned coordination layer](decisions/architecture.md#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer) — _architecture_ +- [D-78: Claude permission/prompt transport is the stdio control channel](decisions/architecture.md#d-78-claude-permissionprompt-transport-is-the-stdio-control-channel) — _architecture_ ## Open questions diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 623e7953..88fe2630 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -332,3 +332,15 @@ Core, rendering, IPC, kernel, panel manager. - **Raised by:** 2026-05-24 — user testing found the UI cannot handle AskUserQuestion / permission prompts (they live in the TUI, never hit the transcript). Spike (docs + SDK) confirmed stream-json surfaces prompts via `canUseTool` but agent teams are tmux-only; user chose the stream-json direction, accepting that teams must become clide-orchestrated. --- + +### D-78: Claude permission/prompt transport is the stdio control channel +- **Date:** 2026-05-25 +- **Status:** accepted +- **Decision:** Carry Claude's conversation, **permission prompts, and AskUserQuestion over the stream-json stdio control channel** — spawn with `--permission-prompt-tool stdio`, receive `can_use_tool` `control_request`s, reply with a `control_response`. **Do not route these through MCP.** MCP is reserved for the orthogonal job of giving agents *tools/capabilities* (IDE context; the team messaging/task broker per D-77/T-170). This refines D-77's abstract "`canUseTool` handling" into the concrete transport choice. +- **Empirically confirmed** (claude **2.1.150**; full shapes + fixtures in [`docs/spikes/cc-stream-json-control-protocol-2.1.150.md`](../../docs/spikes/cc-stream-json-control-protocol-2.1.150.md)): `stdio` is **mandatory** — without it, "ask" tools are silently **auto-denied** and no prompt reaches the client; an `allow` decision must echo back `updatedInput` (a bare `{behavior:"allow"}` is rejected); `deny` requires `message`; **AskUserQuestion is permission-gated through the same `can_use_tool` channel** and answered by injecting `updatedInput.answers` (question-text → chosen label), *not* via a `tool_result`. +- **Rationale:** Directness — no extra process or loopback "networking", and Claude hands us structured prompt metadata (`display_name` / `description` / `permission_suggestions`) for free. It is exactly what the SDK's `canUseTool` maps to. Routing permissions through MCP instead is strictly more machinery (the broker must be up before the first prompt; the structured fields must be hand-rolled) for no gain. +- **Cost / risk:** The control channel is an **undocumented internal contract pinned to a claude version** — Anthropic may change or remove it on any bump. Mitigation: pin `claude_code_version`; the captured spike logs serve as **regression fixtures / canaries**; all protocol framing lives behind one module (per D-77) so the transport is a one-seam swap. **Documented fallback menu** so a shift never lands us at a blank slate (preference order): (1) **MCP permission tool** — `--permission-prompt-tool mcp__clide__approve` against the T-170 broker (strongest; MCP is a public/stable protocol and the broker will already exist); (2) degrade to `--permission-mode acceptEdits` / `dontAsk` / `bypassPermissions` as a reduced-fidelity stopgap; (3) the **Agent SDK** if a stable public `canUseTool` surface ships; (4) **ACP** (`session/request_permission`) if the ecosystem converges on it. Detection symptoms + detail in the spike doc. +- **Cross-reference:** refines [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer); relates to [D-1](#d-1-cli-first-not-mcp) (clide hosts MCP for *capabilities*, never for the conversation/permission transport) and [D-76](#d-76-claudeconfig--claudes-config-surface-is-clides-app-settings-builtin-owned-watched-probe-cached-per-version). Implemented by T-166. +- **Raised by:** 2026-05-25 — during T-165/T-166 work, an empirical spike against claude 2.1.150 (driving the real CLI + reading the shipped binary's zod schemas) nailed the control-protocol shapes. User weighed stdio vs MCP for permissions, chose stdio for directness, and asked that the brittleness and researched alternatives be documented so a future Anthropic change doesn't leave clide without options. + +---