From f6b88f503f3d92008cb7f0926aea830e853f8558 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 30 May 2026 13:05:41 +0200 Subject: [PATCH] retire tmux for Claude sessions Session lifecycle now runs entirely on the stream-json model: argv selection picks --resume for an existing transcript and --session-id 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 --- CHANGELOG.md | 11 + .../claude/src/claude_meta_sidebar.dart | 2 +- lib/builtin/claude/src/extension.dart | 94 +---- lib/builtin/claude/src/session_naming.dart | 51 ++- lib/builtin/claude/src/team_observer.dart | 329 ------------------ lib/builtin/claude/src/team_panel_host.dart | 7 +- lib/builtin/claude/src/tmux_session.dart | 92 ----- .../claude/session_lifecycle_test.dart | 275 +++++++++++++++ test/builtin/claude/session_naming_test.dart | 68 +--- test/builtin/claude/team_observer_test.dart | 308 ---------------- test/builtin/claude/team_panel_host_test.dart | 4 +- test/builtin/claude/tmux_session_test.dart | 137 -------- 12 files changed, 350 insertions(+), 1028 deletions(-) delete mode 100644 lib/builtin/claude/src/team_observer.dart delete mode 100644 lib/builtin/claude/src/tmux_session.dart create mode 100644 test/builtin/claude/session_lifecycle_test.dart delete mode 100644 test/builtin/claude/team_observer_test.dart delete mode 100644 test/builtin/claude/tmux_session_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index decdabdb..f44271ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### 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 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 @@ -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 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 - Team coordination broker (T-170, D-77) — clide hosts an in-process MCP diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index 49ba9d1a..9f0ec3eb 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -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 / diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 47d3fa43..202d0e1c 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -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 _hostKey = GlobalKey(); - TeamObserver? _observer; ClaudeConfig? _config; ClaudeSessionOrchestrator? _orchestrator; final List> _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().listen((e) => _restartObserver(e.path))); - _subs.add(ctx.events.on().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 _killAllSessions(List 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 _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); - } } diff --git a/lib/builtin/claude/src/session_naming.dart b/lib/builtin/claude/src/session_naming.dart index 3116cd7e..b34f00d5 100644 --- a/lib/builtin/claude/src/session_naming.dart +++ b/lib/builtin/claude/src/session_naming.dart @@ -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-`. For example: -/// ~/projects/clide → clide-claude-projects-clide -/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp +/// Session continuity is via `--resume ` (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 `.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 `.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 `.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` diff --git a/lib/builtin/claude/src/team_observer.dart b/lib/builtin/claude/src/team_observer.dart deleted file mode 100644 index 12ebe352..00000000 --- a/lib/builtin/claude/src/team_observer.dart +++ /dev/null @@ -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//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 `//subagents/agent-.jsonl` -/// whose only ids are a random hex (the filename) and a `slug`; it carries -/// no `name`/`agentType`. The config's `agentId` is `@` — a -/// different namespace — so there is no shared key. We join via a sibling -/// `agent-.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, `@`. - 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//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 members; - final int createdAt; - - /// Non-lead members (the panes we surface). - List get teammates => members.where((m) => !m.isLead).toList(); - - /// Parse a config; returns null on malformed JSON. - static TeamConfig? parse(String teamDirName, String jsonStr) { - Map d; - try { - d = jsonDecode(jsonStr) as Map; - } catch (_) { - return null; - } - final members = []; - 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 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> 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? 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 _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> _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 tick() async { - final config = await discoverTeam(workspacePath, teamsBase: _teamsBase); - if (config == null) { - await _killAll(); - return; - } - final livePanes = await _paneLister(); - final configIds = {}; - - 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 _joined(TeamConfig config, TeamMember m) async { - final path = await _resolveTranscript(config, m); - TranscriptPublisher? pub; - StreamSubscription? 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 _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 _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 _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 = []; - 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 dispose() async { - _disposed = true; - _timer?.cancel(); - _timer = null; - await _killAll(); - } -} diff --git a/lib/builtin/claude/src/team_panel_host.dart b/lib/builtin/claude/src/team_panel_host.dart index 5a83fb7d..1db1f4dc 100644 --- a/lib/builtin/claude/src/team_panel_host.dart +++ b/lib/builtin/claude/src/team_panel_host.dart @@ -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'; diff --git a/lib/builtin/claude/src/tmux_session.dart b/lib/builtin/claude/src/tmux_session.dart deleted file mode 100644 index 0b983976..00000000 --- a/lib/builtin/claude/src/tmux_session.dart +++ /dev/null @@ -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 Function(List args); - -TmuxRunner tmuxRunner = _defaultRunner; - -Future _defaultRunner(List args) => Process.run('tmux', args); - -const _socket = ['-L', 'clide']; - -/// Kill the named tmux session on the clide socket. No-op if the -/// session does not exist (kill-session exits non-zero — we ignore it). -Future killSession(String name) async { - await tmuxRunner([..._socket, 'kill-session', '-t', name]); -} - -/// 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 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> listClideSessions() async { - final r = await tmuxRunner([..._socket, 'list-sessions', '-F', '#{session_name}']); - if (r.exitCode != 0) return const []; - return (r.stdout as String).split('\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); -} - -/// Kill every secondary clide-claude session whose name begins with -/// [primaryName] and ends with `-`. Leaves the primary itself -/// alive (D-41). -Future reapSecondaries(String primaryName) async { - final pattern = RegExp('^${RegExp.escape(primaryName)}-\\d+\$'); - for (final s in await listClideSessions()) { - if (pattern.hasMatch(s)) { - await killSession(s); - } - } -} - -/// Kill every clide-claude session for [primaryName], including the -/// primary itself. Used by the explicit `claude.kill-all-sessions` -/// command when the user wants a hard reset. -Future killAllForRepo(String primaryName) async { - for (final s in await listClideSessions()) { - if (s == primaryName || s.startsWith('$primaryName-')) { - await killSession(s); - } - } -} - -/// 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 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 sendCommand(String session, String text) async { - await tmuxRunner([..._socket, 'send-keys', '-t', session, '-l', '--', text]); - await tmuxRunner([..._socket, 'send-keys', '-t', session, 'Enter']); -} diff --git a/test/builtin/claude/session_lifecycle_test.dart b/test/builtin/claude/session_lifecycle_test.dart new file mode 100644 index 00000000..c0317a3d --- /dev/null +++ b/test/builtin/claude/session_lifecycle_test.dart @@ -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.broadcast(); + final List writes = []; + bool killed = false; + + @override + Stream get lines => _ctl.stream; + + @override + void writeLine(String line) => writes.add(line); + + @override + Future 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.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.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); + }); + }); +} diff --git a/test/builtin/claude/session_naming_test.dart b/test/builtin/claude/session_naming_test.dart index 23db350a..b95182a4 100644 --- a/test/builtin/claude/session_naming_test.dart +++ b/test/builtin/claude/session_naming_test.dart @@ -2,56 +2,10 @@ import 'package:clide/builtin/claude/src/session_naming.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('claude session naming', () { - test('primary name is deterministic per repo path', () { - final a = primarySessionName('/home/me/clide'); - final b = primarySessionName('/home/me/clide'); - 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)); - }); - }); + // The tmux-slug functions (primarySessionName / secondarySessionName) are + // retired as public API (D-77 / T-167). The UUID derivation is kept because + // `primarySessionId` still deterministically derives its UUID from the old + // slug (private) so existing transcripts survive the migration. 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}$'); @@ -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', () { // --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']); }); test('creates a new session with --session-id', () { 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); + }); }); } diff --git a/test/builtin/claude/team_observer_test.dart b/test/builtin/claude/team_observer_test.dart deleted file mode 100644 index 80c10b7e..00000000 --- a/test/builtin/claude/team_observer_test.dart +++ /dev/null @@ -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> 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 _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 _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 joined; - late List left; - - setUp(() async { - teamsBase = await Directory.systemTemp.createTemp('teams_'); - projectsBase = await Directory.systemTemp.createTemp('projects_'); - events = DaemonBus(); - messages = MessageBus(); - joined = []; - left = []; - events.on().listen(joined.add); - events.on().listen(left.add); - }); - - tearDown(() async { - await teamsBase.delete(recursive: true); - await projectsBase.delete(recursive: true); - await events.dispose(); - messages.dispose(); - }); - - Future settle() => Future.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.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')])); - // ///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')); - }); - }); -} diff --git a/test/builtin/claude/team_panel_host_test.dart b/test/builtin/claude/team_panel_host_test.dart index f31a82b9..fb52a735 100644 --- a/test/builtin/claude/team_panel_host_test.dart +++ b/test/builtin/claude/team_panel_host_test.dart @@ -1,8 +1,8 @@ /// Widget tests for the teammate tile grid (T-140): tiles appear on /// TeamMemberJoined, disappear on TeamMemberLeft, and the lead shows /// alone when there's no team. Events are emitted directly into the -/// fixture's event bus (the observer is exercised separately in -/// team_observer_test.dart). +/// fixture's event bus (team membership is orchestrator-driven since +/// D-77 / T-167 — the tmux observer was retired). library; import 'package:clide/builtin/claude/src/conversation_view.dart'; diff --git a/test/builtin/claude/tmux_session_test.dart b/test/builtin/claude/tmux_session_test.dart deleted file mode 100644 index 907ee94c..00000000 --- a/test/builtin/claude/tmux_session_test.dart +++ /dev/null @@ -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> calls = []; - String stdout = ''; - int exitCode = 0; - - Future call(List 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 --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'], - ]); - }); - }); -}