add tmux team observer + member lifecycle events (T-139, D-75)
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 24s
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 24s
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/<team>/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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/<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
|
||||
/// born/died 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);
|
||||
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<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 born/died.
|
||||
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 _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<void> _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<void> _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<void> _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<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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> _tick(StreamController<ConversationItem> 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) {
|
||||
|
||||
@@ -106,3 +106,74 @@ class DaemonEvent extends ClideEvent {
|
||||
@override
|
||||
Map<String, Object?> 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/<team>` directory).
|
||||
final String team;
|
||||
|
||||
/// Config agent id (`<name>@<team>`).
|
||||
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<String, Object?> 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<String, Object?> payload() => {'team': team, 'agentId': agentId, 'paneId': paneId};
|
||||
}
|
||||
|
||||
@@ -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<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('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<TeamMemberBorn> born;
|
||||
late List<TeamMemberDied> died;
|
||||
|
||||
setUp(() async {
|
||||
teamsBase = await Directory.systemTemp.createTemp('teams_');
|
||||
projectsBase = await Directory.systemTemp.createTemp('projects_');
|
||||
events = DaemonBus();
|
||||
messages = MessageBus();
|
||||
born = [];
|
||||
died = [];
|
||||
events.on<TeamMemberBorn>().listen(born.add);
|
||||
events.on<TeamMemberDied>().listen(died.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 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<void>.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')]));
|
||||
// <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(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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 = <ConversationItem>[];
|
||||
final sub = reader.stream.listen(collected.add);
|
||||
|
||||
await pumpUntil(() => collected.isNotEmpty);
|
||||
|
||||
await sub.cancel();
|
||||
await reader.dispose();
|
||||
|
||||
expect(collected.whereType<AssistantTextMessage>().single.text, 'from the explicit file');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user