kill secondary Claude tmux sessions on tab close (T-87)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 16:41:33 +02:00
co-authored by Claude
parent 6e6546fe32
commit 1c424f26e7
5 changed files with 78 additions and 2 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"exported_at": "2026-05-06T14:40:38Z",
"exported_at": "2026-05-06T14:41:33Z",
"decisions": [
{
"id": "D-1",
+4
View File
@@ -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.
+10 -1
View File
@@ -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<ClaudePane> {
_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();
}
+2
View File
@@ -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';
+61
View File
@@ -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<ProcessResult> Function(List<String> args);
TmuxRunner tmuxRunner = _defaultRunner;
Future<ProcessResult> _defaultRunner(List<String> 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<void> 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<List<String>> 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 `-<digits>`. Leaves the primary itself
/// alive (D-41).
Future<void> 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<void> killAllForRepo(String primaryName) async {
for (final s in await listClideSessions()) {
if (s == primaryName || s.startsWith('$primaryName-')) {
await killSession(s);
}
}
}