diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index dd917222..b083d713 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-06T14:41:33Z", + "exported_at": "2026-05-06T14:48:06Z", "decisions": [ { "id": "D-1", diff --git a/CHANGELOG.md b/CHANGELOG.md index d14c0698..9e6c1303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. on the clide socket, honouring D-41's "closing a secondary kills that tmux session" lifecycle. Previously `pane.close` only killed the ptyc-spawned tmux client and the server-side session leaked. +- Cold-start reap: every clide launch kills any leftover secondary + tmux sessions for the current repo before spawning new ones, so + D-41's "secondary numbering resets between runs" holds even after + an abrupt previous exit (kill -9, crash, force-quit). +- `claude.kill-all-sessions` command now actually kills the + server-side tmux sessions for the repo, not just the panes. - Terminal cell grid no longer drifts on bold text — bold rendering is suppressed at the painter level since synthetic bold (with no Bold.ttf registered) shifts glyph advance widths. diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 5b9109bf..0c497928 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -50,29 +50,63 @@ class ClaudeExtension extends ClideExtension { @override Future activate(ClideExtensionContext ctx) async { _ctx = ctx; + // Cold-start reap: kill any leftover secondary tmux sessions from + // a previous run. D-41's "secondary numbering resets between + // clide runs" only holds if the leftovers are gone before the new + // run starts. Doing this in activate (rather than the previous + // run's deactivate) guarantees cleanup even after an abrupt exit + // — Flutter's deactivate hook only fires on explicit extension + // teardown, not on app quit / kill -9 / OOM. + final primary = await _primarySessionName(); + if (primary != null) await tmux.reapSecondaries(primary); } @override Future deactivate() async { - await _killAllSessions([]); + // Best-effort cleanup on explicit extension teardown. The cold- + // start reap in activate is the actual safety net. + final primary = await _primarySessionName(); + if (primary != null) await tmux.reapSecondaries(primary); } + /// Hard-reset command: kill every clide-claude tmux session for this + /// repo, primary included. The user invokes this when they want to + /// start over — typically after a tmux/Claude wedge. Future _killAllSessions(List args) async { final ctx = _ctx; if (ctx == null) return IpcResponse.ok(id: '', data: const {}); + + // Close the UI panes first so they don't try to talk to a tmux + // server that's about to lose their sessions. final resp = await ctx.ipc.request('pane.list'); - if (!resp.ok) return resp; - final panes = resp.data['panes']; - if (panes is List) { - for (final p in panes) { - if (p is Map && p['kind'] == 'claude') { - final id = p['id'] as String?; - if (id != null) { - await ctx.ipc.request('pane.close', args: {'id': id}); + if (resp.ok) { + final panes = resp.data['panes']; + if (panes is List) { + for (final p in panes) { + if (p is Map && p['kind'] == 'claude') { + final id = p['id'] as String?; + if (id != null) { + await ctx.ipc.request('pane.close', args: {'id': id}); + } } } } } + + // Then kill the server-side sessions, primary included. + final primary = await _primarySessionName(); + if (primary != null) await tmux.killAllForRepo(primary); + return IpcResponse.ok(id: '', data: const {'status': 'killed'}); } + + Future _primarySessionName() async { + final ctx = _ctx; + if (ctx == null) return null; + final resp = await ctx.ipc.request('files.root'); + if (!resp.ok) return null; + final root = resp.data['path'] as String?; + if (root == null) return null; + return primarySessionName(root); + } } diff --git a/test/builtin/claude/tmux_session_test.dart b/test/builtin/claude/tmux_session_test.dart new file mode 100644 index 00000000..75bb154b --- /dev/null +++ b/test/builtin/claude/tmux_session_test.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux; +import 'package:test/test.dart'; + +class _RecordingRunner { + final List> calls = []; + String stdout = ''; + int exitCode = 0; + + Future call(List args) async { + calls.add(List.of(args)); + return ProcessResult(0, exitCode, stdout, ''); + } +} + +void main() { + late _RecordingRunner runner; + + setUp(() { + runner = _RecordingRunner(); + tmux.tmuxRunner = runner.call; + }); + + tearDown(() { + // Restore the default runner so other tests aren't affected. + tmux.tmuxRunner = (args) => Process.run('tmux', args); + }); + + group('killSession', () { + test('invokes tmux kill-session on the clide socket', () async { + await tmux.killSession('clide-claude-foo'); + expect(runner.calls, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo'], + ]); + }); + + test('does not surface non-zero exit (session already gone)', () async { + runner.exitCode = 1; + await tmux.killSession('clide-claude-foo'); + expect(runner.calls, hasLength(1)); + }); + }); + + group('listClideSessions', () { + test('parses session names from tmux output', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-foo-2\n'; + final names = await tmux.listClideSessions(); + expect(names, ['clide-claude-foo', 'clide-claude-foo-1', 'clide-claude-foo-2']); + }); + + test('returns empty list when server is not running', () async { + runner.exitCode = 1; + final names = await tmux.listClideSessions(); + expect(names, isEmpty); + }); + + test('strips blank lines and whitespace', () async { + runner.stdout = '\nclide-claude-foo\n\n clide-claude-foo-1 \n'; + final names = await tmux.listClideSessions(); + expect(names, ['clide-claude-foo', 'clide-claude-foo-1']); + }); + }); + + group('reapSecondaries', () { + test('kills only --suffixed sessions, leaves primary alive', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-foo-2\n'; + await tmux.reapSecondaries('clide-claude-foo'); + + // First call lists, then one kill per secondary. + expect(runner.calls.first, ['-L', 'clide', 'list-sessions', '-F', '#{session_name}']); + final kills = runner.calls.skip(1).toList(); + expect(kills, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'], + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-2'], + ]); + }); + + test('ignores sessions for other repos', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-bar-1\nclide-claude-foo-1\n'; + await tmux.reapSecondaries('clide-claude-foo'); + final kills = runner.calls.skip(1).toList(); + expect(kills, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'], + ]); + }); + + test('no-op when there are no secondaries', () async { + runner.stdout = 'clide-claude-foo\n'; + await tmux.reapSecondaries('clide-claude-foo'); + expect(runner.calls, hasLength(1)); // just the list call + }); + }); + + group('killAllForRepo', () { + test('kills primary and every secondary for the repo', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-bar\n'; + await tmux.killAllForRepo('clide-claude-foo'); + final kills = runner.calls.skip(1).toList(); + expect(kills, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo'], + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'], + ]); + }); + }); +}