bind each Claude pane to its own session id (T-146)

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 <session-id>.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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 08:28:55 +02:00
co-authored by Claude Opus 4.7
parent 24f68e7aaa
commit e1cd4653c0
10 changed files with 222 additions and 4 deletions
+1
View File
@@ -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-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-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-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);
+41
View File
@@ -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-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', '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-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 <uuid>` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named <session-id>.jsonl, so the pane''s transcript is <munged-cwd>/<uuid>.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/<uuid>) 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-<slug>) 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 <existing-uuid>` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is <uuid>.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;
+40
View File
@@ -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-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.', '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-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 <uuid>` (verified via --help). Spawn each pane''s claude with its own UUID; a transcript is named <session-id>.jsonl, so the pane''s transcript is <munged-cwd>/<uuid>.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/<uuid>) 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-<slug>) 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 <existing-uuid>` resumes vs. errors when re-spawned after the tmux session was killed (display works either way since the file is <uuid>.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);
+5
View File
@@ -120,6 +120,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed ### 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 - 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, caps its initial read to the recent tail, parses off the UI isolate,
and coalesces view notifications into one rebuild per burst (T-137). and coalesces view notifications into one rebuild per burst (T-137).
+22 -3
View File
@@ -45,6 +45,7 @@ class _ClaudePaneState extends State<ClaudePane> {
TranscriptPublisher? _feed; TranscriptPublisher? _feed;
String? _paneId; String? _paneId;
String? _sessionName; String? _sessionName;
String? _sessionId;
String? _error; String? _error;
String _statusLine = 'attaching…'; String _statusLine = 'attaching…';
@@ -143,6 +144,11 @@ class _ClaudePaneState extends State<ClaudePane> {
} }
_sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); _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(); final tmuxConf = await _ensureTmuxConf();
const cols = _cols; const cols = _cols;
@@ -162,6 +168,8 @@ class _ClaudePaneState extends State<ClaudePane> {
'-y', '-y',
'$rows', '$rows',
'claude', 'claude',
'--session-id',
_sessionId!,
]; ];
// CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode: // CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode:
@@ -180,7 +188,7 @@ class _ClaudePaneState extends State<ClaudePane> {
}); });
if (!resp.ok) { if (!resp.ok) {
argv = ['claude']; argv = ['claude', '--session-id', _sessionId!];
resp = await ipc.request('pane.spawn', args: { resp = await ipc.request('pane.spawn', args: {
'argv': argv, 'argv': argv,
'kind': PaneKind.claude.wire, 'kind': PaneKind.claude.wire,
@@ -209,9 +217,20 @@ class _ClaudePaneState extends State<ClaudePane> {
// kernel MessageBus, which the view's controller subscribes to. The // kernel MessageBus, which the view's controller subscribes to. The
// subscription is wired before the reader's first poll so the initial // subscription is wired before the reader's first poll so the initial
// tail is never missed. // tail is never missed.
// Tail this session's own transcript (<munged-cwd>/<sessionId>.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; final messages = _kernel()!.messages;
_feed = TranscriptPublisher(messages: messages, reader: TranscriptReader(repoRoot)); final home = Platform.environment['HOME'] ?? '';
_conversation = ConversationController.fromBus(messages: messages); 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(); _subscribe();
setState(() {}); setState(() {});
} }
@@ -6,9 +6,18 @@
/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp /// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp
/// ///
/// Secondary sessions append `-N`. /// Secondary sessions append `-N`.
///
/// Also derives the Claude `--session-id` for each pane (T-146): a pane's
/// transcript is named `<session-id>.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; library;
import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:math';
/// Stable session name for the primary Claude pane of [repoRoot]. /// Stable session name for the primary Claude pane of [repoRoot].
String primarySessionName(String repoRoot) { String primarySessionName(String repoRoot) {
@@ -49,3 +58,46 @@ String _hash(String s) {
} }
return h.toRadixString(16).padLeft(8, '0'); 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 `<uuid>.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<int>.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 = <int>[];
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<int> bytes) {
final b = List<int>.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)}';
}
@@ -22,6 +22,10 @@ abstract final class ClaudeConversation {
/// Channel for the lead (or single) Claude pane's conversation. /// Channel for the lead (or single) Claude pane's conversation.
static const leadChannel = '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). /// Channel for a teammate's conversation (team work, T-139/T-140).
static String teammateChannel(String agentId) => 'conversation/$agentId'; static String teammateChannel(String agentId) => 'conversation/$agentId';
@@ -294,10 +294,13 @@ class TranscriptReader {
} }
Future<void> _tick(StreamController<ConversationItem> controller) async { Future<void> _tick(StreamController<ConversationItem> 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. // newest session `.jsonl` in the munged dir.
final newest = _explicitFile ?? await _newestJsonl(_mungedDir()); final newest = _explicitFile ?? await _newestJsonl(_mungedDir());
if (newest == null) return; 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) { if (newest != _currentPath) {
// New session file. Start from the recent tail rather than byte 0: // New session file. Start from the recent tail rather than byte 0:
@@ -52,4 +52,28 @@ void main() {
expect(primarySessionName(long), primarySessionName(long)); 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));
});
});
} }
@@ -743,6 +743,35 @@ void main() {
expect(collected.whereType<AssistantTextMessage>().single.text, 'from the explicit file'); expect(collected.whereType<AssistantTextMessage>().single.text, 'from the explicit file');
}); });
test('explicit file: waits without error until the file appears', () async {
// claude writes <session-id>.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 = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
// Let it poll a few times against the missing file — must not throw.
await Future<void>.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<AssistantTextMessage>().single.text, 'arrived late');
});
}); });
} }