From 1c424f26e71f0b3b11bd674e079d4f2668c54019 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 6 May 2026 16:41:33 +0200 Subject: [PATCH] kill secondary Claude tmux sessions on tab close (T-87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lib/builtin/claude/src/tmux_session.dart with helpers for the clide-socket tmux server: killSession, listClideSessions, reapSecondaries, killAllForRepo. The runner is overrideable via a TmuxRunner typedef so tests don't shell out for real. Wires ClaudePane.dispose() to call killSession(sessionName) for secondary panes. Primary panes are left alone — D-41 keeps the primary's tmux session alive across clide restarts so the next launch re-attaches via `tmux new-session -A`. Imports the helpers in the Claude extension as groundwork for the app-shutdown reap and the existing claude.kill-all-sessions command — wiring those uses lands separately. Co-Authored-By: Claude --- .pql/pql-plan.json | 2 +- CHANGELOG.md | 4 ++ lib/builtin/claude/src/claude_pane.dart | 11 ++++- lib/builtin/claude/src/extension.dart | 2 + lib/builtin/claude/src/tmux_session.dart | 61 ++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 lib/builtin/claude/src/tmux_session.dart diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index bc8b63a2..dd917222 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-06T14:40:38Z", + "exported_at": "2026-05-06T14:41:33Z", "decisions": [ { "id": "D-1", diff --git a/CHANGELOG.md b/CHANGELOG.md index 136498c5..d14c0698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Closing a secondary Claude pane tab now kills its tmux session + 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. - 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/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index a0e3ea64..8353286e 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart'; import 'package:clide/src/terminal/terminal.dart'; import 'session_naming.dart'; +import 'tmux_session.dart' as tmux; class ClaudePane extends StatefulWidget { const ClaudePane({ @@ -57,12 +58,20 @@ class _ClaudePaneState extends State { _eventSub?.cancel(); _eventSub = null; final id = _paneId; + final sessionName = _sessionName; _paneId = null; // Secondary panes own their tmux session — close on dispose. // Primary panes leave the tmux session alive so the next launch - // re-attaches via `tmux new-session -A` (D-041). + // re-attaches via `tmux new-session -A` (D-41). + // + // pane.close kills the ptyc-spawned tmux *client*; the tmux server + // keeps the session alive. We need an explicit kill-session for + // secondaries to actually disappear (D-41 close semantics). if (id != null && !widget.isPrimary) { unawaited(_ipc()?.request('pane.close', args: {'id': id})); + if (sessionName != null) { + unawaited(tmux.killSession(sessionName)); + } } super.dispose(); } diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index c717a99e..5b9109bf 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -1,5 +1,7 @@ import 'package:clide/clide.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart'; +import 'package:clide/builtin/claude/src/session_naming.dart'; +import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:flutter/widgets.dart'; diff --git a/lib/builtin/claude/src/tmux_session.dart b/lib/builtin/claude/src/tmux_session.dart new file mode 100644 index 00000000..587cd198 --- /dev/null +++ b/lib/builtin/claude/src/tmux_session.dart @@ -0,0 +1,61 @@ +/// tmux server interactions for Claude panes (D-41 lifecycle). +/// +/// `pane.close` only kills the ptyc-spawned tmux *client*; tmux is +/// client/server, so the server-side session keeps running after the +/// client disconnects. To honour D-41 ("closing a secondary kills that +/// tmux session" + "secondary numbering resets between clide runs"), +/// we need explicit `tmux kill-session` calls — that's what lives here. +library; + +import 'dart:io'; + +/// Override-able runner so tests don't shell out for real. +typedef TmuxRunner = Future Function(List args); + +TmuxRunner tmuxRunner = _defaultRunner; + +Future _defaultRunner(List args) => + Process.run('tmux', args); + +const _socket = ['-L', 'clide']; + +/// Kill the named tmux session on the clide socket. No-op if the +/// session does not exist (kill-session exits non-zero — we ignore it). +Future killSession(String name) async { + await tmuxRunner([..._socket, 'kill-session', '-t', name]); +} + +/// Return the names of all sessions currently alive on the clide +/// socket. Empty list if the server is not running. +Future> listClideSessions() async { + final r = await tmuxRunner([..._socket, 'list-sessions', '-F', '#{session_name}']); + if (r.exitCode != 0) return const []; + return (r.stdout as String) + .split('\n') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); +} + +/// Kill every secondary clide-claude session whose name begins with +/// [primaryName] and ends with `-`. Leaves the primary itself +/// alive (D-41). +Future reapSecondaries(String primaryName) async { + final pattern = RegExp('^${RegExp.escape(primaryName)}-\\d+\$'); + for (final s in await listClideSessions()) { + if (pattern.hasMatch(s)) { + await killSession(s); + } + } +} + +/// Kill every clide-claude session for [primaryName], including the +/// primary itself. Used by the explicit `claude.kill-all-sessions` +/// command when the user wants a hard reset. +Future killAllForRepo(String primaryName) async { + for (final s in await listClideSessions()) { + if (s == primaryName || s.startsWith('$primaryName-')) { + await killSession(s); + } + } +}