retire tmux for Claude sessions

Session lifecycle now runs entirely on the stream-json model: argv
selection picks --resume <id> for an existing transcript and
--session-id <uuid> for a fresh one, and the managed-session orchestrator
owns spawn/close. With the transport off tmux, remove the tmux session
lifecycle (reaping, kill-all-for-repo) and the tmux-polling team observer;
kill-all-sessions now closes sessions through the orchestrator. Team
membership is orchestrator-driven since the coordination broker landed.

Amends D-41 (tmux persistence -> --resume). T-167.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:05:41 +02:00
co-authored by Claude
parent 7b9861a097
commit f6b88f503f
12 changed files with 350 additions and 1028 deletions
@@ -4,7 +4,7 @@
/// - **Activity** — Claude usage stats (from `~/.claude/stats-cache.json`,
/// polled) plus the primary session's live runtime (model / mode / context /
/// skills).
/// - **Team** — a roster of live members (from the TeamObserver's join/left
/// - **Team** — a roster of live members (from orchestrator-emitted join/left
/// events + per-member status on the message bus). Auto-fronted when a team
/// spawns; mostly empty when solo.
/// - **Config** — the Claude environment settings table (model / output style /
+17 -77
View File
@@ -4,15 +4,12 @@ import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/builtin/claude/src/claude_config.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/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/pane_context_status.dart';
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/session_index.dart';
import 'package:clide/builtin/claude/src/session_storage.dart';
import 'package:clide/builtin/claude/src/team_observer.dart';
import 'package:clide/builtin/claude/src/team_panel_host.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:clide/widgets/widgets.dart';
@@ -31,7 +28,6 @@ class ClaudeExtension extends ClideExtension {
ClideExtensionContext? _ctx;
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
TeamObserver? _observer;
ClaudeConfig? _config;
ClaudeSessionOrchestrator? _orchestrator;
final List<StreamSubscription<dynamic>> _subs = [];
@@ -63,7 +59,7 @@ class ClaudeExtension extends ClideExtension {
CommandContribution(
id: 'claude.kill-all-sessions',
command: 'claude.kill-all-sessions',
title: 'Claude: kill all tmux sessions for this repo',
title: 'Claude: kill all sessions for this repo',
run: _killAllSessions,
),
CommandContribution(
@@ -84,9 +80,12 @@ class ClaudeExtension extends ClideExtension {
),
// In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here.
// flex: 1 → StatusbarHost wraps this in Flexible(loose) so the slot
// yields width under pressure and ClideMarquee scrolls (T-160).
StatusItemContribution(
id: 'claude.status-context',
priority: 50,
flex: 1,
build: (_) => const PaneContextStatusItem(),
),
];
@@ -116,39 +115,6 @@ class ClaudeExtension extends ClideExtension {
// session outlives its pane and is shared across surfaces.
_orchestrator = ClaudeSessionOrchestrator();
activeSessionOrchestrator = _orchestrator;
// 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);
// Observe a tmux agent team for the open workspace (T-139/T-140). The
// observer emits TeamMemberJoined/Left, which TeamPanelHost renders as
// teammate tiles. Restart it as the project changes.
if (ctx.project.current != null) _restartObserver(ctx.project.current!.path);
_subs.add(ctx.events.on<ProjectOpened>().listen((e) => _restartObserver(e.path)));
_subs.add(ctx.events.on<ProjectClosed>().listen((_) => _stopObserver()));
}
void _restartObserver(String workspacePath) {
final ctx = _ctx;
if (ctx == null) return;
unawaited(_observer?.dispose());
_observer = TeamObserver(
workspacePath: workspacePath,
events: ctx.events,
messages: ctx.messages,
)..start();
}
void _stopObserver() {
unawaited(_observer?.dispose());
_observer = null;
}
@override
@@ -157,47 +123,31 @@ class ClaudeExtension extends ClideExtension {
unawaited(s.cancel());
}
_subs.clear();
_stopObserver();
if (identical(activeSessionOrchestrator, _orchestrator)) activeSessionOrchestrator = null;
_orchestrator?.dispose();
_orchestrator = null;
if (identical(activeClaudeConfig, _config)) activeClaudeConfig = null;
_config?.dispose();
_config = null;
// 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.
/// Hard-reset command: close every clide-managed Claude session for this
/// repo (primary + all secondaries + any team members). The user invokes
/// this when they want a hard reset — after a Claude wedge or to start
/// completely fresh. All sessions are torn down through the orchestrator
/// (D-77); the primary will re-spawn and resume on the next pane build.
Future<IpcResponse> _killAllSessions(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
final orch = _orchestrator;
if (orch == 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) {
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});
}
}
}
}
// Close every tracked session through the orchestrator; this kills each
// process and releases its resources. Panes will see their session gone
// and surface an error / restart on next interaction.
final ids = orch.sessions.map((m) => m.id).toList();
for (final id in ids) {
await orch.close(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'});
}
@@ -218,14 +168,4 @@ class ClaudeExtension extends ClideExtension {
);
return IpcResponse.ok(id: '', data: const {'status': 'shown'});
}
Future<String?> _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);
}
}
+25 -26
View File
@@ -1,37 +1,36 @@
/// Derive deterministic tmux session names for Claude panes (D-041).
/// Claude session-id derivation for clide panes (D-77/T-146).
///
/// The primary session name encodes the repo path in a human-readable
/// form: `clide-claude-<path-slug>`. For example:
/// ~/projects/clide → clide-claude-projects-clide
/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp
/// Session continuity is via `--resume <session-id>` (D-77); tmux session
/// names are no longer used. This module provides:
///
/// Secondary sessions append `-N`.
///
/// Also derives the Claude `--session-id` for each pane (T-146): a pane's
/// transcript is named `<session-id>.jsonl`, so binding each pane to a
/// distinct UUID is how concurrent sessions in one workspace stay
/// independent. The primary's id is deterministic from its (stable)
/// session name so it resumes across restarts; secondaries get a fresh
/// random id each spawn so a clean session is always one click away.
/// - [primarySessionId] — a stable UUID derived from the repo path, so the
/// primary pane resumes the same transcript across clide restarts.
/// - [freshSessionId] — a fresh random UUID for secondary panes, which always
/// start clean.
/// - [claudeLaunchArgs] — selects `--resume` vs `--session-id` depending on
/// whether the transcript already exists on disk (T-161).
library;
import 'dart:convert';
import 'dart:io' show Platform;
import 'dart:math';
/// Stable session name for the primary Claude pane of [repoRoot].
String primarySessionName(String repoRoot) {
// ---------------------------------------------------------------------------
// Internal seed derivation (private — tmux slug names are no longer public)
// ---------------------------------------------------------------------------
// Slug cap kept for backward-compat: the same path still produces the same
// deterministic UUID across upgrades because the seed string is unchanged.
const _maxSlugLen = 80;
/// Derive a stable, short seed from [repoRoot] to feed into the deterministic
/// UUID. This is the old tmux-session-name slug, kept internal and unchanged
/// so existing transcripts survive the tmux→--resume migration (the UUID is
/// derived from this seed, so the UUID must not change).
String _primarySessionSeed(String repoRoot) {
return 'clide-claude-${_slugify(repoRoot)}';
}
/// Nth secondary session name. [n] starts at 1.
String secondarySessionName(String repoRoot, int n) {
return '${primarySessionName(repoRoot)}-$n';
}
// tmux session names max out at 256 chars; keep ours well under.
const _maxSlugLen = 80;
String _slugify(String path) {
final home = Platform.environment['HOME'] ?? '';
var p = path;
@@ -64,9 +63,9 @@ String _hash(String s) {
// ---------------------------------------------------------------------------
/// Stable session id for the primary pane of [repoRoot]: a UUID
/// deterministically derived from the primary session name, so the same
/// workspace re-binds the same `<uuid>.jsonl` across restarts (resume).
String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot));
/// deterministically derived from the repo path, so the same workspace
/// re-binds the same `<uuid>.jsonl` across restarts (`--resume`, D-77).
String primarySessionId(String repoRoot) => _deterministicUuid(_primarySessionSeed(repoRoot));
/// The session-selection args for launching [sessionId]: `--resume` an
/// existing session, or `--session-id` to create a new one. `--session-id`
-329
View File
@@ -1,329 +0,0 @@
/// tmux agent-team observer (epic T-132, T-139, D-75).
///
/// THE single drift-containment point for Claude Code's experimental tmux
/// team mode. Everything that reads CC's undocumented team artifacts lives
/// here so a CC change only breaks one file.
///
/// # What's reliable vs. fragile
/// - **Reliable — lifecycle + identity (config-driven).** A team writes
/// `~/.claude/teams/<team>/config.json` listing each member with its
/// `tmuxPaneId` (`%N`, empty for the lead), `name`, `agentType`, `model`,
/// `color`, `cwd`, `joinedAt`. Polling `tmux -L clide list-panes -a` and
/// correlating live pane ids with `tmuxPaneId` gives a dependable
/// joined/left signal and full identity — no transcript needed.
/// - **Fragile — per-teammate transcript join.** A teammate's transcript is
/// a subagent file `<munged-cwd>/<leadSessionId>/subagents/agent-<hex>.jsonl`
/// whose only ids are a random hex (the filename) and a `slug`; it carries
/// no `name`/`agentType`. The config's `agentId` is `<name>@<team>` — a
/// different namespace — so there is no shared key. We join via a sibling
/// `agent-<hex>.meta.json` (`{agentType}`) when present, else fall back to
/// zipping members-by-`joinedAt` against files-by-mtime. This is the part
/// most likely to drift; it needs validation against a live team run.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/kernel/src/events/types.dart';
/// One member of a team config.
class TeamMember {
const TeamMember({
required this.agentId,
required this.name,
required this.agentType,
required this.tmuxPaneId,
this.model,
this.color,
this.cwd,
this.joinedAt,
});
/// Config agent id, `<name>@<team>`.
final String agentId;
final String name;
final String agentType;
/// tmux pane id (`%N`); empty for the lead.
final String tmuxPaneId;
final String? model;
final String? color;
final String? cwd;
final int? joinedAt;
bool get isLead => tmuxPaneId.isEmpty || agentType == 'team-lead';
}
/// Parsed `~/.claude/teams/<team>/config.json`.
class TeamConfig {
const TeamConfig({
required this.team,
required this.leadSessionId,
required this.members,
required this.createdAt,
});
final String team;
final String leadSessionId;
final List<TeamMember> members;
final int createdAt;
/// Non-lead members (the panes we surface).
List<TeamMember> get teammates => members.where((m) => !m.isLead).toList();
/// Parse a config; returns null on malformed JSON.
static TeamConfig? parse(String teamDirName, String jsonStr) {
Map<String, dynamic> d;
try {
d = jsonDecode(jsonStr) as Map<String, dynamic>;
} catch (_) {
return null;
}
final members = <TeamMember>[];
for (final m in (d['members'] as List? ?? const [])) {
if (m is! Map) continue;
members.add(TeamMember(
agentId: m['agentId'] as String? ?? '',
name: m['name'] as String? ?? '',
agentType: m['agentType'] as String? ?? '',
tmuxPaneId: m['tmuxPaneId'] as String? ?? '',
model: m['model'] as String?,
color: m['color'] as String?,
cwd: m['cwd'] as String?,
joinedAt: (m['joinedAt'] as num?)?.toInt(),
));
}
return TeamConfig(
team: (d['name'] as String?) ?? teamDirName,
leadSessionId: d['leadSessionId'] as String? ?? '',
members: members,
createdAt: (d['createdAt'] as num?)?.toInt() ?? 0,
);
}
}
/// Discover the active team config for [workspacePath]: the team (under
/// [teamsBase]) any of whose members runs in [workspacePath], newest by
/// `createdAt` when several match. Null if none.
Future<TeamConfig?> discoverTeam(String workspacePath, {required String teamsBase}) async {
final dir = Directory(teamsBase);
if (!await dir.exists()) return null;
TeamConfig? best;
await for (final entity in dir.list()) {
if (entity is! Directory) continue;
final cfgFile = File('${entity.path}/config.json');
if (!await cfgFile.exists()) continue;
final cfg = TeamConfig.parse(entity.path.split('/').last, await cfgFile.readAsString());
if (cfg == null) continue;
if (!cfg.members.any((m) => m.cwd == workspacePath)) continue;
if (best == null || cfg.createdAt > best.createdAt) best = cfg;
}
return best;
}
/// Returns the set of live tmux pane ids on the `clide` socket. Injectable
/// so tests don't shell out.
typedef PaneLister = Future<Set<String>> Function();
class _LiveMember {
_LiveMember(this.member, this.team, this.publisher, this.statusSub);
final TeamMember member;
final String team;
final TranscriptPublisher? publisher;
/// Forwards the member's status onto [ClaudeConversation.memberStatusChannel]
/// (T-157); cancelled when the member leaves.
final StreamSubscription<SessionStatus>? statusSub;
}
/// Watches a workspace's tmux team and emits [TeamMemberJoined] /
/// [TeamMemberLeft] as panes appear/disappear, publishing each teammate's
/// transcript onto the [MessageBus] under its per-agent channel.
class TeamObserver {
TeamObserver({
required this.workspacePath,
required DaemonBus events,
required MessageBus messages,
String? teamsBase,
String? projectsBase,
PaneLister? paneLister,
Duration pollInterval = const Duration(seconds: 2),
}) : _events = events,
_messages = messages,
_teamsBase = teamsBase ?? _defaultTeamsBase(),
_projectsBase = projectsBase ?? _defaultProjectsBase(),
_paneLister = paneLister ?? _tmuxPaneLister,
_pollInterval = pollInterval;
final String workspacePath;
final DaemonBus _events;
final MessageBus _messages;
final String _teamsBase;
final String _projectsBase;
final PaneLister _paneLister;
final Duration _pollInterval;
Timer? _timer;
bool _disposed = false;
final Map<String, _LiveMember> _live = {};
static String _defaultTeamsBase() {
final home = Platform.environment['HOME'] ?? '';
return home.isNotEmpty ? '$home/.claude/teams' : '.claude/teams';
}
static String _defaultProjectsBase() {
final home = Platform.environment['HOME'] ?? '';
return home.isNotEmpty ? '$home/.claude/projects' : '.claude/projects';
}
static Future<Set<String>> _tmuxPaneLister() async {
try {
final r = await Process.run('tmux', ['-L', 'clide', 'list-panes', '-a', '-F', '#{pane_id}']);
if (r.exitCode != 0) return const {};
return (r.stdout as String).split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
} catch (_) {
return const {};
}
}
/// Begin polling.
void start() => _scheduleNext();
void _scheduleNext() {
_timer = Timer(_pollInterval, () async {
if (_disposed) return;
await tick();
if (!_disposed) _scheduleNext();
});
}
/// One poll cycle (public for tests). Diffs the config roster against the
/// live panes and emits joined/left.
Future<void> tick() async {
final config = await discoverTeam(workspacePath, teamsBase: _teamsBase);
if (config == null) {
await _killAll();
return;
}
final livePanes = await _paneLister();
final configIds = <String>{};
for (final m in config.teammates) {
configIds.add(m.agentId);
final paneLive = livePanes.contains(m.tmuxPaneId);
final tracked = _live.containsKey(m.agentId);
if (paneLive && !tracked) {
await _joined(config, m);
} else if (!paneLive && tracked) {
await _left(m.agentId);
}
}
// A member dropped from the config (team reshaped) also counts as left.
for (final id in _live.keys.toList()) {
if (!configIds.contains(id)) await _left(id);
}
}
Future<void> _joined(TeamConfig config, TeamMember m) async {
final path = await _resolveTranscript(config, m);
TranscriptPublisher? pub;
StreamSubscription<SessionStatus>? statusSub;
if (path != null) {
pub = TranscriptPublisher(
messages: _messages,
reader: TranscriptReader(m.cwd ?? workspacePath, file: path, projectsBase: _projectsBase),
channel: ClaudeConversation.teammateChannel(m.agentId),
);
// Forward this member's status onto the shared status channel so the
// team sidebar can show its mode + context without re-tailing (T-157).
statusSub = pub.statusStream.listen((s) => _messages.publish(
ClaudeConversation.publisher,
ClaudeConversation.memberStatusChannel,
ClaudeConversation.memberStatusData(m.agentId, s),
));
}
_live[m.agentId] = _LiveMember(m, config.team, pub, statusSub);
_events.emit(TeamMemberJoined(
team: config.team,
agentId: m.agentId,
name: m.name,
agentType: m.agentType,
paneId: m.tmuxPaneId,
model: m.model,
color: m.color,
cwd: m.cwd,
transcriptPath: path,
));
}
Future<void> _left(String agentId) async {
final live = _live.remove(agentId);
if (live == null) return;
await live.statusSub?.cancel();
await live.publisher?.dispose();
_events.emit(TeamMemberLeft(team: live.team, agentId: agentId, paneId: live.member.tmuxPaneId));
}
Future<void> _killAll() async {
for (final id in _live.keys.toList()) {
await _left(id);
}
}
/// Best-effort join of [member] to its subagent transcript file. See the
/// library doc — this is the drift-prone part. Returns null if no
/// transcript can be resolved.
Future<String?> _resolveTranscript(TeamConfig config, TeamMember member) async {
final cwd = member.cwd;
if (cwd == null || config.leadSessionId.isEmpty) return null;
final munged = cwd.replaceAll('/', '-');
final subDir = Directory('$_projectsBase/$munged/${config.leadSessionId}/subagents');
if (!await subDir.exists()) return null;
final files = <File>[];
await for (final e in subDir.list()) {
if (e is File && e.path.endsWith('.jsonl') && !e.path.contains('compact')) {
files.add(e);
}
}
if (files.isEmpty) return null;
// Clean join: a sibling `.meta.json` whose agentType matches.
for (final f in files) {
final metaPath = '${f.path.substring(0, f.path.length - '.jsonl'.length)}.meta.json';
final meta = File(metaPath);
if (!await meta.exists()) continue;
try {
final m = jsonDecode(await meta.readAsString());
if (m is Map && m['agentType'] == member.agentType) return f.path;
} catch (_) {
// ignore malformed meta
}
}
// Fallback: zip teammates-by-joinedAt against files-by-mtime.
final teammates = [...config.teammates]..sort((a, b) => (a.joinedAt ?? 0).compareTo(b.joinedAt ?? 0));
final idx = teammates.indexWhere((m) => m.agentId == member.agentId);
if (idx < 0) return null;
final stats = <(File, DateTime)>[];
for (final f in files) {
stats.add((f, (await f.stat()).modified));
}
stats.sort((a, b) => a.$2.compareTo(b.$2));
return idx < stats.length ? stats[idx].$1.path : null;
}
Future<void> dispose() async {
_disposed = true;
_timer?.cancel();
_timer = null;
await _killAll();
}
}
+3 -4
View File
@@ -1,12 +1,11 @@
/// Faithful tiling for a Claude agent team (epic T-132, T-140).
///
/// Wraps the lead Claude surface on the left; when the [TeamObserver]
/// Wraps the lead Claude surface on the left; when the orchestrator
/// emits [TeamMemberJoined], a resizable right pane appears holding a
/// teammate tile per live member, arranged in a responsive grid that
/// wraps 1→2→3 columns by count. Each tile renders the teammate's
/// conversation from its per-agent MessageBus channel (published by the
/// observer). Tiles vanish on [TeamMemberLeft]. With no team, only the
/// lead is shown.
/// conversation from its per-agent MessageBus channel. Tiles vanish on
/// [TeamMemberLeft]. With no team, only the lead is shown.
library;
import 'dart:async';
-92
View File
@@ -1,92 +0,0 @@
/// tmux server interactions for Claude panes (D-41 lifecycle).
///
/// `pane.close` only kills the PTY-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]);
}
/// Whether [name] is a live session on the clide socket. Used to suppress
/// a spurious "session exited" when a transient tmux client process exits
/// but the session itself is fine (T-149 follow-up).
Future<bool> hasSession(String name) async {
final r = await tmuxRunner([..._socket, 'has-session', '-t', name]);
return r.exitCode == 0;
}
/// 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);
}
}
}
/// Named tmux paste buffer clide loads composed messages into.
const _composeBuffer = 'clide-compose';
/// Submit [text] to [session] as a single message: load it into a named
/// paste buffer, paste it in bracketed mode (so multi-line content and
/// special characters arrive as one block rather than a stream of
/// submits), then press Enter.
///
/// This goes through the tmux *server*, so it reaches Claude whether or
/// not a client is attached. Writing to the app's spawned client PTY
/// (`pane.write`) does not — once that client detaches, the PTY is dead
/// and keystrokes vanish (the bug this replaces).
Future<void> sendMessage(String session, String text) async {
await tmuxRunner([..._socket, 'set-buffer', '-b', _composeBuffer, '--', text]);
await tmuxRunner([..._socket, 'paste-buffer', '-p', '-d', '-b', _composeBuffer, '-t', session]);
await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']);
}
/// Submit [text] to [session] as TYPED input (literal keystrokes, no bracketed
/// paste), then Enter — so Claude's TUI parses a leading `/` as a slash
/// command, exactly as if the user had typed it (T-153). For single-line
/// slash-command input only; regular messages go through [sendMessage] so
/// bracketed paste keeps multi-line content and stray slashes literal.
Future<void> sendCommand(String session, String text) async {
await tmuxRunner([..._socket, 'send-keys', '-t', session, '-l', '--', text]);
await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']);
}