From fc55f58e6b9ddab98e0288dabc7d6de23a02cd73 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 01:13:46 +0200 Subject: [PATCH] add tmux team observer + member lifecycle events (T-139, D-75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit team_observer.dart is the single drift-containment point for Claude Code's experimental tmux team mode. It discovers the active team for a workspace (~/.claude/teams//config.json, matched by member cwd), polls `tmux -L clide list-panes -a`, and correlates live panes with the config's tmuxPaneId to emit TeamMemberBorn / TeamMemberDied — identity (name, agentType, model, colour, pane) comes from the config, so it's reliable regardless of transcript drift. Each teammate's subagent transcript is resolved best-effort and streamed on a per-agent MessageBus channel via TranscriptPublisher (TranscriptReader gains an explicit `file:` for this). The config<->transcript join is the fragile part: no shared key, so it uses a sibling .meta.json agentType when present, else zips members-by-joinedAt against files-by-mtime. This join needs validation against a live team run. App wiring + visible surfacing land with the teammate tiles (T-140). Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 2 + .pql/changelog/tickets/2026-05.sql | 2 + lib/builtin/claude/src/team_observer.dart | 316 ++++++++++++++++++ lib/builtin/claude/src/transcript_reader.dart | 16 +- lib/kernel/src/events/types.dart | 71 ++++ test/builtin/claude/team_observer_test.dart | 308 +++++++++++++++++ .../claude/transcript_reader_test.dart | 25 ++ 7 files changed, 735 insertions(+), 5 deletions(-) create mode 100644 lib/builtin/claude/src/team_observer.dart create mode 100644 test/builtin/claude/team_observer_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index d3f0d5fa..ed624f8f 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2148,3 +2148,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-143', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:31:25', '2026-05-22 22:31:25', '2026-05-22 22:31:25', NULL, 'e2a16eb8af0afc30a5d0fc88c523e507', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-143', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:36:09', '2026-05-22 22:36:09', '2026-05-22 22:36:09', NULL, '9b8859ea2e8db7083bfb9d2ab17c1c57', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:41:28', '2026-05-22 22:41:28', '2026-05-22 22:41:28', NULL, 'eda6f75e88401ee5b445e7edfc312f82', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'status', 'in_progress', 'done', NULL, '2026-05-22 22:43:45', '2026-05-22 22:43:45', '2026-05-22 22:43:45', NULL, 'da17b2710e17b1821882d47724790cbb', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:50:41', '2026-05-22 22:50:41', '2026-05-22 22:50:41', NULL, 'ad063f0c3c65e5b35d3dcea036412ad9', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 6ea891ed..b0f3f118 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2493,3 +2493,5 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-143', 'task', 'T-132', 'Distinguish user message cards in the conversation', 'User messages render flat (label + text on the canvas) like Claude''s responses, so it''s hard to scan what you said vs what Claude said. Give UserMessage a distinct card: a filled background (globalBackground vs the panelBackground canvas), a left accent stripe (globalFocus), padding and rounded corners. Claude''s text responses stay flat markdown (better for long answers). conversation_view.dart _ConversationTurn.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-22 22:31:22', '2026-05-22 22:31:25', NULL, 'efa90d878f79235f81d54d00c04014c8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-143', 'task', 'T-132', 'Distinguish user message cards in the conversation', 'User messages render flat (label + text on the canvas) like Claude''s responses, so it''s hard to scan what you said vs what Claude said. Give UserMessage a distinct card: a filled background (globalBackground vs the panelBackground canvas), a left accent stripe (globalFocus), padding and rounded corners. Claude''s text responses stay flat markdown (better for long answers). conversation_view.dart _ConversationTurn.', 'done', 'low', NULL, NULL, NULL, '2026-05-22 22:31:22', '2026-05-22 22:36:09', NULL, '11ebd7f43b4371b15acb0729010cdec9', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:41:28', NULL, '4c0eebba4edb4394fb47ba2d81ef42ad', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-144', 'task', 'T-132', 'Claude message card with brand-orange accent', 'Extend the distinct-card treatment (T-143) to Claude''s text responses with a different accent: Claude''s brand coral-orange (#D97757), vs the user card''s focus-colour stripe. Refactor _userCard into a shared _messageCard(label, accent, body). conversation_view.dart.', 'done', 'low', NULL, NULL, NULL, '2026-05-22 22:40:42', '2026-05-22 22:43:45', NULL, '1ce891624631502279b0356c5cdffae2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-139', 'task', 'T-132', 'tmux team observer (lifecycle + identity + per-teammate transcripts)', 'New lib/builtin/claude/src/team_observer.dart: detect teammate spawn/exit (control-mode or list-panes -a poll on -L clide), map pane->teammate via ~/.claude/teams config + agentId, spin a TranscriptReader per teammate, emit new TeamMemberBorn/TeamMemberDied ClideEvents (lib/kernel/src/events/types.dart). ALL fragile CC-internals parsing isolated here (single drift-containment point; key off the version field). Cite D-75. Acceptance: a real team run emits born/died with correct identity; per-teammate content streams. Blocked by T2, T4.', 'in_progress', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 22:50:41', NULL, 'a5f4b20a0a37332ab35de8fa07e22298', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/builtin/claude/src/team_observer.dart b/lib/builtin/claude/src/team_observer.dart new file mode 100644 index 00000000..65c21d06 --- /dev/null +++ b/lib/builtin/claude/src/team_observer.dart @@ -0,0 +1,316 @@ +/// 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 +/// born/died 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); + final TeamMember member; + final String team; + final TranscriptPublisher? publisher; +} + +/// Watches a workspace's tmux team and emits [TeamMemberBorn] / +/// [TeamMemberDied] 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 born/died. + 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 _born(config, m); + } else if (!paneLive && tracked) { + await _died(m.agentId); + } + } + + // A member dropped from the config (team reshaped) also counts as died. + for (final id in _live.keys.toList()) { + if (!configIds.contains(id)) await _died(id); + } + } + + Future _born(TeamConfig config, TeamMember m) async { + final path = await _resolveTranscript(config, m); + TranscriptPublisher? pub; + if (path != null) { + pub = TranscriptPublisher( + messages: _messages, + reader: TranscriptReader(m.cwd ?? workspacePath, file: path, projectsBase: _projectsBase), + channel: ClaudeConversation.teammateChannel(m.agentId), + ); + } + _live[m.agentId] = _LiveMember(m, config.team, pub); + _events.emit(TeamMemberBorn( + 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 _died(String agentId) async { + final live = _live.remove(agentId); + if (live == null) return; + await live.publisher?.dispose(); + _events.emit(TeamMemberDied(team: live.team, agentId: agentId, paneId: live.member.tmuxPaneId)); + } + + Future _killAll() async { + for (final id in _live.keys.toList()) { + await _died(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/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart index f97093b5..01c8b0a2 100644 --- a/lib/builtin/claude/src/transcript_reader.dart +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -187,10 +187,12 @@ class TranscriptReader { void Function(String)? onWarn, String? projectsBase, int? initialTailBytes, + String? file, }) : _pollInterval = pollInterval, _onWarn = onWarn ?? _defaultWarn, _projectsBase = projectsBase ?? _defaultProjectsBase(), - _initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes; + _initialTailBytes = initialTailBytes ?? _defaultInitialTailBytes, + _explicitFile = file; final String workspacePath; final Duration _pollInterval; @@ -205,6 +207,11 @@ class TranscriptReader { /// temp directory instead of the user's home. final String _projectsBase; + /// When set, tail this exact file instead of discovering the newest + /// `.jsonl` in the munged dir. Used for teammate subagent transcripts + /// (T-139), whose path the team observer resolves explicitly. + final String? _explicitFile; + static String _defaultProjectsBase() { final home = Platform.environment['HOME'] ?? ''; return home.isNotEmpty ? '$home/.claude/projects' : '.claude/projects'; @@ -287,10 +294,9 @@ class TranscriptReader { } Future _tick(StreamController controller) async { - final dir = _mungedDir(); - - // Discover or refresh the active session file. - final newest = await _newestJsonl(dir); + // A teammate reader tails one fixed file; otherwise discover the + // newest session `.jsonl` in the munged dir. + final newest = _explicitFile ?? await _newestJsonl(_mungedDir()); if (newest == null) return; if (newest != _currentPath) { diff --git a/lib/kernel/src/events/types.dart b/lib/kernel/src/events/types.dart index afe1ca02..1c87e254 100644 --- a/lib/kernel/src/events/types.dart +++ b/lib/kernel/src/events/types.dart @@ -106,3 +106,74 @@ class DaemonEvent extends ClideEvent { @override Map payload() => {'ts': ts.toIso8601String(), ...data}; } + +/// A Claude Code tmux teammate appeared (its pane is live). Identity is +/// taken from the team config, so it is reliable regardless of transcript +/// drift (T-139, D-75). [transcriptPath] is the best-effort resolved +/// subagent transcript, or null if it could not be joined yet. +class TeamMemberBorn extends ClideEvent { + const TeamMemberBorn({ + required this.team, + required this.agentId, + required this.name, + required this.agentType, + required this.paneId, + this.model, + this.color, + this.cwd, + this.transcriptPath, + }); + + /// Team name (the `~/.claude/teams/` directory). + final String team; + + /// Config agent id (`@`). + final String agentId; + final String name; + final String agentType; + + /// tmux pane id (`%N`). + final String paneId; + final String? model; + final String? color; + final String? cwd; + final String? transcriptPath; + + @override + String get subsystem => 'team'; + @override + String get kind => 'member-born'; + @override + Map payload() => { + 'team': team, + 'agentId': agentId, + 'name': name, + 'agentType': agentType, + 'paneId': paneId, + if (model != null) 'model': model, + if (color != null) 'color': color, + if (cwd != null) 'cwd': cwd, + if (transcriptPath != null) 'transcriptPath': transcriptPath, + }; +} + +/// A Claude Code tmux teammate's pane went away (it exited or the team +/// dissolved) — T-139. +class TeamMemberDied extends ClideEvent { + const TeamMemberDied({ + required this.team, + required this.agentId, + required this.paneId, + }); + + final String team; + final String agentId; + final String paneId; + + @override + String get subsystem => 'team'; + @override + String get kind => 'member-died'; + @override + Map payload() => {'team': team, 'agentId': agentId, 'paneId': paneId}; +} diff --git a/test/builtin/claude/team_observer_test.dart b/test/builtin/claude/team_observer_test.dart new file mode 100644 index 00000000..7410f676 --- /dev/null +++ b/test/builtin/claude/team_observer_test.dart @@ -0,0 +1,308 @@ +/// Tests for the tmux team observer (T-139). Pure Dart (no Flutter): +/// config parsing/discovery, the config-driven born/died 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('TeamMemberBorn payload carries identity + optional fields', () { + const e = TeamMemberBorn( + 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-born'); + 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('TeamMemberBorn omits null optional fields', () { + const e = TeamMemberBorn(team: 't', agentId: 'a@t', name: 'a', agentType: 'a', paneId: '%1'); + expect(e.payload().keys, ['team', 'agentId', 'name', 'agentType', 'paneId']); + }); + + test('TeamMemberDied payload', () { + const e = TeamMemberDied(team: 't', agentId: 'a@t', paneId: '%1'); + expect(e.subsystem, 'team'); + expect(e.kind, 'member-died'); + 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 born; + late List died; + + setUp(() async { + teamsBase = await Directory.systemTemp.createTemp('teams_'); + projectsBase = await Directory.systemTemp.createTemp('projects_'); + events = DaemonBus(); + messages = MessageBus(); + born = []; + died = []; + events.on().listen(born.add); + events.on().listen(died.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 born when a teammate pane is live, died 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(born.map((b) => b.name), ['alice']); + expect(born.single.paneId, '%5'); + expect(born.single.agentId, 'alice@myteam'); + expect(died, isEmpty); + + // Same pane still live -> no duplicate born. + await obs.tick(); + await settle(); + expect(born, hasLength(1)); + + // Pane gone -> died. + panes = {}; + await obs.tick(); + await settle(); + expect(died.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 born (or time out). + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (born.isEmpty && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 10)); + } + expect(born.map((b) => b.name), ['alice']); + await obs.dispose(); + // dispose emits died for the tracked member. + await settle(); + expect(died.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(born, isEmpty); + expect(died, 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(born.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 born) b.name: b.transcriptPath}; + expect(byName['first'], endsWith('agent-older.jsonl')); + expect(byName['second'], endsWith('agent-newer.jsonl')); + }); + }); +} diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart index abe5794a..32b6d4c7 100644 --- a/test/builtin/claude/transcript_reader_test.dart +++ b/test/builtin/claude/transcript_reader_test.dart @@ -718,6 +718,31 @@ void main() { 'reply number 199', ); }); + + test('explicit file: tails that exact file, ignoring newest-discovery', () async { + // A teammate reader (T-139) points at one fixed subagent file rather + // than the newest .jsonl in the munged dir. + final dir = await Directory.systemTemp.createTemp('explicit_file_'); + addTearDown(() => dir.delete(recursive: true)); + final target = File('${dir.path}/agent-abc.jsonl'); + writeLines(target, [assistantText('a1', 'from the explicit file')]); + + final reader = TranscriptReader( + '/unused/workspace', + projectsBase: '/nonexistent', + pollInterval: const Duration(milliseconds: 20), + file: target.path, + ); + final collected = []; + final sub = reader.stream.listen(collected.add); + + await pumpUntil(() => collected.isNotEmpty); + + await sub.cancel(); + await reader.dispose(); + + expect(collected.whereType().single.text, 'from the explicit file'); + }); }); }