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:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"exported_at": "2026-05-06T14:40:38Z",
|
||||
"exported_at": "2026-05-06T14:41:33Z",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "D-1",
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user