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
+11
View File
@@ -30,6 +30,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed ### Changed
- Claude session persistence now rides on `claude --resume` instead of tmux
(T-167, amends D-41). A restart resumes the primary session, `/clear`
starts a fresh one, and `/resume` reopens a picked session — all without
tmux.
- Permission prompt cards render the tool input in the shape that fits the - Permission prompt cards render the tool input in the shape that fits the
tool — Bash shows the command as a shell code block (with a footer for tool — Bash shows the command as a shell code block (with a footer for
`run_in_background` / `timeout`), Write shows the path plus the content `run_in_background` / `timeout`), Write shows the path plus the content
@@ -41,6 +45,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
environment settings table. Activity and Config share one table geometry so environment settings table. Activity and Config share one table geometry so
switching doesn't jump. switching doesn't jump.
### Removed
- tmux is no longer used for Claude sessions (T-167) — the tmux session
lifecycle and the tmux-polling team observer are gone, replaced by the
managed-session orchestrator. tmux is still used for the general-purpose
terminal pane.
### Added ### Added
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP - Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
@@ -4,7 +4,7 @@
/// - **Activity** — Claude usage stats (from `~/.claude/stats-cache.json`, /// - **Activity** — Claude usage stats (from `~/.claude/stats-cache.json`,
/// polled) plus the primary session's live runtime (model / mode / context / /// polled) plus the primary session's live runtime (model / mode / context /
/// skills). /// 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 /// events + per-member status on the message bus). Auto-fronted when a team
/// spawns; mostly empty when solo. /// spawns; mostly empty when solo.
/// - **Config** — the Claude environment settings table (model / output style / /// - **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/clide.dart';
import 'package:clide/builtin/claude/src/claude_config.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/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/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/pane_context_status.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/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/session_index.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/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/team_panel_host.dart';
import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux;
import 'package:clide/extension/extension.dart'; import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
@@ -31,7 +28,6 @@ class ClaudeExtension extends ClideExtension {
ClideExtensionContext? _ctx; ClideExtensionContext? _ctx;
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey(); final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
TeamObserver? _observer;
ClaudeConfig? _config; ClaudeConfig? _config;
ClaudeSessionOrchestrator? _orchestrator; ClaudeSessionOrchestrator? _orchestrator;
final List<StreamSubscription<dynamic>> _subs = []; final List<StreamSubscription<dynamic>> _subs = [];
@@ -63,7 +59,7 @@ class ClaudeExtension extends ClideExtension {
CommandContribution( CommandContribution(
id: 'claude.kill-all-sessions', id: 'claude.kill-all-sessions',
command: '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, run: _killAllSessions,
), ),
CommandContribution( CommandContribution(
@@ -84,9 +80,12 @@ class ClaudeExtension extends ClideExtension {
), ),
// In-pane status slot (T-145): the active Claude pane publishes // In-pane status slot (T-145): the active Claude pane publishes
// its model · permission-mode · context line here. // 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( StatusItemContribution(
id: 'claude.status-context', id: 'claude.status-context',
priority: 50, priority: 50,
flex: 1,
build: (_) => const PaneContextStatusItem(), build: (_) => const PaneContextStatusItem(),
), ),
]; ];
@@ -116,39 +115,6 @@ class ClaudeExtension extends ClideExtension {
// session outlives its pane and is shared across surfaces. // session outlives its pane and is shared across surfaces.
_orchestrator = ClaudeSessionOrchestrator(); _orchestrator = ClaudeSessionOrchestrator();
activeSessionOrchestrator = _orchestrator; 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 @override
@@ -157,47 +123,31 @@ class ClaudeExtension extends ClideExtension {
unawaited(s.cancel()); unawaited(s.cancel());
} }
_subs.clear(); _subs.clear();
_stopObserver();
if (identical(activeSessionOrchestrator, _orchestrator)) activeSessionOrchestrator = null; if (identical(activeSessionOrchestrator, _orchestrator)) activeSessionOrchestrator = null;
_orchestrator?.dispose(); _orchestrator?.dispose();
_orchestrator = null; _orchestrator = null;
if (identical(activeClaudeConfig, _config)) activeClaudeConfig = null; if (identical(activeClaudeConfig, _config)) activeClaudeConfig = null;
_config?.dispose(); _config?.dispose();
_config = null; _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 /// Hard-reset command: close every clide-managed Claude session for this
/// repo, primary included. The user invokes this when they want to /// repo (primary + all secondaries + any team members). The user invokes
/// start over — typically after a tmux/Claude wedge. /// 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 { Future<IpcResponse> _killAllSessions(List<String> args) async {
final ctx = _ctx; final orch = _orchestrator;
if (ctx == null) return IpcResponse.ok(id: '', data: const {}); if (orch == null) return IpcResponse.ok(id: '', data: const {});
// Close the UI panes first so they don't try to talk to a tmux // Close every tracked session through the orchestrator; this kills each
// server that's about to lose their sessions. // process and releases its resources. Panes will see their session gone
final resp = await ctx.ipc.request('pane.list'); // and surface an error / restart on next interaction.
if (resp.ok) { final ids = orch.sessions.map((m) => m.id).toList();
final panes = resp.data['panes']; for (final id in ids) {
if (panes is List) { await orch.close(id);
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'}); return IpcResponse.ok(id: '', data: const {'status': 'killed'});
} }
@@ -218,14 +168,4 @@ class ClaudeExtension extends ClideExtension {
); );
return IpcResponse.ok(id: '', data: const {'status': 'shown'}); 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 /// Session continuity is via `--resume <session-id>` (D-77); tmux session
/// form: `clide-claude-<path-slug>`. For example: /// names are no longer used. This module provides:
/// ~/projects/clide → clide-claude-projects-clide
/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp
/// ///
/// Secondary sessions append `-N`. /// - [primarySessionId] — a stable UUID derived from the repo path, so the
/// /// primary pane resumes the same transcript across clide restarts.
/// Also derives the Claude `--session-id` for each pane (T-146): a pane's /// - [freshSessionId] — a fresh random UUID for secondary panes, which always
/// transcript is named `<session-id>.jsonl`, so binding each pane to a /// start clean.
/// distinct UUID is how concurrent sessions in one workspace stay /// - [claudeLaunchArgs] — selects `--resume` vs `--session-id` depending on
/// independent. The primary's id is deterministic from its (stable) /// whether the transcript already exists on disk (T-161).
/// session name so it resumes across restarts; secondaries get a fresh
/// random id each spawn so a clean session is always one click away.
library; library;
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'dart:math'; 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)}'; 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) { String _slugify(String path) {
final home = Platform.environment['HOME'] ?? ''; final home = Platform.environment['HOME'] ?? '';
var p = path; var p = path;
@@ -64,9 +63,9 @@ String _hash(String s) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Stable session id for the primary pane of [repoRoot]: a UUID /// Stable session id for the primary pane of [repoRoot]: a UUID
/// deterministically derived from the primary session name, so the same /// deterministically derived from the repo path, so the same workspace
/// workspace re-binds the same `<uuid>.jsonl` across restarts (resume). /// re-binds the same `<uuid>.jsonl` across restarts (`--resume`, D-77).
String primarySessionId(String repoRoot) => _deterministicUuid(primarySessionName(repoRoot)); String primarySessionId(String repoRoot) => _deterministicUuid(_primarySessionSeed(repoRoot));
/// The session-selection args for launching [sessionId]: `--resume` an /// The session-selection args for launching [sessionId]: `--resume` an
/// existing session, or `--session-id` to create a new one. `--session-id` /// 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). /// 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 /// emits [TeamMemberJoined], a resizable right pane appears holding a
/// teammate tile per live member, arranged in a responsive grid that /// teammate tile per live member, arranged in a responsive grid that
/// wraps 1→2→3 columns by count. Each tile renders the teammate's /// wraps 1→2→3 columns by count. Each tile renders the teammate's
/// conversation from its per-agent MessageBus channel (published by the /// conversation from its per-agent MessageBus channel. Tiles vanish on
/// observer). Tiles vanish on [TeamMemberLeft]. With no team, only the /// [TeamMemberLeft]. With no team, only the lead is shown.
/// lead is shown.
library; library;
import 'dart:async'; 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']);
}
@@ -0,0 +1,275 @@
/// Session lifecycle tests for T-167 (--resume model, /clear, /resume,
/// kill-all-sessions via orchestrator).
///
/// Pure Dart (no Flutter): exercises the session argv selection, the
/// spawn-spec logic for clear vs resume, and the kill-all-sessions command
/// behaviour — all without spawning a real `claude` process.
library;
import 'dart:async';
import 'dart:io';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:test/test.dart';
// ---------------------------------------------------------------------------
// Minimal fake process — same as session_orchestrator_test.dart.
// ---------------------------------------------------------------------------
class _FakeProc implements StreamJsonProcess {
final _ctl = StreamController<String>.broadcast();
final List<String> writes = [];
bool killed = false;
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) => writes.add(line);
@override
Future<void> kill() async => killed = true;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ClaudeSessionOrchestrator _orch(List<_FakeProc> created) {
return ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _FakeProc();
created.add(p);
return p;
},
);
}
SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath}) => SpawnSpec(
id: id,
role: id,
sessionId: '$id-uuid',
cwd: '/repo',
resume: resume,
transcriptPath: transcriptPath,
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
// ---- argv selection (D-77 / T-161) -------------------------------------
group('claudeLaunchArgs — argv selection', () {
// These mirror session_naming_test.dart but put the semantics in context.
test('/clear → fresh session — uses --session-id', () {
// /clear spawns a brand-new session: transcript does not exist yet.
final args = claudeLaunchArgs('new-uuid', resume: false);
expect(args, ['--session-id', 'new-uuid']);
});
test('/resume → existing session — uses --resume', () {
// /resume picks a past session whose transcript is already on disk.
final args = claudeLaunchArgs('past-uuid', resume: true);
expect(args, ['--resume', 'past-uuid']);
});
test('restart after transcript exists → --resume preserves continuity', () {
// On restart, the primary's transcript is on disk → resume:true.
const id = 'stable-uuid';
expect(claudeLaunchArgs(id, resume: true).first, '--resume');
});
test('secondary spawn → always --session-id (fresh)', () {
// Secondaries always start clean: freshSessionId() + resume:false.
final id = freshSessionId();
expect(claudeLaunchArgs(id, resume: false).first, '--session-id');
});
});
// ---- primarySessionId stability (migration safety) ----------------------
group('primarySessionId — stable across restart', () {
test('same repo root always yields the same UUID', () {
const root = '/home/user/projects/myapp';
expect(primarySessionId(root), primarySessionId(root));
});
test('different repos yield different UUIDs', () {
expect(primarySessionId('/home/user/a'), isNot(primarySessionId('/home/user/b')));
});
});
// ---- /clear — spawn a fresh session via orchestrator --------------------
group('/clear — fresh session via orchestrator', () {
late List<_FakeProc> created;
late ClaudeSessionOrchestrator orch;
setUp(() {
created = [];
orch = _orch(created);
});
tearDown(() => orch.dispose());
test('spawns a new session with resume:false (empty conversation)', () async {
final managed = await orch.spawn(_spec('primary', resume: false));
expect(managed.id, 'primary');
expect(managed.conversation.items, isEmpty);
expect(created, hasLength(1));
// The process receives --session-id (not --resume) in its init args.
// The factory was given the right spec; verify the session is new.
expect(managed.sessionId, 'primary-uuid');
});
test('close old + spawn new resets the session (clear flow)', () async {
await orch.spawn(_spec('primary', resume: false));
// /clear: close the current session and spawn a fresh one.
await orch.close('primary');
expect(orch.byId('primary'), isNull);
expect(created.first.killed, isTrue);
await orch.spawn(_spec('primary', resume: false));
expect(orch.sessions, hasLength(1));
expect(created, hasLength(2)); // a second process was created
});
});
// ---- /resume — bind to an existing session via orchestrator -------------
group('/resume — resume an existing session via orchestrator', () {
late List<_FakeProc> created;
late ClaudeSessionOrchestrator orch;
setUp(() {
created = [];
orch = _orch(created);
});
tearDown(() => orch.dispose());
test('spawns with resume:true and seeds conversation from transcript', () async {
final tmp = await Directory.systemTemp.createTemp('clide-resume-');
final file = File('${tmp.path}/session.jsonl');
await file.writeAsString(
'{"type":"user","uuid":"u1","timestamp":"2026-05-01T00:00:00Z","isSidechain":false,'
'"message":{"role":"user","content":"hello from the past"}}\n',
);
final managed = await orch.spawn(_spec(
'primary',
resume: true,
transcriptPath: file.path,
));
expect(managed.conversation.items, hasLength(1));
await tmp.delete(recursive: true);
});
test('close old + spawn resumed resets to picked session (resume flow)', () async {
await orch.spawn(_spec('primary', resume: false));
await orch.close('primary');
// /resume picked a past session id; re-spawn with resume:true.
final picked = SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'picked-past-uuid',
cwd: '/repo',
resume: true,
);
final managed = await orch.spawn(picked);
expect(managed.sessionId, 'picked-past-uuid');
expect(created, hasLength(2));
});
});
// ---- claude.kill-all-sessions via orchestrator --------------------------
group('claude.kill-all-sessions via orchestrator (T-167)', () {
late List<_FakeProc> created;
late ClaudeSessionOrchestrator orch;
setUp(() {
created = [];
orch = _orch(created);
});
tearDown(() => orch.dispose());
test('kills the primary session through the orchestrator', () async {
await orch.spawn(_spec('primary'));
expect(orch.sessions, hasLength(1));
final ids = orch.sessions.map((m) => m.id).toList();
for (final id in ids) {
await orch.close(id);
}
expect(orch.sessions, isEmpty);
await Future<void>.delayed(Duration.zero);
expect(created.single.killed, isTrue);
});
test('kills primary + all secondaries and leaves orchestrator empty', () async {
await orch.spawn(_spec('primary'));
await orch.spawn(_spec('secondary-1'));
await orch.spawn(_spec('secondary-2'));
expect(orch.sessions, hasLength(3));
// Simulate what _killAllSessions does in extension.dart.
final ids = orch.sessions.map((m) => m.id).toList();
for (final id in ids) {
await orch.close(id);
}
expect(orch.sessions, isEmpty);
await Future<void>.delayed(Duration.zero);
expect(created.every((p) => p.killed), isTrue);
});
test('kill-all on empty orchestrator is a no-op', () async {
// No sessions — the loop is a no-op, no crash.
final ids = orch.sessions.map((m) => m.id).toList();
for (final id in ids) {
await orch.close(id);
}
expect(orch.sessions, isEmpty);
expect(created, isEmpty);
});
test('kill-all includes team sessions', () async {
await orch.spawn(SpawnSpec(
id: 'primary',
role: 'lead',
sessionId: 'primary-uuid',
cwd: '/repo',
team: true,
memberName: 'lead',
));
await orch.spawn(SpawnSpec(
id: 'teammate:tyre',
role: 'teammate',
sessionId: 'tyre-uuid',
cwd: '/repo',
team: true,
memberName: 'tyre',
));
expect(orch.sessions, hasLength(2));
expect(orch.broker.members, hasLength(2));
final ids = orch.sessions.map((m) => m.id).toList();
for (final id in ids) {
await orch.close(id);
}
expect(orch.sessions, isEmpty);
expect(orch.broker.members, isEmpty);
});
});
}
+16 -52
View File
@@ -2,56 +2,10 @@ import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('claude session naming', () { // The tmux-slug functions (primarySessionName / secondarySessionName) are
test('primary name is deterministic per repo path', () { // retired as public API (D-77 / T-167). The UUID derivation is kept because
final a = primarySessionName('/home/me/clide'); // `primarySessionId` still deterministically derives its UUID from the old
final b = primarySessionName('/home/me/clide'); // slug (private) so existing transcripts survive the migration.
expect(a, b);
expect(a, startsWith('clide-claude-'));
});
test('different repos yield different primaries', () {
final a = primarySessionName('/home/me/clide');
final b = primarySessionName('/home/me/other');
expect(a, isNot(b));
});
test('secondary names carry the N suffix', () {
final p = primarySessionName('/home/me/clide');
final s1 = secondarySessionName('/home/me/clide', 1);
final s2 = secondarySessionName('/home/me/clide', 2);
expect(s1, '$p-1');
expect(s2, '$p-2');
});
test('a HOME-relative path collapses the HOME prefix in the slug', () {
// Forces the `p.startsWith(home)` branch.
final home = const String.fromEnvironment('HOME');
// Use a path we know lives under the platform HOME so the branch fires.
// In test environments HOME is set; the path /tmp may or may not be
// under it. Use a synthesized HOME path so the assert holds regardless.
final fake = '${home.isEmpty ? '/home/test' : home}/projects/clide';
final name = primarySessionName(fake);
expect(name, contains('projects-clide'));
});
test('path of only "/" slugifies to "root"', () {
// Exercises the "strip leading/trailing '-' then fall back" branch.
expect(primarySessionName('/'), 'clide-claude-root');
});
test('path longer than the slug cap hashes to 8 hex chars', () {
final long = '/${'segment/' * 30}leaf';
final name = primarySessionName(long);
// Hash form: clide-claude-<8 hex>.
expect(name, matches(RegExp(r'^clide-claude-[0-9a-f]{8}$')));
});
test('the same long path produces a stable hash', () {
final long = '/${'a/' * 200}';
expect(primarySessionName(long), primarySessionName(long));
});
});
group('claude session ids (T-146)', () { group('claude session ids (T-146)', () {
final uuidRe = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'); final uuidRe = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$');
@@ -77,15 +31,25 @@ void main() {
}); });
}); });
group('claudeLaunchArgs (T-161)', () { group('claudeLaunchArgs (T-161 / T-167)', () {
test('resumes an existing session with --resume, not --session-id', () { test('resumes an existing session with --resume, not --session-id', () {
// --session-id refuses an existing id ("already in use"), so resuming // --session-id refuses an existing id ("already in use"), so resuming
// (transcript on disk) must use --resume. // (transcript on disk) must use --resume (D-77).
expect(claudeLaunchArgs('abc', resume: true), ['--resume', 'abc']); expect(claudeLaunchArgs('abc', resume: true), ['--resume', 'abc']);
}); });
test('creates a new session with --session-id', () { test('creates a new session with --session-id', () {
expect(claudeLaunchArgs('abc', resume: false), ['--session-id', 'abc']); expect(claudeLaunchArgs('abc', resume: false), ['--session-id', 'abc']);
}); });
test('resume flag controls the verb — same id, different verb', () {
const id = '11111111-1111-4111-8111-111111111111';
final fresh = claudeLaunchArgs(id, resume: false);
final resumed = claudeLaunchArgs(id, resume: true);
expect(fresh.first, '--session-id');
expect(resumed.first, '--resume');
expect(fresh.last, id);
expect(resumed.last, id);
});
}); });
} }
-308
View File
@@ -1,308 +0,0 @@
/// Tests for the tmux team observer (T-139). Pure Dart (no Flutter):
/// config parsing/discovery, the config-driven joined/left lifecycle, and
/// the best-effort subagent-transcript join — all exercised against
/// on-disk fixtures, mirroring how the T-134 spike validated CC's
/// undocumented team artifacts.
library;
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/team_observer.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';
import 'package:test/test.dart';
const _ws = '/work/space';
String _configJson({
String team = 'myteam',
int createdAt = 1000,
String leadSessionId = 'sid-1',
String cwd = _ws,
List<Map<String, dynamic>> teammates = const [],
}) {
return jsonEncode({
'name': team,
'createdAt': createdAt,
'leadSessionId': leadSessionId,
'members': [
{'agentId': 'team-lead@$team', 'name': 'team-lead', 'agentType': 'team-lead', 'tmuxPaneId': '', 'cwd': cwd, 'joinedAt': 1},
...teammates,
],
});
}
Map<String, dynamic> _member(String name, String pane, {String? type, int joinedAt = 2, String cwd = _ws}) => {
'agentId': '$name@myteam',
'name': name,
'agentType': type ?? name,
'tmuxPaneId': pane,
'model': 'sonnet',
'color': 'blue',
'cwd': cwd,
'joinedAt': joinedAt,
};
Future<Directory> _writeTeam(Directory teamsBase, String team, String json) async {
final dir = Directory('${teamsBase.path}/$team');
await dir.create(recursive: true);
await File('${dir.path}/config.json').writeAsString(json);
return dir;
}
void main() {
group('team events', () {
test('TeamMemberJoined payload carries identity + optional fields', () {
const e = TeamMemberJoined(
team: 'myteam',
agentId: 'alice@myteam',
name: 'alice',
agentType: 'researcher',
paneId: '%5',
model: 'sonnet',
color: 'blue',
cwd: '/work/space',
transcriptPath: '/t/agent-a.jsonl',
);
expect(e.subsystem, 'team');
expect(e.kind, 'member-joined');
expect(e.payload(), {
'team': 'myteam',
'agentId': 'alice@myteam',
'name': 'alice',
'agentType': 'researcher',
'paneId': '%5',
'model': 'sonnet',
'color': 'blue',
'cwd': '/work/space',
'transcriptPath': '/t/agent-a.jsonl',
});
});
test('TeamMemberJoined omits null optional fields', () {
const e = TeamMemberJoined(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1');
expect(e.payload().keys, ['team', 'agentId', 'name', 'agentType', 'paneId']);
});
test('TeamMemberLeft payload', () {
const e = TeamMemberLeft(team: 't', agentId: 'a@t', paneId: '%1');
expect(e.subsystem, 'team');
expect(e.kind, 'member-left');
expect(e.payload(), {'team': 't', 'agentId': 'a@t', 'paneId': '%1'});
});
});
group('TeamConfig.parse', () {
test('parses members and detects the lead', () {
final cfg = TeamConfig.parse('myteam', _configJson(teammates: [_member('alice', '%5')]))!;
expect(cfg.team, 'myteam');
expect(cfg.leadSessionId, 'sid-1');
expect(cfg.members, hasLength(2));
expect(cfg.teammates.map((m) => m.name), ['alice']);
final lead = cfg.members.firstWhere((m) => m.isLead);
expect(lead.name, 'team-lead');
final alice = cfg.teammates.single;
expect(alice.tmuxPaneId, '%5');
expect(alice.model, 'sonnet');
expect(alice.isLead, isFalse);
});
test('returns null on malformed JSON', () {
expect(TeamConfig.parse('x', 'not json'), isNull);
});
});
group('discoverTeam', () {
late Directory teamsBase;
setUp(() async => teamsBase = await Directory.systemTemp.createTemp('teams_'));
tearDown(() async => teamsBase.delete(recursive: true));
test('finds the team whose member cwd matches the workspace', () async {
await _writeTeam(teamsBase, 'other', _configJson(team: 'other', cwd: '/elsewhere', teammates: [_member('bob', '%9', cwd: '/elsewhere')]));
await _writeTeam(teamsBase, 'mine', _configJson(team: 'mine', teammates: [_member('alice', '%5')]));
final cfg = await discoverTeam(_ws, teamsBase: teamsBase.path);
expect(cfg, isNotNull);
expect(cfg!.team, 'mine');
});
test('prefers the newest createdAt when several match', () async {
await _writeTeam(teamsBase, 'old', _configJson(team: 'old', createdAt: 100, teammates: [_member('a', '%1')]));
await _writeTeam(teamsBase, 'new', _configJson(team: 'new', createdAt: 999, teammates: [_member('b', '%2')]));
final cfg = await discoverTeam(_ws, teamsBase: teamsBase.path);
expect(cfg!.team, 'new');
});
test('returns null when nothing matches', () async {
await _writeTeam(teamsBase, 'other', _configJson(team: 'other', cwd: '/elsewhere', teammates: [_member('bob', '%9', cwd: '/elsewhere')]));
expect(await discoverTeam(_ws, teamsBase: teamsBase.path), isNull);
});
});
group('TeamObserver lifecycle', () {
late Directory teamsBase;
late Directory projectsBase;
late DaemonBus events;
late MessageBus messages;
late List<TeamMemberJoined> joined;
late List<TeamMemberLeft> left;
setUp(() async {
teamsBase = await Directory.systemTemp.createTemp('teams_');
projectsBase = await Directory.systemTemp.createTemp('projects_');
events = DaemonBus();
messages = MessageBus();
joined = [];
left = [];
events.on<TeamMemberJoined>().listen(joined.add);
events.on<TeamMemberLeft>().listen(left.add);
});
tearDown(() async {
await teamsBase.delete(recursive: true);
await projectsBase.delete(recursive: true);
await events.dispose();
messages.dispose();
});
Future<void> settle() => Future<void>.delayed(const Duration(milliseconds: 10));
test('emits joined when a teammate pane is live, left when it goes', () async {
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')]));
var panes = {'%5'};
final obs = TeamObserver(
workspacePath: _ws,
events: events,
messages: messages,
teamsBase: teamsBase.path,
projectsBase: projectsBase.path,
paneLister: () async => panes,
);
addTearDown(obs.dispose);
await obs.tick();
await settle();
expect(joined.map((b) => b.name), ['alice']);
expect(joined.single.paneId, '%5');
expect(joined.single.agentId, 'alice@myteam');
expect(left, isEmpty);
// Same pane still live -> no duplicate joined.
await obs.tick();
await settle();
expect(joined, hasLength(1));
// Pane gone -> left.
panes = {};
await obs.tick();
await settle();
expect(left.map((d) => d.agentId), ['alice@myteam']);
});
test('start() polls on a timer and dispose() stops it', () async {
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5')]));
final obs = TeamObserver(
workspacePath: _ws,
events: events,
messages: messages,
teamsBase: teamsBase.path,
projectsBase: projectsBase.path,
paneLister: () async => {'%5'},
pollInterval: const Duration(milliseconds: 20),
);
obs.start();
// Poll until the timer-driven tick emits joined (or time out).
final deadline = DateTime.now().add(const Duration(seconds: 2));
while (joined.isEmpty && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
expect(joined.map((b) => b.name), ['alice']);
await obs.dispose();
// dispose emits left for the tracked member.
await settle();
expect(left.map((d) => d.agentId), ['alice@myteam']);
});
test('constructs with default base dirs / pane lister', () {
// Exercises the default resolvers; not started, so nothing shells out.
final obs = TeamObserver(workspacePath: _ws, events: events, messages: messages);
expect(obs.workspacePath, _ws);
});
test('no team config -> no events', () async {
final obs = TeamObserver(
workspacePath: _ws,
events: events,
messages: messages,
teamsBase: teamsBase.path,
projectsBase: projectsBase.path,
paneLister: () async => {'%5'},
);
addTearDown(obs.dispose);
await obs.tick();
await settle();
expect(joined, isEmpty);
expect(left, isEmpty);
});
test('joins the teammate transcript via a matching .meta.json', () async {
await _writeTeam(teamsBase, 'myteam', _configJson(teammates: [_member('alice', '%5', type: 'researcher')]));
// <projectsBase>/<munged cwd>/<leadSessionId>/subagents/agent-*.jsonl
final sub = Directory('${projectsBase.path}/${_ws.replaceAll('/', '-')}/sid-1/subagents');
await sub.create(recursive: true);
await File('${sub.path}/agent-aaa111.jsonl').writeAsString('');
await File('${sub.path}/agent-aaa111.meta.json').writeAsString(jsonEncode({'agentType': 'researcher'}));
final obs = TeamObserver(
workspacePath: _ws,
events: events,
messages: messages,
teamsBase: teamsBase.path,
projectsBase: projectsBase.path,
paneLister: () async => {'%5'},
);
addTearDown(obs.dispose);
await obs.tick();
await settle();
expect(joined.single.transcriptPath, endsWith('agent-aaa111.jsonl'));
});
test('falls back to joinedAt<->mtime order when no .meta.json', () async {
await _writeTeam(
teamsBase,
'myteam',
_configJson(teammates: [
_member('first', '%5', joinedAt: 10),
_member('second', '%6', joinedAt: 20),
]),
);
final sub = Directory('${projectsBase.path}/${_ws.replaceAll('/', '-')}/sid-1/subagents');
await sub.create(recursive: true);
// Older file first (earlier mtime) -> maps to the earlier-joined member.
final older = File('${sub.path}/agent-older.jsonl');
await older.writeAsString('');
await older.setLastModified(DateTime(2026, 1, 1));
final newer = File('${sub.path}/agent-newer.jsonl');
await newer.writeAsString('');
await newer.setLastModified(DateTime(2026, 2, 1));
final obs = TeamObserver(
workspacePath: _ws,
events: events,
messages: messages,
teamsBase: teamsBase.path,
projectsBase: projectsBase.path,
paneLister: () async => {'%5', '%6'},
);
addTearDown(obs.dispose);
await obs.tick();
await settle();
final byName = {for (final b in joined) b.name: b.transcriptPath};
expect(byName['first'], endsWith('agent-older.jsonl'));
expect(byName['second'], endsWith('agent-newer.jsonl'));
});
});
}
@@ -1,8 +1,8 @@
/// Widget tests for the teammate tile grid (T-140): tiles appear on /// Widget tests for the teammate tile grid (T-140): tiles appear on
/// TeamMemberJoined, disappear on TeamMemberLeft, and the lead shows /// TeamMemberJoined, disappear on TeamMemberLeft, and the lead shows
/// alone when there's no team. Events are emitted directly into the /// alone when there's no team. Events are emitted directly into the
/// fixture's event bus (the observer is exercised separately in /// fixture's event bus (team membership is orchestrator-driven since
/// team_observer_test.dart). /// D-77 / T-167 — the tmux observer was retired).
library; library;
import 'package:clide/builtin/claude/src/conversation_view.dart'; import 'package:clide/builtin/claude/src/conversation_view.dart';
-137
View File
@@ -1,137 +0,0 @@
import 'dart:io';
import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux;
import 'package:test/test.dart';
class _RecordingRunner {
final List<List<String>> calls = [];
String stdout = '';
int exitCode = 0;
Future<ProcessResult> call(List<String> 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 -<digits>-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'],
]);
});
});
group('hasSession', () {
test('true on exit 0, false otherwise', () async {
runner.exitCode = 0;
expect(await tmux.hasSession('clide-claude-foo'), isTrue);
expect(runner.calls.last, ['-L', 'clide', 'has-session', '-t', 'clide-claude-foo']);
runner.exitCode = 1;
expect(await tmux.hasSession('clide-claude-foo'), isFalse);
});
});
group('sendMessage', () {
test('loads a bracketed paste buffer then submits with Enter', () async {
await tmux.sendMessage('clide-claude-foo', 'hello\nworld');
expect(runner.calls, [
['-L', 'clide', 'set-buffer', '-b', 'clide-compose', '--', 'hello\nworld'],
['-L', 'clide', 'paste-buffer', '-p', '-d', '-b', 'clide-compose', '-t', 'clide-claude-foo'],
['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', 'Enter'],
]);
});
});
group('sendCommand', () {
test('types the text literally (no bracketed paste) then submits Enter', () async {
await tmux.sendCommand('clide-claude-foo', '/whats-next');
expect(runner.calls, [
['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', '-l', '--', '/whats-next'],
['-L', 'clide', 'send-keys', '-t', 'clide-claude-foo', 'Enter'],
]);
});
});
}