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:
@@ -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);
|
||||
|
||||
@@ -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 <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;
|
||||
|
||||
@@ -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 <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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -45,6 +45,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
TranscriptPublisher? _feed;
|
||||
String? _paneId;
|
||||
String? _sessionName;
|
||||
String? _sessionId;
|
||||
String? _error;
|
||||
String _statusLine = 'attaching…';
|
||||
|
||||
@@ -143,6 +144,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
|
||||
_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<ClaudePane> {
|
||||
'-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<ClaudePane> {
|
||||
});
|
||||
|
||||
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<ClaudePane> {
|
||||
// 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 (<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;
|
||||
_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(() {});
|
||||
}
|
||||
|
||||
@@ -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 `<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;
|
||||
|
||||
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 `<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.
|
||||
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';
|
||||
|
||||
|
||||
@@ -294,10 +294,13 @@ class TranscriptReader {
|
||||
}
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -743,6 +743,35 @@ void main() {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user