resume existing Claude sessions with --resume, not --session-id
test / unit + widget + golden + a11y (push) Failing after 32s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 32s

Confirmed root cause of the dead-pane bug: `claude --session-id <id>`
rejects an id that already exists ("Session ID … is already in use") and
exits. The primary pane uses a deterministic id to resume across restarts,
and /resume re-binds to an existing id — both relaunched with --session-id,
so whenever the tmux session wasn't already alive (clean boot, or after
/clear+/resume) Claude exited instantly and the pane had no live backend:
typed input vanished while the transcript still rendered. The pane now
launches an existing session (transcript on disk) with `--resume <id>` and
only a brand-new one with `--session-id <id>`. Fresh secondaries and /clear
(fresh ids) were always fine. Verified empirically against a live session.

T-161.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 11:45:03 +02:00
co-authored by Claude Opus 4.7
parent b8168d8ffe
commit 0d45416bb8
6 changed files with 63 additions and 13 deletions
@@ -2219,3 +2219,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-141', 'status', 'in_progress', 'done', NULL, '2026-05-23 20:49:44', '2026-05-23 20:49:44', '2026-05-23 20:49:44', NULL, 'fb2405478b0d422d39a924ccedec5022', 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-157', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 20:52:47', '2026-05-23 20:52:47', '2026-05-23 20:52:47', NULL, 'b2b31ab5fe70fb5027773775b0945bd5', 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-157', 'status', 'in_progress', 'done', NULL, '2026-05-23 20:57:07', '2026-05-23 20:57:07', '2026-05-23 20:57:07', NULL, 'ce114090979d31512a9aa09f1c9311a1', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-159', 'status', 'backlog', 'done', NULL, '2026-05-24 08:54:30', '2026-05-24 08:54:30', '2026-05-24 08:54:30', NULL, '478956f24d051f603aab2c7ceec328b2', 1) ON CONFLICT(hash) DO NOTHING;
+23
View File
@@ -2659,3 +2659,26 @@ Acceptance: roster matches list-panes/config; each row mirrors the member''s mod
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-157', 'task', 'T-132', 'Team sidebar: live per-member status (mode + context)', 'Extend the Claude meta sidebar (T-141) roster rows with each teammate''s LIVE status — permission-mode badge (default/accept-edits/plan/bypass) and context-token usage — mirroring the per-session strip (T-145/T-150). The data exists: TeamObserver creates a TranscriptPublisher per teammate whose statusStream emits SessionStatus, but it is NEVER published to the MessageBus. Wire it: in team_observer _joined, subscribe to the publisher''s statusStream and publish {agentId, model, permissionMode, contextTokens} to a new ClaudeConversation.memberStatusChannel; cancel on _left. The sidebar subscribes to that channel and updates the matching roster row. Do NOT re-tail transcripts (D-75) — reuse the existing stream. Acceptance: a running team shows each member''s mode badge + context tokens, updating live; module is the single status-forwarding point; tests; coverage >= floor.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 20:48:31', '2026-05-23 20:52:47', NULL, '386818f67d3cbcc1a1b7a528c2baaca1', 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-157', 'task', 'T-132', 'Team sidebar: live per-member status (mode + context)', 'Extend the Claude meta sidebar (T-141) roster rows with each teammate''s LIVE status — permission-mode badge (default/accept-edits/plan/bypass) and context-token usage — mirroring the per-session strip (T-145/T-150). The data exists: TeamObserver creates a TranscriptPublisher per teammate whose statusStream emits SessionStatus, but it is NEVER published to the MessageBus. Wire it: in team_observer _joined, subscribe to the publisher''s statusStream and publish {agentId, model, permissionMode, contextTokens} to a new ClaudeConversation.memberStatusChannel; cancel on _left. The sidebar subscribes to that channel and updates the matching roster row. Do NOT re-tail transcripts (D-75) — reuse the existing stream. Acceptance: a running team shows each member''s mode badge + context tokens, updating live; module is the single status-forwarding point; tests; coverage >= floor.', 'done', 'low', NULL, NULL, NULL, '2026-05-23 20:48:31', '2026-05-23 20:57:07', NULL, '6fd17798d24f81a3f81f284c7bd595e2', 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-159', 'bug', 'T-132', 'ClidePane convey notifies focus listeners during build (markNeedsBuild crash)', 'Found via make run (2026-05-24). ClidePane.didChangeDependencies/didUpdateWidget run during the build phase and call FocusTracker.setStatusWidget -> notifyListeners() synchronously, which rebuilds the focus-listening status-bar item (PaneContextStatusItem''s ListenableBuilder) mid-build -> ''setState()/markNeedsBuild() called during build'' (thrown repeatedly each frame). Introduced by T-150; the T-150 widget tests missed it because no focus listener was in their tree. Fix: ClidePane defers the convey to a post-frame callback when schedulerPhase == persistentCallbacks (mid-build), re-checking focus then; immediate otherwise. Regression test added with a PaneContextStatusItem in the tree while a ClidePane mounts focused.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-24 08:53:13', '2026-05-24 08:53:13', NULL, '52caaaca78b7c2bf6cae907fcf0fcdae', 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-159', 'bug', 'T-132', 'ClidePane convey notifies focus listeners during build (markNeedsBuild crash)', 'Found via make run (2026-05-24). ClidePane.didChangeDependencies/didUpdateWidget run during the build phase and call FocusTracker.setStatusWidget -> notifyListeners() synchronously, which rebuilds the focus-listening status-bar item (PaneContextStatusItem''s ListenableBuilder) mid-build -> ''setState()/markNeedsBuild() called during build'' (thrown repeatedly each frame). Introduced by T-150; the T-150 widget tests missed it because no focus listener was in their tree. Fix: ClidePane defers the convey to a post-frame callback when schedulerPhase == persistentCallbacks (mid-build), re-checking focus then; immediate otherwise. Regression test added with a PaneContextStatusItem in the tree while a ClidePane mounts focused.', 'done', 'high', NULL, NULL, NULL, '2026-05-24 08:53:13', '2026-05-24 08:54:30', NULL, 'a2200e366f389c81f2e52e986715f9d1', 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-160', 'bug', 'T-132', 'Status bar overflows instead of marquee-scrolling the in-pane slot', 'Observed via make run (2026-05-24): the bottom status bar shows a RenderFlex ''overflowed by 77 PIXELS'' stripe after the in-pane context line (opus 4.7 · default · 21k ctx · 10 skills); the ClideMarquee never engages.
Cause: StatusbarHost (lib/app.dart ~1143) lays items in a Row [left items..., Spacer(), right items...], each at INTRINSIC width with no Flexible. PaneContextStatusItem wraps its text in ConstrainedBox(maxWidth:360) + ClideMarquee, but because the text (~280px) is under 360 the slot takes content width, so the marquee''s viewport == its content and it never scrolls. T-154 appended ''· N skills'', lengthening the line enough that git-branch + context slot + right items exceed the bar width -> the Row overflows (~77px) instead of the slot shrinking + marquee scrolling.
Fix direction: give the in-pane context slot a flexible/bounded width in the status bar (e.g. wrap it in Flexible(fit: loose), or have the bar allot it a shrinkable share) so it yields width when the bar is tight and ClideMarquee scrolls within the allotted width. Verify the interaction with the Spacer and the right-aligned items (priority>=100), and reconsider the fixed maxWidth:360. Acceptance: at narrow widths the in-pane slot marquee-scrolls and the status bar never shows an overflow stripe; widget test at a constrained width. Introduced by T-150 (marquee) + T-154 (longer line).', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-24 09:13:42', '2026-05-24 09:13:42', NULL, 'd7c01b7742f730c2c7e2fb5473cdbefd', 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-161', 'bug', 'T-132', 'Typed input reaches no live Claude after respawn — pane renders transcript only', 'Observed via make run (2026-05-24). After using /clear and /resume (which appear to work), regular typed messages produce no response and never appear in the conversation stream.
Diagnosis (live process inspection): the clide app (PID 77023) had ZERO child processes; ''tmux -L clide list-sessions'' reported ''no server running''. clide spawns pane processes (tmux client / claude) via posix_spawn as its own children, so no children + no -L clide server = NO live Claude backing the pane. The two stray ''claude'' procs were the user''s other sessions (an agent + a game-coding session), not clide''s. The conversation still renders because TranscriptReader tails the transcript FILE so /clear and /resume only re-point the reader at a different file; there is no live session behind them.
Leading hypothesis: T-156''s _respawnWithSession (/clear, /resume) kills the tmux session (killing the last session also kills the -L clide server) and the subsequent _spawn does not leave a durable session but clide may still have _usingTmux=true and routes input via tmux.sendMessage to a now-dead session, where set-buffer/paste-buffer/send-keys silently no-op (tmuxRunner ignores non-zero exit). Net: keystrokes vanish.
To localise: does a BRAND-NEW Claude tab (fresh spawn, no respawn) accept input and respond? If yes, the respawn path is the culprit; if no, the spawn path itself regressed. Also check the make-run console for pane.spawn errors / ''session exited''.
Acceptance: after /clear and /resume, a live tmux session (or no-tmux PTY) exists and typed input reaches Claude and streams a response; clide''s _usingTmux/_paneId/_sessionName stay consistent with the actual spawned process; regression test or a documented manual verification. Relates to T-156, T-147 (self-heal), D-41.', 'backlog', 'high', NULL, NULL, NULL, '2026-05-24 09:24:02', '2026-05-24 09:24:02', NULL, '7ef055c3f51dffb9efe3e03c785a10be', 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-162', 'bug', 'T-132', 'Slash typeahead omits clide-owned commands (/resume)', '/resume works when typed but doesn''t appear in the composer slash typeahead. Cause: the typeahead sources its command list from activeClaudeConfig.slashCommands (the CLI stream-json probe), which does not include ''resume'' (it''s not an in-session slash command the CLI advertises; clide owns/intercepts it). /clear appears because ''clear'' IS in the probe list. Fix: union kClideOwnedCommands (slash_commands.dart) into the typeahead''s command source — e.g. in ClaudeComposer''s default slashCommandsResolver return {...?activeClaudeConfig?.slashCommands, ...kClideOwnedCommands}. Acceptance: typing /res suggests /resume; /clear still suggested; widget test asserting clide-owned commands appear even when absent from the probe list. Follow-up to T-152/T-156.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-24 09:29:07', '2026-05-24 09:29:07', NULL, '403801e2c738160eac2e49e474eb30d0', 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-163', 'task', 'T-132', 'Composer prompt history via Up/Down (Claude-CLI-style)', 'Arrow Up/Down in the Claude composer should scroll through previously-sent prompts, matching the Claude Code CLI UX:
- Up: if the caret isn''t on the first visual line, move up one line (normal editing); once on the first line, Up steps to the previous prompt in history. Down mirrors it (move down within multiline; at the last line, step to the next/newer entry).
- When entering history, stash the current draft as the newest entry so Down past the most recent restores exactly what the user was typing.
- History is the sequence of prompts submitted from this composer (this session at least; persisting across restarts is a nice-to-have).
- Plays well with the existing Enter-submit / Shift+Enter-newline and the slash typeahead (Up/Down already drive the typeahead when it''s open history nav only applies when the typeahead is closed).
Implementation: a small history controller + caret-line detection in ClaudeComposer (EditableText); the Up/Down handling lives alongside the existing _onKey/Shortcuts. Acceptance: send a few prompts, Up walks back through them, Down returns through them to the restored draft; multiline prompts navigate line-by-line before crossing into history; widget tests for the bound conditions (first/last line) and draft stash/restore.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-24 09:42:44', '2026-05-24 09:42:44', NULL, 'c2d0a0a7c96dba20b920a2cc9d20d2e0', 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
@@ -145,6 +145,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
- Claude pane is no longer dead-on-arrival when resuming a session (T-161).
The primary pane (and `/resume`) relaunched Claude with `--session-id
<existing-id>`, which Claude rejects as "already in use" — so the pane had
no live backend and typed input vanished. clide now uses `--resume` for an
existing session and `--session-id` only for a brand-new one.
- Claude pane no longer floods the console with "markNeedsBuild called
during build" (T-159) — a focused pane surfacing its status-bar widget
now defers the notification out of the build phase instead of rebuilding
+16 -13
View File
@@ -207,21 +207,26 @@ class _ClaudePaneState extends State<ClaudePane> {
final home = Platform.environment['HOME'] ?? '';
final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl';
// A transcript already on disk means this session existed before, so we
// resume it; otherwise it's new. This drives both the self-heal kill and
// the launch flag — `claude --session-id <id>` REFUSES an existing id
// ("already in use"), so an existing session must launch with `--resume`
// (T-161).
final transcriptExists = await File(transcriptFile).exists();
// Self-heal (T-147): if no transcript is bound to our session id, any
// clide tmux session of this name is stale (created before --session-id
// clide tmux session of this name is stale (created before session-id
// binding, or otherwise unconnectable) and `new-session -A` would
// attach to it and leave the pane stuck waiting forever. Kill it so a
// clean session is created with our --session-id.
//
// Safe by construction: this only ever kills clide's OWN session — by
// its exact `clide-claude-<slug>` name, on the private `-L clide`
// socket the user's terminal claude never runs on — and never deletes
// any transcript file. A healthy session's transcript already exists,
// so re-attach (D-41 continuity) is preserved.
if (!await File(transcriptFile).exists()) {
// clean session is created. Safe by construction: only ever kills clide's
// OWN `clide-claude-<slug>` session on the private `-L clide` socket, and
// never deletes any transcript. A healthy session's transcript exists, so
// re-attach (D-41 continuity) is preserved.
if (!transcriptExists) {
await tmux.killSession(_sessionName!);
}
final launch = claudeLaunchArgs(_sessionId!, resume: transcriptExists);
final tmuxConf = await _ensureTmuxConf();
const cols = _cols;
const rows = _rows;
@@ -239,9 +244,7 @@ class _ClaudePaneState extends State<ClaudePane> {
'$cols',
'-y',
'$rows',
'claude',
'--session-id',
_sessionId!,
...launch,
];
// CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode:
@@ -260,7 +263,7 @@ class _ClaudePaneState extends State<ClaudePane> {
});
if (!resp.ok) {
argv = ['claude', '--session-id', _sessionId!];
argv = launch;
resp = await ipc.request('pane.spawn', args: {
'argv': argv,
'kind': PaneKind.claude.wire,
@@ -68,6 +68,12 @@ String _hash(String s) {
/// workspace re-binds the same `<uuid>.jsonl` across restarts (resume).
String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot));
/// The `claude` argv to launch [sessionId]: `--resume` an existing session,
/// or `--session-id` to create a new one. `--session-id` REFUSES an id that
/// already exists ("Session ID … is already in use") — so resuming a pane
/// whose transcript already exists must use `--resume` (T-161).
List<String> claudeLaunchArgs(String sessionId, {required bool resume}) => resume ? ['claude', '--resume', sessionId] : ['claude', '--session-id', sessionId];
/// A fresh random session id for a secondary pane — secondaries are
/// always clean sessions, never resumed.
String freshSessionId() {
@@ -76,4 +76,16 @@ void main() {
expect(a, isNot(b));
});
});
group('claudeLaunchArgs (T-161)', () {
test('resumes an existing session with --resume, not --session-id', () {
// --session-id refuses an existing id ("already in use"), so resuming
// (transcript on disk) must use --resume.
expect(claudeLaunchArgs('abc', resume: true), ['claude', '--resume', 'abc']);
});
test('creates a new session with --session-id', () {
expect(claudeLaunchArgs('abc', resume: false), ['claude', '--session-id', 'abc']);
});
});
}