From e1cd4653c000dd1eeaf2f71492079a59a2c8cadb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 08:28:55 +0200 Subject: [PATCH] bind each Claude pane to its own session id (T-146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regression from T-137: every pane rendered the newest .jsonl in the workspace dir, so concurrent sessions collided — a secondary tab showed the primary's conversation. Each pane now spawns claude with its own --session-id (a transcript is named .jsonl), tails that exact file via TranscriptReader's file: param, and uses a per-session MessageBus channel so controllers don't cross-talk. The primary's id is deterministic from its session name (stable → it resumes across restarts, like /resume off the same history file); secondaries get a fresh random id so a clean session is always available. The reader now waits for the bound file to appear rather than throwing. Migration: an existing tmux session created before this (no --session-id, claude chose its own id) must be killed once (claude.kill-all-sessions) so the next spawn binds the controlled id. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_deps/2026-05.sql | 1 + .pql/changelog/ticket_history/2026-05.sql | 41 +++++++++++++++ .pql/changelog/tickets/2026-05.sql | 40 ++++++++++++++ CHANGELOG.md | 5 ++ lib/builtin/claude/src/claude_pane.dart | 25 +++++++-- lib/builtin/claude/src/session_naming.dart | 52 +++++++++++++++++++ .../claude/src/transcript_publisher.dart | 4 ++ lib/builtin/claude/src/transcript_reader.dart | 5 +- test/builtin/claude/session_naming_test.dart | 24 +++++++++ .../claude/transcript_reader_test.dart | 29 +++++++++++ 10 files changed, 222 insertions(+), 4 deletions(-) diff --git a/.pql/changelog/ticket_deps/2026-05.sql b/.pql/changelog/ticket_deps/2026-05.sql index 47915ab7..17f98637 100644 --- a/.pql/changelog/ticket_deps/2026-05.sql +++ b/.pql/changelog/ticket_deps/2026-05.sql @@ -19,3 +19,4 @@ 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-135', 'T-137', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'ae447ef31c40b8003cb8e372ec0b23b0', 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-136', 'T-137', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'cb44e9c64d3051a320f314e2d81ee552', 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-139', 'T-141', '2026-05-22 15:59:56', '2026-05-22 15:59:56', NULL, 'e2c7e7ea34fb4b007bd3d384ab788ae7', 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-145', 'T-141', '2026-05-23 06:08:24', '2026-05-23 06:08:24', NULL, '1426109288c2a7bbf5a3190940e09fbd', 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); diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index a507111c..721c7e22 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2151,3 +2151,44 @@ 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-144', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:43:45', '2026-05-22 22:43:45', '2026-05-22 22:43:45', NULL, 'da17b2710e17b1821882d47724790cbb', 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-139', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:50:41', '2026-05-22 22:50:41', '2026-05-22 22:50:41', NULL, 'ad063f0c3c65e5b35d3dcea036412ad9', 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-139', 'status', 'in_progress', 'done', NULL, '2026-05-22 23:16:28', '2026-05-22 23:16:28', '2026-05-22 23:16:28', NULL, 'add82dc118e2081500f19cbb321272d4', 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-140', 'status', 'backlog', 'done', NULL, '2026-05-22 23:31:46', '2026-05-22 23:31:46', '2026-05-22 23:31:46', NULL, 'e6204c6f0a0dbd38396a69dfef2c7080', 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-145', 'description', NULL, 'A compact per-session status strip in the lead Claude pane header AND each teammate tile header (T-140), showing: current model, permission mode (accept-edits / plan / default / bypass), and context-window usage %. + +Shared status source (this ticket owns it): extract per-session status in the drift-contained reader/observer (D-75) — do NOT parse CC internals in the widgets. +- Model: team config member.model, overridden by the latest assistant message.model in the transcript when present. +- Permission mode: the transcript''s `permission-mode` records (TranscriptReader currently SKIPS these — surface the latest instead). Also honor config member.planModeRequired for teammates. +- Context %: latest assistant message.usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens) over the model''s context limit. + +Delivery: emit a per-session status to the UI via the bus/events (e.g. a TeamMemberStatus event or an extra MessageBus channel) so BOTH the header strip and the T-141 sidebar render from one source without re-tailing. + +Applies to the lead session and every teammate. Re-validate field names against a live team run / CC version bump (undocumented, per T-134). + +Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done). +', NULL, '2026-05-23 06:07:59', '2026-05-23 06:07:59', '2026-05-23 06:07:59', NULL, '26bb1c89f3fef8d916f3eb6a1ba9d065', 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-141', 'description', 'New sidebar panel contributed to Slots.sidebar: roster from ~/.claude/teams config; live status verb / elapsed / down+up tokens / edit-mode from transcript tailing; team token budget pulled from the Anthropic usage API on a timer. Acceptance: roster matches list-panes/config; budget refreshes on the timer; appears in team mode. Blocked by T7.', 'New sidebar panel (contributed to Slots.sidebar), visible only in team mode: a roster row per team member that MIRRORS the per-session status surfaced by T-145, plus team-wide meta. + +Per-member row: +- Identity from the team config + observer: name, agentType, color; membership/lifecycle from TeamMemberJoined/Left. +- Mirror of T-145''s shared status: current model, permission-mode badge (accept-edits / plan / default / bypass), context-window usage %. Consume the SAME status source/event T-145 emits — do NOT re-tail transcripts or re-parse CC internals here (D-75). +- Live status verb + elapsed-since-last-activity + down/up token deltas from that same status feed. + +Team-wide: +- Team/account token budget pulled from the Anthropic usage API on a timer (distinct from per-conversation context above). + +Acceptance: roster matches list-panes/config; each row mirrors the member''s model + accept-edits/plan badge + context-usage; status verb and token deltas update live; account budget refreshes on its timer; panel appears only when a team is active. Depends on T-145 (status source) + T-139 (done). +', NULL, '2026-05-23 06:08:24', '2026-05-23 06:08:24', '2026-05-23 06:08:24', NULL, 'cfa1ce349be286d89a1f8e169605f04d', 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-146', 'description', NULL, 'Regression from T-137: every Claude pane renders the newest .jsonl in the workspace''s munged dir, so concurrent sessions in one workspace (primary + secondaries) all converge on the same transcript — a secondary tab shows the primary''s conversation (and the primary follows whichever session is newest). The pre-T-137 terminal pane rendered each tab''s own PTY, so tabs were independent. + +Fix: bind each pane to a specific Claude session id. +- claude CLI supports `--session-id ` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named .jsonl, so the pane''s transcript is /.jsonl. +- Reader targets that exact file via TranscriptReader''s `file:` param (added in T-139). Guard _tail against the file not existing yet (claude writes it shortly after spawn). +- Each pane publishes/subscribes on a per-session MessageBus channel (conversation/) so primary and secondary controllers don''t cross-talk. +- UUID per pane, seeded by the tmux session name (the identity we already have): primary = deterministic from its stable session name (clide-claude-) so it RESUMES across restarts (D-41 continuity); secondary = random per spawn so each is a clean/fresh session (a deterministic secondary id would make starting a clean session impossible). + +Migration caveat: an existing tmux session created by the OLD code (no --session-id, claude picked its own id) won''t match the new deterministic id — it must be killed (claude.kill-all-sessions) once so the next spawn binds the controlled id. + +Open question to validate live: whether `claude --session-id ` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is .jsonl, but worth confirming claude starts). + +Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41. +', NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, '9e46b8eec7cc5e42132249a81d4925d1', 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-146', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, 'dd4ec6cfbbc3118cd4cf02604b91108b', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index b0df8605..21177fa5 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2496,3 +2496,43 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'done', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:43:45', NULL, '1ce891624631502279b0356c5cdffae2', 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-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 22:50:41', NULL, 'a5f4b20a0a37332ab35de8fa07e22298', 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-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 23:16:28', NULL, 'bd5e9cc5ae0e8bdbac4ad8cc57b33b58', 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-140', 'story', 'T-132', 'Teammate tile grid (faithful tiling, responsive auto-wrap)', 'Tiling host: lead ConversationView (left, with composer) + teammate ConversationViews (right) in a responsive auto-wrap grid (1->2->3 columns by count); resizable lead/teammate split. Workspace currently only vertical-splits, so extend _WorkspaceSlot (lib/app.dart) or add team slots in lib/kernel/src/panels/layout_preset.dart; reuse DragResizeHandle (lib/kernel/src/panels/drag_resize.dart). Driven by TeamMemberBorn/Died from the observer. Acceptance: tiles appear/disappear with lifecycle; grid wraps 1->2->3 by count; split resizes; select-across-tiles works. Blocked by T5, T7.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 23:31:46', NULL, '9c4254dcfca0f81367a0d909c72e9dd4', 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-145', 'task', 'T-132', 'Per-session status strip: model / permission-mode / context%', 'A compact per-session status strip in the lead Claude pane header AND each teammate tile header (T-140), showing: current model, permission mode (accept-edits / plan / default / bypass), and context-window usage %. + +Shared status source (this ticket owns it): extract per-session status in the drift-contained reader/observer (D-75) — do NOT parse CC internals in the widgets. +- Model: team config member.model, overridden by the latest assistant message.model in the transcript when present. +- Permission mode: the transcript''s `permission-mode` records (TranscriptReader currently SKIPS these — surface the latest instead). Also honor config member.planModeRequired for teammates. +- Context %: latest assistant message.usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens + output_tokens) over the model''s context limit. + +Delivery: emit a per-session status to the UI via the bus/events (e.g. a TeamMemberStatus event or an extra MessageBus channel) so BOTH the header strip and the T-141 sidebar render from one source without re-tailing. + +Applies to the lead session and every teammate. Re-validate field names against a live team run / CC version bump (undocumented, per T-134). + +Acceptance: lead pane header and each teammate tile header show current model + an accept-edits/plan-mode badge + a context-usage indicator, updating live from the transcript. Blocked by T-137 (done), T-140 (done). +', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-23 06:07:59', '2026-05-23 06:07:59', NULL, '5047ae730e9065c684fcecf557353de7', 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-141', 'task', 'T-132', 'Team-meta sidebar (roster + token budget)', 'New sidebar panel (contributed to Slots.sidebar), visible only in team mode: a roster row per team member that MIRRORS the per-session status surfaced by T-145, plus team-wide meta. + +Per-member row: +- Identity from the team config + observer: name, agentType, color; membership/lifecycle from TeamMemberJoined/Left. +- Mirror of T-145''s shared status: current model, permission-mode badge (accept-edits / plan / default / bypass), context-window usage %. Consume the SAME status source/event T-145 emits — do NOT re-tail transcripts or re-parse CC internals here (D-75). +- Live status verb + elapsed-since-last-activity + down/up token deltas from that same status feed. + +Team-wide: +- Team/account token budget pulled from the Anthropic usage API on a timer (distinct from per-conversation context above). + +Acceptance: roster matches list-panes/config; each row mirrors the member''s model + accept-edits/plan badge + context-usage; status verb and token deltas update live; account budget refreshes on its timer; panel appears only when a team is active. Depends on T-145 (status source) + T-139 (done). +', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-23 06:08:24', NULL, '54edba4b89b8926c3527b4f1174c2a4c', 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-146', 'bug', 'T-132', 'Secondary Claude panes render the primary''s transcript', 'Regression from T-137: every Claude pane renders the newest .jsonl in the workspace''s munged dir, so concurrent sessions in one workspace (primary + secondaries) all converge on the same transcript — a secondary tab shows the primary''s conversation (and the primary follows whichever session is newest). The pre-T-137 terminal pane rendered each tab''s own PTY, so tabs were independent. + +Fix: bind each pane to a specific Claude session id. +- claude CLI supports `--session-id ` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named .jsonl, so the pane''s transcript is /.jsonl. +- Reader targets that exact file via TranscriptReader''s `file:` param (added in T-139). Guard _tail against the file not existing yet (claude writes it shortly after spawn). +- Each pane publishes/subscribes on a per-session MessageBus channel (conversation/) so primary and secondary controllers don''t cross-talk. +- UUID per pane, seeded by the tmux session name (the identity we already have): primary = deterministic from its stable session name (clide-claude-) so it RESUMES across restarts (D-41 continuity); secondary = random per spawn so each is a clean/fresh session (a deterministic secondary id would make starting a clean session impossible). + +Migration caveat: an existing tmux session created by the OLD code (no --session-id, claude picked its own id) won''t match the new deterministic id — it must be killed (claude.kill-all-sessions) once so the next spawn binds the controlled id. + +Open question to validate live: whether `claude --session-id ` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is .jsonl, but worth confirming claude starts). + +Acceptance: a secondary tab shows its OWN fresh conversation, independent of the primary; the primary keeps its conversation; both update live. Regression of T-137; relates to D-41. +', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-23 06:19:51', '2026-05-23 06:19:51', NULL, 'c47ada73a71c18d1998cc6fdc36d1b22', 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/CHANGELOG.md b/CHANGELOG.md index 223c6cbb..e9e128ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Secondary Claude tabs showed the primary's conversation instead of + their own (T-146). Each pane now binds to its own session via + `claude --session-id`, so concurrent sessions in one workspace stop + colliding on the newest transcript — the primary keeps a stable id + (resumes), secondaries get a fresh one (clean session). - Claude pane no longer freezes the app on open — the transcript reader caps its initial read to the recent tail, parses off the UI isolate, and coalesces view notifications into one rebuild per burst (T-137). diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 0333ef81..40907845 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -45,6 +45,7 @@ class _ClaudePaneState extends State { TranscriptPublisher? _feed; String? _paneId; String? _sessionName; + String? _sessionId; String? _error; String _statusLine = 'attaching…'; @@ -143,6 +144,11 @@ class _ClaudePaneState extends State { } _sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); + // Bind this pane to a specific Claude session id so concurrent + // sessions in one workspace don't collide on the newest transcript + // (T-146). Primary: deterministic → resumes across restarts. + // Secondary: fresh → always a clean session. + _sessionId ??= widget.isPrimary ? primarySessionId(repoRoot) : freshSessionId(); final tmuxConf = await _ensureTmuxConf(); const cols = _cols; @@ -162,6 +168,8 @@ class _ClaudePaneState extends State { '-y', '$rows', 'claude', + '--session-id', + _sessionId!, ]; // CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode: @@ -180,7 +188,7 @@ class _ClaudePaneState extends State { }); if (!resp.ok) { - argv = ['claude']; + argv = ['claude', '--session-id', _sessionId!]; resp = await ipc.request('pane.spawn', args: { 'argv': argv, 'kind': PaneKind.claude.wire, @@ -209,9 +217,20 @@ class _ClaudePaneState extends State { // kernel MessageBus, which the view's controller subscribes to. The // subscription is wired before the reader's first poll so the initial // tail is never missed. + // Tail this session's own transcript (/.jsonl), + // not just the newest in the workspace — that's what kept secondaries + // showing the primary's conversation (T-146). Each pane gets its own + // bus channel so their controllers don't cross-talk. final messages = _kernel()!.messages; - _feed = TranscriptPublisher(messages: messages, reader: TranscriptReader(repoRoot)); - _conversation = ConversationController.fromBus(messages: messages); + final home = Platform.environment['HOME'] ?? ''; + final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl'; + final channel = ClaudeConversation.sessionChannel(_sessionId!); + _feed = TranscriptPublisher( + messages: messages, + reader: TranscriptReader(repoRoot, file: transcriptFile), + channel: channel, + ); + _conversation = ConversationController.fromBus(messages: messages, channel: channel); _subscribe(); setState(() {}); } diff --git a/lib/builtin/claude/src/session_naming.dart b/lib/builtin/claude/src/session_naming.dart index 87562c68..5d9e0028 100644 --- a/lib/builtin/claude/src/session_naming.dart +++ b/lib/builtin/claude/src/session_naming.dart @@ -6,9 +6,18 @@ /// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp /// /// Secondary sessions append `-N`. +/// +/// Also derives the Claude `--session-id` for each pane (T-146): a pane's +/// transcript is named `.jsonl`, so binding each pane to a +/// distinct UUID is how concurrent sessions in one workspace stay +/// independent. The primary's id is deterministic from its (stable) +/// session name so it resumes across restarts; secondaries get a fresh +/// random id each spawn so a clean session is always one click away. library; +import 'dart:convert'; import 'dart:io' show Platform; +import 'dart:math'; /// Stable session name for the primary Claude pane of [repoRoot]. String primarySessionName(String repoRoot) { @@ -49,3 +58,46 @@ String _hash(String s) { } return h.toRadixString(16).padLeft(8, '0'); } + +// --------------------------------------------------------------------------- +// Claude session-id (UUID) derivation — T-146 +// --------------------------------------------------------------------------- + +/// Stable session id for the primary pane of [repoRoot]: a UUID +/// deterministically derived from the primary session name, so the same +/// workspace re-binds the same `.jsonl` across restarts (resume). +String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot)); + +/// A fresh random session id for a secondary pane — secondaries are +/// always clean sessions, never resumed. +String freshSessionId() { + final r = Random.secure(); + return _formatUuid(List.generate(16, (_) => r.nextInt(256))); +} + +/// Deterministic, valid-format UUID derived from [seed] (same seed → +/// same id). Expands an FNV-1a stream into 16 bytes. +String _deterministicUuid(String seed) { + final bytes = []; + var h = 0xcbf29ce484222325; + const prime = 0x100000001b3; + for (var i = 0; i < 16; i++) { + for (final c in utf8.encode('$seed:$i')) { + h ^= c; + h = (h * prime) & 0xFFFFFFFFFFFFFFFF; + } + bytes.add(h & 0xff); + } + return _formatUuid(bytes); +} + +/// Format 16 [bytes] as a canonical v4 UUID string (sets the version and +/// variant nibbles so it passes `--session-id`'s UUID validation). +String _formatUuid(List bytes) { + final b = List.of(bytes); + b[6] = (b[6] & 0x0f) | 0x40; // version 4 + b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant + final hex = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-' + '${hex.substring(16, 20)}-${hex.substring(20)}'; +} diff --git a/lib/builtin/claude/src/transcript_publisher.dart b/lib/builtin/claude/src/transcript_publisher.dart index a14bddf0..4a1c1a2c 100644 --- a/lib/builtin/claude/src/transcript_publisher.dart +++ b/lib/builtin/claude/src/transcript_publisher.dart @@ -22,6 +22,10 @@ abstract final class ClaudeConversation { /// Channel for the lead (or single) Claude pane's conversation. static const leadChannel = 'conversation'; + /// Per-session channel, keyed by the Claude session id (T-146) so + /// concurrent panes in one workspace don't cross-talk. + static String sessionChannel(String sessionId) => 'conversation/$sessionId'; + /// Channel for a teammate's conversation (team work, T-139/T-140). static String teammateChannel(String agentId) => 'conversation/$agentId'; diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index 01c8b0a2..6066389f 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -294,10 +294,13 @@ class TranscriptReader { } Future _tick(StreamController controller) async { - // A teammate reader tails one fixed file; otherwise discover the + // A pane/teammate reader tails one fixed file; otherwise discover the // newest session `.jsonl` in the munged dir. final newest = _explicitFile ?? await _newestJsonl(_mungedDir()); if (newest == null) return; + // An explicit file may not exist yet (claude writes it shortly after + // spawn) — wait for it rather than throwing in the poll loop. + if (!await File(newest).exists()) return; if (newest != _currentPath) { // New session file. Start from the recent tail rather than byte 0: diff --git a/test/builtin/claude/session_naming_test.dart b/test/builtin/claude/session_naming_test.dart index 28bac402..1df081fe 100644 --- a/test/builtin/claude/session_naming_test.dart +++ b/test/builtin/claude/session_naming_test.dart @@ -52,4 +52,28 @@ void main() { expect(primarySessionName(long), primarySessionName(long)); }); }); + + group('claude session ids (T-146)', () { + final uuidRe = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'); + + test('primary id is a valid v4-format UUID', () { + expect(primarySessionId('/home/me/clide'), matches(uuidRe)); + }); + + test('primary id is deterministic per repo (resumes across restarts)', () { + expect(primarySessionId('/home/me/clide'), primarySessionId('/home/me/clide')); + }); + + test('different repos get different primary ids', () { + expect(primarySessionId('/home/me/clide'), isNot(primarySessionId('/home/me/other'))); + }); + + test('fresh ids are valid UUIDs and unique per call (clean secondaries)', () { + final a = freshSessionId(); + final b = freshSessionId(); + expect(a, matches(uuidRe)); + expect(b, matches(uuidRe)); + expect(a, isNot(b)); + }); + }); } diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index 32b6d4c7..4bb49599 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -743,6 +743,35 @@ void main() { expect(collected.whereType().single.text, 'from the explicit file'); }); + + test('explicit file: waits without error until the file appears', () async { + // claude writes .jsonl shortly after spawn (T-146); the + // reader must poll without throwing until it exists, then stream it. + final dir = await Directory.systemTemp.createTemp('late_file_'); + addTearDown(() => dir.delete(recursive: true)); + final target = File('${dir.path}/agent-late.jsonl'); + + final reader = TranscriptReader( + '/unused', + projectsBase: '/nonexistent', + pollInterval: const Duration(milliseconds: 20), + file: target.path, + ); + final collected = []; + final sub = reader.stream.listen(collected.add); + + // Let it poll a few times against the missing file — must not throw. + await Future.delayed(const Duration(milliseconds: 80)); + expect(collected, isEmpty); + + // Now the file appears. + writeLines(target, [assistantText('a1', 'arrived late')]); + await pumpUntil(() => collected.isNotEmpty); + + await sub.cancel(); + await reader.dispose(); + expect(collected.whereType().single.text, 'arrived late'); + }); }); }