add StreamJsonSession transport (T-165 foundation)
test / unit + widget + golden + a11y (push) Failing after 25s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 23s
test / unit + widget + golden + a11y (push) Failing after 25s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 23s
The testable core of the stream-json pivot (D-77): StreamJsonSession drives a claude process in stream-json mode — parsing its line-delimited events into the existing ConversationItem / SessionStatus types (reusing parseTranscriptChunk, since stream-json assistant/user events share the transcript's message.content shapes), pulling permission-mode off the init event, and sending user input as stream-json over stdin (with a local echo so the user's own message renders immediately). The process is abstracted behind StreamJsonProcess so it unit-tests without spawning; the real ClaudeStreamJsonProcess wraps Process.start. Not yet wired into the pane — the claude_pane integration (replace the tmux/PTY spawn + TranscriptReader feed, route input through send) and live verification are the next step. T-165. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,11 @@ INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'T-152', '2026-05-23 09:52:40', '2026-05-23 09:52:40', NULL, 'c1958579d0319728ff264fff515fa5f9', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'T-153', '2026-05-23 09:52:43', '2026-05-23 09:52:43', NULL, '697ba3998bb654d0e490413d32426c1a', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'T-154', '2026-05-23 09:52:45', '2026-05-23 09:52:45', NULL, 'b66731a8e02d061e324c707e499f3768', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-165', 'T-166', '2026-05-24 16:27:29', '2026-05-24 16:27:29', NULL, 'aa2cff48755c11f6b81679d48c5f2c03', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-165', 'T-167', '2026-05-24 16:27:29', '2026-05-24 16:27:29', NULL, 'b123c2f27de04ba814d71b5f521e761d', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-165', 'T-168', '2026-05-24 16:27:29', '2026-05-24 16:27:29', NULL, 'f1069ecd7d3725941b5bdad1db49db28', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-165', 'T-169', '2026-05-24 16:27:37', '2026-05-24 16:27:37', NULL, '7faf9e3d99a7f5cbee4ca7fbd5b2c2e8', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'T-170', '2026-05-24 16:27:43', '2026-05-24 16:27:43', NULL, 'e7dd5c5e9a4683aadc8276821dd925b4', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'T-171', '2026-05-24 16:27:46', '2026-05-24 16:27:46', NULL, '6f283faa601b8cbccc50d9a4c9a49ee2', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-170', 'T-171', '2026-05-24 16:27:52', '2026-05-24 16:27:52', NULL, '1815c2248aebfb6e4806d1dffb9e3ea2', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
INSERT INTO ticket_deps (blocker_id, blocked_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-169', 'T-172', '2026-05-24 16:27:56', '2026-05-24 16:27:56', NULL, '9bb8d0b4d690668248a8e1b063103bcd', 1) ON CONFLICT(blocker_id, blocked_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||
|
||||
@@ -2221,3 +2221,4 @@ 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-157', 'status', 'in_progress', 'done', NULL, '2026-05-23 20:57:07', '2026-05-23 20:57:07', '2026-05-23 20:57:07', NULL, 'ce114090979d31512a9aa09f1c9311a1', 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-159', 'status', 'backlog', 'done', NULL, '2026-05-24 08:54:30', '2026-05-24 08:54:30', '2026-05-24 08:54:30', NULL, '478956f24d051f603aab2c7ceec328b2', 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-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;
|
||||
|
||||
@@ -2691,3 +2691,19 @@ Leading hypothesis: T-156''s _respawnWithSession (/clear, /resume) kills the tmu
|
||||
To localise: does a BRAND-NEW Claude tab (fresh spawn, no respawn) accept input and respond? If yes, the respawn path is the culprit; if no, the spawn path itself regressed. Also check the make-run console for pane.spawn errors / ''session exited''.
|
||||
|
||||
Acceptance: after /clear and /resume, a live tmux session (or no-tmux PTY) exists and typed input reaches Claude and streams a response; clide''s _usingTmux/_paneId/_sessionName stay consistent with the actual spawned process; regression test or a documented manual verification. Relates to T-156, T-147 (self-heal), D-41.', 'done', 'high', NULL, NULL, NULL, '2026-05-24 09:24:02', '2026-05-24 09:45:07', NULL, 'e7e053905caff3775e6aeb5eab4c0039', 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-164', 'epic', NULL, 'Claude via stream-json + unified clide-managed agent/session model', 'Per D-77: pivot the Claude pane from the interactive tmux TUI to Claude Code''s stream-json control protocol, and build a unified clide-managed agent/session model on top.
|
||||
|
||||
Why: the interactive TUI can''t surface AskUserQuestion / permission prompts (they never reach the transcript), transcript-file tailing is lossy + caused the session-fork/--session-id bug class, and Claude''s agent-team mode is tmux/interactive-only. stream-json fixes all three: structured events, native canUseTool prompt handling, --resume persistence — and lets clide OWN orchestration (teammate / secondary tab / forked branch / inline subagent all collapse into one primitive: a clide-managed session rendered as a pane; the sidebar becomes its cockpit).
|
||||
|
||||
Phase 1 (single-agent, ships the prompt-gap + lifecycle fix): stream-json transport, native permission/AskUserQuestion UI, --resume persistence, leveled-up native rendering.
|
||||
Phase 2 (unified model): session orchestrator (spawn/show/hide), clide-hosted MCP coordination broker + injected team-awareness, sidebar cockpit (tasks + messages + roster controls), fork-into-a-pane.
|
||||
|
||||
Supersedes the transcript-tail + observe-tmux-team approach of epic T-132. Acceptance: Claude runs over stream-json with native prompts + --resume; teams are clide-orchestrated managed sessions controlled from the sidebar; the tmux dependency for Claude is gone (terminal builtin retained for general shell).', 'backlog', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:05', '2026-05-24 16:26:05', NULL, '024365f2199feee1ce06cd6d62ed2013', 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.', 'backlog', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:23', '2026-05-24 16:26:23', NULL, '7815b0a0af62b3aff885a94a96bbc47f', 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-167', 'task', 'T-164', 'P1: session persistence + /clear + /resume on stream-json (--resume)', 'Move session lifecycle onto the stream-json model. New session: --session-id <uuid>; resume an existing one: --resume <id> (T-161 confirmed --session-id refuses an existing id). Re-implement the clide-owned commands on this model: /clear = new fresh session, /resume = picker then resume the picked id. Retire the tmux session lifecycle for Claude (kill/hasSession/reap, new-session -A) and the --session-id-in-use handling once the transport no longer uses tmux. Acceptance: restart resumes the primary''s session; /clear starts empty; /resume switches to a picked session; all without tmux; tests for the new/resume argv selection and the command flows. Supersedes the mechanics of T-156/T-161. Blocked by T-165. Amends D-41 (tmux persistence → --resume).', 'backlog', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:30', '2026-05-24 16:26:30', NULL, '076b301a0ab50ad9c9f845dc43ba174e', 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-168', 'task', 'T-164', 'P1: native rendering leveled up — typed event cards + streaming + live status', 'Exploit the structured stream the transcript-tail never cleanly gave. Per-tool cards from tool_use input (Edit/Write → diff, Bash → command+output, Read/Grep → file/query); pair each tool_result to its tool_use (with is_error) so diffs/failures render in place; stream assistant text + thinking blocks token-by-token via --include-partial-messages; surface live model / permission-mode / context-tokens / cost and rate_limit_event straight off the init + result events (retires the ClaudeConfig probe + status-bar guessing for the active pane); render structured errors as events. Acceptance: a session with edits/bash/reads shows the right card per tool with paired results; replies stream; the status line reflects live model/mode/context/cost from events; widget + fixture tests. Blocked by T-165.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:39', '2026-05-24 16:26:39', NULL, '07595343a6ca4aab533282ae045e30d1', 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-169', 'task', 'T-164', 'P2: session orchestrator — one managed-session primitive (spawn/show/hide)', 'Generalize the Claude pane into a clide-managed SESSION primitive: clide spawns/owns each stream-json Claude process; a pane is just a view on a session; teammate / secondary tab / forked branch / inline subagent are all the same thing. Provide spawn / show / hide / close over the set of live sessions; visibility is a UI toggle, not a lifecycle change (sessions stay live when hidden). Re-point the T-139 TeamObserver from observing a Claude tmux team to ORCHESTRATING clide-managed sessions; the T-140 tiles render whatever sessions are shown. Acceptance: multiple managed sessions run concurrently; each can be shown/hidden as a pane from a roster without tearing down its process; tests for the orchestrator lifecycle. Blocked by T-165 (phase 1 transport).', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:49', '2026-05-24 16:26:49', NULL, '02a0991d91d79696af98db0673c5b7df', 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-170', 'task', 'T-164', 'P2: clide-hosted MCP coordination broker + team-awareness injection', 'Give clide-managed sessions team behavior that Claude''s tmux mode provided (and that is undocumented + headless-unavailable). clide hosts a small MCP server (attached to each agent via --mcp-config) exposing team tools: send_message(to,text), broadcast(text), list_teammates(), inbox(), and a shared claim_task/task_status. clide is the BROKER — a tool call from agent A is delivered into agent B''s next turn on B''s stream-json stdin; clide owns routing/ordering. Inject team-awareness into each agent via --append-system-prompt (roster + role) and/or --agents. Acceptance: two managed agents exchange messages through the broker and see a shared task list; a lead can address one teammate by name; tests for the broker routing + the MCP tool surface. Blocked by T-169.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:01', '2026-05-24 16:27:01', NULL, '11457574f4c3a752ef4b60edb639848f', 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-171', 'task', 'T-164', 'P2: sidebar cockpit — task list + messages + roster controls', 'Evolve the Claude meta sidebar (T-141/T-157) from a read-only roster into the CONTROL surface for clide-managed agents. Because clide owns the broker, the task list and inter-agent messages are local data — so the sidebar both shows them live and lets the user ACT: spawn / show / hide / mute an agent, reassign a task, inject or redirect a message. Roster rows keep the live per-member status (T-157) but gain controls. Acceptance: from the sidebar the user can spawn/show/hide an agent, watch the shared task list update, see the message flow, and intervene (reassign/inject); widget tests for the controls wired to the orchestrator + broker. Blocked by T-169 (orchestrator) and T-170 (broker).', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:09', '2026-05-24 16:27:09', NULL, '1134d3b5bbef2b2017d43a56b9fdac39', 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-172', 'task', 'T-164', 'P2: fork-into-a-pane (--fork-session)', 'Branch a conversation into a new managed session/pane, reusing the orchestrator''s spawn plumbing. ''Fork'' = spawn a managed session seeded from another session''s context via --resume + --fork-session (resume the source, but create a new session-id so the branch diverges without touching the original). Surface it as a slash action and a sidebar/roster button on a session. Acceptance: forking an active session opens a new pane continuing from its context, independent thereafter; the original is unaffected; tests for the fork argv + the new managed session appearing in the roster. Blocked by T-169 (orchestrator).', 'backlog', 'low', NULL, NULL, 'D-77', '2026-05-24 16:27:16', '2026-05-24 16:27:16', NULL, 'e27d5e85bb9a9ce26ae8756b36448d44', 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).', 'in_progress', 'high', NULL, NULL, 'D-77', '2026-05-24 16:26:15', '2026-05-24 16:32:59', NULL, '60aad91b8c9af23881c71ffc6c3082a8', 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);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/// Drives a `claude` process in stream-json mode (T-165, D-77).
|
||||
///
|
||||
/// Instead of running an interactive TUI in tmux and tailing the transcript
|
||||
/// file, clide spawns `claude --input-format stream-json --output-format
|
||||
/// stream-json --verbose`, reads its line-delimited JSON events, and turns
|
||||
/// them into the same [ConversationItem] / [SessionStatus] stream the
|
||||
/// transcript reader produced — so the conversation view, controller, and
|
||||
/// status surface are reused unchanged. User input is written to the
|
||||
/// process stdin as a stream-json user message.
|
||||
///
|
||||
/// The event-stream parsing reuses [parseTranscriptChunk]: stream-json
|
||||
/// `assistant` / `user` events carry the same `message.content` block shapes
|
||||
/// as transcript records. Status fields the transcript parser doesn't read
|
||||
/// (permission mode on the `init` event) are pulled here.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
|
||||
/// The claude subprocess, abstracted so tests drive it without spawning.
|
||||
abstract class StreamJsonProcess {
|
||||
/// stdout, one JSON event per line.
|
||||
Stream<String> get lines;
|
||||
|
||||
/// Write one JSON message line to stdin.
|
||||
void writeLine(String line);
|
||||
|
||||
/// Terminate the process.
|
||||
Future<void> kill();
|
||||
}
|
||||
|
||||
/// Production [StreamJsonProcess] backed by a real `claude` process.
|
||||
class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc);
|
||||
|
||||
final Process _proc;
|
||||
|
||||
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
||||
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
||||
static Future<ClaudeStreamJsonProcess> start({
|
||||
required List<String> sessionArgs,
|
||||
required String cwd,
|
||||
Map<String, String>? env,
|
||||
}) async {
|
||||
final proc = await Process.start(
|
||||
'claude',
|
||||
[
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
...sessionArgs,
|
||||
],
|
||||
workingDirectory: cwd,
|
||||
environment: env,
|
||||
);
|
||||
return ClaudeStreamJsonProcess._(proc);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _proc.stdout.transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
@override
|
||||
void writeLine(String line) => _proc.stdin.writeln(line);
|
||||
|
||||
@override
|
||||
Future<void> kill() async {
|
||||
_proc.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
||||
/// and sends user messages.
|
||||
class StreamJsonSession {
|
||||
StreamJsonSession(this._proc);
|
||||
|
||||
final StreamJsonProcess _proc;
|
||||
final _items = StreamController<ConversationItem>.broadcast();
|
||||
final _statusCtl = StreamController<SessionStatus>.broadcast();
|
||||
StreamSubscription<String>? _sub;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
int _localSeq = 0;
|
||||
|
||||
/// Conversation items, in arrival order (assistant turns + the local echo
|
||||
/// of the user's own messages).
|
||||
Stream<ConversationItem> get items => _items.stream;
|
||||
|
||||
/// Session status (model / permission-mode / context tokens), on change.
|
||||
Stream<SessionStatus> get statusStream => _statusCtl.stream;
|
||||
|
||||
/// Begin consuming the process's event stream.
|
||||
void start() {
|
||||
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
|
||||
}
|
||||
|
||||
void _onLine(String line) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || !trimmed.startsWith('{')) return;
|
||||
// Items + assistant model/tokens reuse the transcript parser (identical
|
||||
// message.content shapes).
|
||||
final parsed = parseTranscriptChunk(trimmed);
|
||||
for (final item in parsed.items) {
|
||||
_items.add(item);
|
||||
}
|
||||
// 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)));
|
||||
}
|
||||
|
||||
SessionStatus _statusFromEvent(String line) {
|
||||
Object? j;
|
||||
try {
|
||||
j = jsonDecode(line);
|
||||
} catch (_) {
|
||||
return const SessionStatus();
|
||||
}
|
||||
if (j is! Map) return const SessionStatus();
|
||||
if (j['type'] == 'system' && j['subtype'] == 'init') {
|
||||
return SessionStatus(model: j['model'] as String?, permissionMode: j['permissionMode'] as String?);
|
||||
}
|
||||
return const SessionStatus();
|
||||
}
|
||||
|
||||
void _mergeStatus(SessionStatus delta) {
|
||||
if (delta.isEmpty) return;
|
||||
final next = _status.merge(delta);
|
||||
if (next != _status) {
|
||||
_status = next;
|
||||
_statusCtl.add(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send [text] to claude as a stream-json user message, and echo it locally
|
||||
/// so it renders immediately (stream-json doesn't replay stdin without
|
||||
/// `--replay-user-messages`).
|
||||
void send(String text) {
|
||||
_proc.writeLine(jsonEncode({
|
||||
'type': 'user',
|
||||
'message': {'role': 'user', 'content': text},
|
||||
}));
|
||||
_items.add(UserMessage(
|
||||
uuid: 'local-${_localSeq++}',
|
||||
timestamp: DateTime.now(),
|
||||
isSidechain: false,
|
||||
text: text,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _sub?.cancel();
|
||||
await _proc.kill();
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
final _ctl = StreamController<String>();
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _ctl.stream;
|
||||
@override
|
||||
void writeLine(String line) => writes.add(line);
|
||||
@override
|
||||
Future<void> kill() async => killed = true;
|
||||
|
||||
void emit(String line) => _ctl.add(line);
|
||||
}
|
||||
|
||||
String assistantText(String text) => jsonEncode({
|
||||
'type': 'assistant',
|
||||
'uuid': 'a1',
|
||||
'message': {
|
||||
'model': 'claude-opus-4-7',
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{'type': 'text', 'text': text},
|
||||
],
|
||||
'usage': {'input_tokens': 100, 'cache_read_input_tokens': 50, 'cache_creation_input_tokens': 0},
|
||||
},
|
||||
});
|
||||
|
||||
String assistantToolUse() => jsonEncode({
|
||||
'type': 'assistant',
|
||||
'uuid': 'a2',
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': [
|
||||
{
|
||||
'type': 'tool_use',
|
||||
'id': 't1',
|
||||
'name': 'Bash',
|
||||
'input': {'command': 'ls'}
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
String initEvent() => jsonEncode({
|
||||
'type': 'system',
|
||||
'subtype': 'init',
|
||||
'model': 'claude-opus-4-7',
|
||||
'permissionMode': 'default',
|
||||
});
|
||||
|
||||
void main() {
|
||||
late _FakeProc proc;
|
||||
late StreamJsonSession session;
|
||||
late List<ConversationItem> items;
|
||||
late List<SessionStatus> statuses;
|
||||
|
||||
setUp(() {
|
||||
proc = _FakeProc();
|
||||
session = StreamJsonSession(proc);
|
||||
items = [];
|
||||
statuses = [];
|
||||
session.items.listen(items.add);
|
||||
session.statusStream.listen(statuses.add);
|
||||
session.start();
|
||||
});
|
||||
|
||||
tearDown(() => session.dispose());
|
||||
|
||||
test('parses assistant text + tool_use events into items', () async {
|
||||
proc.emit(assistantText('hello there'));
|
||||
proc.emit(assistantToolUse());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(items, hasLength(2));
|
||||
expect(items[0], isA<AssistantTextMessage>());
|
||||
expect((items[0] as AssistantTextMessage).text, 'hello there');
|
||||
expect(items[1], isA<AssistantToolUse>());
|
||||
final tu = items[1] as AssistantToolUse;
|
||||
expect(tu.name, 'Bash');
|
||||
expect(tu.toolUseId, 't1');
|
||||
expect(tu.input['command'], 'ls');
|
||||
});
|
||||
|
||||
test('derives status: model + tokens from assistant, permission-mode from init', () async {
|
||||
proc.emit(initEvent());
|
||||
proc.emit(assistantText('hi'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(statuses.last.model, 'claude-opus-4-7');
|
||||
expect(statuses.last.permissionMode, 'default');
|
||||
expect(statuses.last.contextTokens, 150); // 100 + 50 + 0
|
||||
});
|
||||
|
||||
test('only emits status on change', () async {
|
||||
proc.emit(initEvent());
|
||||
proc.emit(initEvent()); // identical → no second emit
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(statuses, hasLength(1));
|
||||
});
|
||||
|
||||
test('ignores blank and non-JSON lines', () async {
|
||||
proc.emit('');
|
||||
proc.emit('not json');
|
||||
proc.emit(' ');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(items, isEmpty);
|
||||
expect(statuses, isEmpty);
|
||||
});
|
||||
|
||||
test('send writes a stream-json user message and echoes it locally', () async {
|
||||
session.send('do the thing');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(proc.writes, hasLength(1));
|
||||
final sent = jsonDecode(proc.writes.single) as Map<String, Object?>;
|
||||
expect(sent['type'], 'user');
|
||||
expect((sent['message'] as Map)['content'], 'do the thing');
|
||||
|
||||
final echoed = items.whereType<UserMessage>().toList();
|
||||
expect(echoed, hasLength(1));
|
||||
expect(echoed.single.text, 'do the thing');
|
||||
});
|
||||
|
||||
test('dispose kills the process', () async {
|
||||
await session.dispose();
|
||||
expect(proc.killed, isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user