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:
@@ -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 /
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user