From f65ef503796b5eadf17a506c676b91d6f029e3da Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 09:48:26 +0200 Subject: [PATCH] fix Claude secondary pane copy + false "session exited" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things surfaced in the secondary pane: the tab said "session 1" while the banner said "secondary 1" — now both say "session N". And the banner showed "session exited" right after starting, even though Claude was alive: a transient tmux client process can exit during spawn while the session itself is fine. pane.exit now verifies via `tmux has-session` and only reports exited when the session is actually gone. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 1 + CHANGELOG.md | 4 ++++ lib/builtin/claude/src/claude_pane.dart | 12 ++++++++++-- lib/builtin/claude/src/tmux_session.dart | 8 ++++++++ test/builtin/claude/tmux_session_test.dart | 10 ++++++++++ 6 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index a6290859..d37638ee 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2196,3 +2196,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-147', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, 'b0bc1949f98ef05a05717cef80ba194f', 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-147', 'status', 'in_progress', 'done', NULL, '2026-05-23 07:06:54', '2026-05-23 07:06:54', '2026-05-23 07:06:54', NULL, 'b6b88954fdc04ecf7847dea204a92e14', 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-149', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '75bb1fa2d964f284910201b1c15995b9', 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-149', 'status', 'in_progress', 'done', NULL, '2026-05-23 07:27:32', '2026-05-23 07:27:32', '2026-05-23 07:27:32', NULL, 'd020ddfea99355676800af1e39249c85', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 277247a4..a42f4738 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2554,3 +2554,4 @@ 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-148', 'task', 'T-132', 'Claude transcript disk-usage indicator + cleanup button', 'Surface transcript/session disk usage in the Claude panel with a cleanup affordance so the user can pick and choose what to remove. Per-session sizes (the /*.jsonl files + subagents/), a total, and a button to delete selected sessions'' transcripts. User-driven only — clide never auto-deletes transcript content. Future/''at some point'' per user. Relates to T-146 (session ids) and D-75.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:03:18', NULL, '7bbe7f4baf80027516cc46cff93a8dfe', 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-147', 'bug', 'T-132', 'Claude pane gets stuck waiting on a stale/legacy session', 'When the bound transcript (/.jsonl) never appears — e.g. attaching via new-session -A to a session created before --session-id binding (T-146), or any unconnectable session — the pane shows ''Waiting for Claude…'' forever. Fix: before spawning, if the bound transcript for our deterministic session id does not exist, kill the clide tmux session of that exact name so new-session creates a clean session with our --session-id. SAFETY (hard requirement): only ever kill clide-initiated sessions — by exact clide-claude- name, on the private -L clide socket (a user''s terminal claude is never on that socket); never delete any transcript file. Also makes clean-install/first-run robust. Live-verify the stuck primary self-heals on relaunch and a secondary still gets a fresh independent session.', 'done', 'high', NULL, NULL, NULL, '2026-05-23 07:03:18', '2026-05-23 07:06:54', NULL, '4c08227959a8fe6905521ebdc42487c1', 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-149', 'task', 'T-132', 'Native Claude pane startup banner (empty state)', 'Replace the bland ''Waiting for Claude…'' empty state with a native clide banner: clide logo, a Claude label (brand orange), the session role (primary/secondary N), the workspace path (home-collapsed), and the tmux session/status line, plus a ''warming up'' hint. Owned/native — no tmux capture-pane, no CC-internals coupling. ConversationView gains an optional emptyState widget; ClaudePane supplies a ClaudeBanner built from data it already has (repoRoot, statusLine, isPrimary).', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 07:14:36', '2026-05-23 07:14:36', NULL, '741b021184df1596b364aa2d09167e85', 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-149', 'task', 'T-132', 'Native Claude pane startup banner (empty state)', 'Replace the bland ''Waiting for Claude…'' empty state with a native clide banner: clide logo, a Claude label (brand orange), the session role (primary/secondary N), the workspace path (home-collapsed), and the tmux session/status line, plus a ''warming up'' hint. Owned/native — no tmux capture-pane, no CC-internals coupling. ConversationView gains an optional emptyState widget; ClaudePane supplies a ClaudeBanner built from data it already has (repoRoot, statusLine, isPrimary).', 'done', 'low', NULL, NULL, NULL, '2026-05-23 07:14:36', '2026-05-23 07:27:32', NULL, '1d922f79ed8f98bea75042af8662dd74', 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 cb0824e0..471f1077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Claude secondary panes no longer flash a false "session exited" while + the session is alive (a transient tmux client exit is now verified + against the live session), and the tab and banner agree on the label + ("session N") (T-149). - Claude pane no longer gets stuck on "Waiting for Claude…" when its session can't be bound (T-147) — e.g. a session left over from before session-id binding, or a fresh machine. It now retires that stale diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 9647641d..9e3f998a 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -275,10 +275,18 @@ class _ClaudePaneState extends State { final kernel = _kernel(); if (kernel == null) return; // Lifecycle only — content comes from the transcript, not pane.output. - _eventSub = kernel.events.on().listen((e) { + _eventSub = kernel.events.on().listen((e) async { if (e.subsystem != 'pane' || e.data['id'] != _paneId) return; switch (e.kind) { case 'pane.exit': + // A transient tmux client can exit (e.g. during spawn/respawn) + // while the session — and Claude — stay alive. Don't report + // "exited" then; only when the tmux session is actually gone. + // (The no-tmux fallback has no session, so the exit is real.) + if (_usingTmux && _sessionName != null && await tmux.hasSession(_sessionName!)) { + return; + } + if (!mounted) return; setState(() => _statusLine = widget.isPrimary ? 'session exited — restart clide to retry' : 'session exited'); case 'pane.closed': _paneId = null; @@ -317,7 +325,7 @@ class _ClaudePaneState extends State { child: ConversationView( controller: _conversation!, emptyState: ClaudeBanner( - role: widget.isPrimary ? 'primary' : 'secondary ${widget.secondaryIndex}', + role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}', workspace: _repoRoot, statusLine: _statusLine, ), diff --git a/lib/builtin/claude/src/tmux_session.dart b/lib/builtin/claude/src/tmux_session.dart index f2ac1586..80aaabd7 100644 --- a/lib/builtin/claude/src/tmux_session.dart +++ b/lib/builtin/claude/src/tmux_session.dart @@ -24,6 +24,14 @@ Future killSession(String name) async { await tmuxRunner([..._socket, 'kill-session', '-t', name]); } +/// Whether [name] is a live session on the clide socket. Used to suppress +/// a spurious "session exited" when a transient tmux client process exits +/// but the session itself is fine (T-149 follow-up). +Future hasSession(String name) async { + final r = await tmuxRunner([..._socket, 'has-session', '-t', name]); + return r.exitCode == 0; +} + /// Return the names of all sessions currently alive on the clide /// socket. Empty list if the server is not running. Future> listClideSessions() async { diff --git a/test/builtin/claude/tmux_session_test.dart b/test/builtin/claude/tmux_session_test.dart index d66a308b..02a3643f 100644 --- a/test/builtin/claude/tmux_session_test.dart +++ b/test/builtin/claude/tmux_session_test.dart @@ -104,6 +104,16 @@ void main() { }); }); + group('hasSession', () { + test('true on exit 0, false otherwise', () async { + runner.exitCode = 0; + expect(await tmux.hasSession('clide-claude-foo'), isTrue); + expect(runner.calls.last, ['-L', 'clide', 'has-session', '-t', 'clide-claude-foo']); + runner.exitCode = 1; + expect(await tmux.hasSession('clide-claude-foo'), isFalse); + }); + }); + group('sendMessage', () { test('loads a bracketed paste buffer then submits with Enter', () async { await tmux.sendMessage('clide-claude-foo', 'hello\nworld');