Files
clide/lib/builtin/claude/src/team_broker.dart
T
jpmschweitzerandClaude a0636c2e5a add the team chat inbox over the broker
Renders broker traffic as a chat timeline and makes the user a first-class
participant. The broker grows a Stream<TeamMessage> and a recipient field,
auto-registers a virtual `user` member, and gains sendAsUser. A Flutter-free
TeamChatModel (owned by the orchestrator) accumulates the feed and exposes
postAsUser with @-routing (a new at_commands helper mirroring slash) and an
optional interrupt that cancels the target's turn before delivery. One model
backs two surfaces: a compact cockpit widget that pops out into a full
workspace chat pane. CLI parity via clide.team-chat.open / .post.

T-180.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-31 10:49:04 +02:00

448 lines
16 KiB
Dart

/// Team coordination broker hosted in clide and exposed to managed Claude
/// sessions as an in-process MCP server (T-170, D-77).
///
/// Claude's tmux team mode gave teammates ways to talk to each other and share
/// a task list. That mode is undocumented and unavailable headless, so clide
/// rebuilds the same behavior over its own managed sessions: clide is the
/// BROKER. Each agent gets an [McpServer] (`clide-team`) whose tools route
/// through this one shared [TeamBroker] — a `send_message` from agent A is
/// delivered into agent B's next turn on B's stdin, and the task list is shared
/// across everyone. clide owns routing and ordering.
///
/// Flutter-free on purpose: this and [TeamMcpServer] run under `dart test`, and
/// the transport ([McpServer], [StreamJsonSession]) is Flutter-free too.
library;
import 'dart:async';
import 'dart:convert';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
/// One agent in the team, keyed by its orchestrator session id.
class TeamMemberRef {
const TeamMemberRef({required this.id, required this.name, required this.role});
/// Orchestrator session id (the [McpServer] is scoped to this), e.g.
/// `primary` or `teammate:tyre`.
final String id;
/// Display name other members address it by (`send_message(to: …)`).
final String name;
/// `lead` / `teammate` / etc. — surfaced in `list_teammates`.
final String role;
}
/// A message left for a member, in arrival order.
class TeamMessage {
const TeamMessage({
required this.from,
required this.text,
required this.at,
this.to,
this.broadcast = false,
});
final String from;
/// Recipient name: a single member's display name (direct message), `null`
/// for a broadcast (every member), or the special value `'user'` when the
/// broker surfaces the message to the chat model rather than a session.
final String? to;
final String text;
final DateTime at;
final bool broadcast;
Map<String, dynamic> toJson() => {
'from': from,
if (to != null) 'to': to,
'text': text,
'at': at.toIso8601String(),
if (broadcast) 'broadcast': true,
};
}
/// A shared task. Status is one of `open` / `claimed` / `done`.
class TeamTask {
TeamTask({required this.id, required this.title, this.status = 'open', this.owner});
final String id;
String title;
String status;
String? owner;
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'status': status,
if (owner != null) 'owner': owner,
};
}
/// Pushes [text] into the member identified by [toMemberId] as a user message
/// on its session stdin. Injected by the orchestrator; null in tests that only
/// assert the broker's bookkeeping.
typedef MessageDelivery = void Function(String toMemberId, String text);
/// The single shared team state behind every member's `clide-team` MCP server.
/// All tool operations are scoped to the calling member's id.
///
/// Observability (T-171): subscribe to [changes] to be notified whenever the
/// task list or message state mutates. Flutter-free — the stream is a plain
/// broadcast [StreamController]; consumers must not assume it fires on the
/// Flutter event loop.
class TeamBroker {
TeamBroker({MessageDelivery? deliver}) : _deliver = deliver {
// The user is always a virtual team participant — agents can address them
// by name; messages routed to `user` surface in the chat model only (no
// stdin delivery). Registered at construction so the roster is consistent.
addMember(const TeamMemberRef(id: 'user', name: 'user', role: 'user'));
}
final MessageDelivery? _deliver;
final _members = <String, TeamMemberRef>{};
final _inboxes = <String, List<TeamMessage>>{};
final _tasks = <String, TeamTask>{};
final _muted = <String>{}; // member ids whose delivery is gated
int _taskSeq = 0;
// --- Observability ---------------------------------------------------------
final _changeCtl = StreamController<void>.broadcast();
final _messageCtl = StreamController<TeamMessage>.broadcast();
/// Fires a void event whenever the task list or message state mutates.
/// Broadcast — multiple listeners are supported. Flutter-free.
Stream<void> get changes => _changeCtl.stream;
/// Every inter-agent message (send_message / broadcast) in arrival order.
/// Also includes messages directed `to: 'user'` so the chat model can surface
/// them. Broadcast — multiple listeners are supported. Flutter-free.
Stream<TeamMessage> get messages => _messageCtl.stream;
void _notify() {
if (!_changeCtl.isClosed) _changeCtl.add(null);
}
// --- Public read surface ---------------------------------------------------
/// All tasks in creation order. Unmodifiable list; individual [TeamTask]
/// objects may be mutated but the list itself is stable.
List<TeamTask> get tasks => List.unmodifiable(_tasks.values);
/// Register a member. Idempotent on [TeamMemberRef.id].
void addMember(TeamMemberRef m) {
_members[m.id] = m;
_inboxes.putIfAbsent(m.id, () => <TeamMessage>[]);
}
/// Drop a member (its inbox is discarded; tasks it owned are released).
void removeMember(String id) {
final name = _members[id]?.name;
_members.remove(id);
_inboxes.remove(id);
_muted.remove(id);
if (name == null) return;
for (final t in _tasks.values) {
if (t.owner == name) {
t.owner = null;
if (t.status == 'claimed') t.status = 'open';
}
}
_notify();
}
// --- Mute / unmute --------------------------------------------------------
/// Whether delivery to [id] is currently muted. Muted members still
/// accumulate inbox messages but the [MessageDelivery] callback is
/// suppressed so the live session doesn't receive the text turn.
bool isMuted(String id) => _muted.contains(id);
/// Mute delivery to [id]. Messages still enqueue in the inbox; the agent
/// just won't receive them in its live stdin until [unmute] is called.
void mute(String id) {
_muted.add(id);
}
/// Re-enable delivery to [id].
void unmute(String id) {
_muted.remove(id);
}
// --- Task management (user-facing) ----------------------------------------
/// Reassign task [taskId] to the member identified by [toMemberId] (may be
/// a member id like `teammate:tyre`). Updates the owner display-name from
/// the member roster and fires [changes]. Returns false if [taskId] is
/// unknown.
bool reassignTask(String taskId, String toMemberId) {
final t = _tasks[taskId];
if (t == null) return false;
final name = _members[toMemberId]?.name ?? toMemberId;
t.owner = name;
if (t.status == 'open') t.status = 'claimed';
_notify();
return true;
}
/// Dispose — closes the [changes] and [messages] stream controllers.
void dispose() {
_changeCtl.close();
_messageCtl.close();
}
/// All members in registration order.
List<TeamMemberRef> get members => List.unmodifiable(_members.values);
TeamMemberRef? _byName(String name) {
final lower = name.toLowerCase();
for (final m in _members.values) {
if (m.name.toLowerCase() == lower) return m;
}
return null;
}
String _nameOf(String id) => _members[id]?.name ?? id;
// --- Tool operations (scoped to the caller [fromId]) ---------------------
/// Post [text] from the human user into the channel. When [to] is null or
/// omitted the message is broadcast; otherwise it is delivered only to the
/// named member. The `user` member is the caller's virtual id — it is
/// excluded from the recipient list in the same way senders are excluded
/// from their own broadcasts.
void sendAsUser(String text, {String? to}) {
if (to == null || to.isEmpty || to == 'team') {
// Broadcast: deliver to every non-user member.
// emitToStream=false: the chat model already recorded the local entry.
for (final m in _members.values) {
if (m.id == 'user') continue;
_enqueue(m.id, TeamMessage(from: 'user', to: null, text: text, at: DateTime.now(), broadcast: true), broadcast: true, emitToStream: false);
}
} else {
// Directed: deliver to the named member.
final target = _byName(to);
if (target == null || target.id == 'user') return;
_enqueue(target.id, TeamMessage(from: 'user', to: target.name, text: text, at: DateTime.now()), emitToStream: false);
}
}
/// Deliver [text] to the single member named [toName].
Map<String, dynamic> sendMessage(String fromId, String toName, String text) {
final target = _byName(toName);
if (target == null) {
return {'ok': false, 'error': 'No teammate named "$toName". Use list_teammates to see who is on the team.'};
}
_enqueue(target.id, TeamMessage(from: _nameOf(fromId), to: target.name, text: text, at: DateTime.now()));
return {'ok': true, 'to': target.name};
}
/// Deliver [text] to every member except the sender.
Map<String, dynamic> broadcast(String fromId, String text) {
final fromName = _nameOf(fromId);
final recipients = <String>[];
for (final m in _members.values) {
if (m.id == fromId) continue;
_enqueue(m.id, TeamMessage(from: fromName, to: null, text: text, at: DateTime.now(), broadcast: true), broadcast: true);
recipients.add(m.name);
}
return {'ok': true, 'recipients': recipients};
}
/// The team roster as seen by [fromId] (everyone else).
Map<String, dynamic> listTeammates(String fromId) {
final others = [
for (final m in _members.values)
if (m.id != fromId) {'name': m.name, 'role': m.role},
];
return {'teammates': others};
}
/// Return and clear the caller's pending messages.
Map<String, dynamic> inbox(String fromId) {
final box = _inboxes[fromId] ?? const <TeamMessage>[];
final out = [for (final m in box) m.toJson()];
_inboxes[fromId]?.clear();
return {'messages': out};
}
/// Claim an existing task by [id], or create one from [title] already claimed
/// by the caller. Returns the task.
Map<String, dynamic> claimTask(String fromId, {String? id, String? title}) {
final owner = _nameOf(fromId);
if (id != null && id.isNotEmpty) {
final t = _tasks[id];
if (t == null) return {'ok': false, 'error': 'No task "$id".'};
t.owner = owner;
t.status = 'claimed';
_notify();
return {'ok': true, 'task': t.toJson()};
}
if (title != null && title.trim().isNotEmpty) {
final t = TeamTask(id: 'task-${++_taskSeq}', title: title.trim(), status: 'claimed', owner: owner);
_tasks[t.id] = t;
_notify();
return {'ok': true, 'task': t.toJson()};
}
return {'ok': false, 'error': 'Pass a task id to claim, or a title to create one.'};
}
/// Update a task's [status] (and implicitly own it), or — with no [id] — list
/// every task. Creating a new open task is done with [title].
Map<String, dynamic> taskStatus(String fromId, {String? id, String? status, String? title}) {
if (title != null && title.trim().isNotEmpty && (id == null || id.isEmpty)) {
final t = TeamTask(id: 'task-${++_taskSeq}', title: title.trim());
_tasks[t.id] = t;
_notify();
return {'ok': true, 'task': t.toJson()};
}
if (id != null && id.isNotEmpty && status != null && status.isNotEmpty) {
final t = _tasks[id];
if (t == null) return {'ok': false, 'error': 'No task "$id".'};
t.status = status;
if (status == 'claimed' || status == 'done') t.owner = _nameOf(fromId);
_notify();
return {'ok': true, 'task': t.toJson()};
}
return {
'tasks': [for (final t in _tasks.values) t.toJson()]
};
}
void _enqueue(String toId, TeamMessage msg, {bool broadcast = false, bool emitToStream = true}) {
(_inboxes[toId] ??= <TeamMessage>[]).add(msg);
// Emit every message to the chat model stream before (possibly) delivering
// to the session stdin. The `user` member is a virtual participant — it has
// no session stdin, so delivery is skipped for it.
//
// [emitToStream] is false for user-originated messages that [TeamChatModel]
// already recorded locally — avoids double-adding them to the timeline.
if (emitToStream && !_messageCtl.isClosed) _messageCtl.add(msg);
if (toId == 'user') {
// User member: surfaced in the chat model only, no stdin delivery.
_notify();
return;
}
final tag = broadcast ? '${msg.from} (broadcast)' : msg.from;
// Gate delivery: muted members still accumulate inbox messages but the
// live session callback is suppressed until unmuted (T-171).
if (!_muted.contains(toId)) {
_deliver?.call(toId, '[team] $tag: ${msg.text}');
}
_notify();
}
}
/// The per-session `clide-team` MCP server: a thin adapter that forwards each
/// tool call to the shared [broker], scoped to this session's [memberId].
class TeamMcpServer implements McpServer {
TeamMcpServer({required this.broker, required this.memberId, this.name = 'clide-team', this.version = '0.1.0'});
final TeamBroker broker;
final String memberId;
@override
final String name;
@override
final String version;
@override
List<Map<String, dynamic>> get tools => _toolDefs;
@override
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments) async {
switch (name) {
case 'send_message':
return _result(broker.sendMessage(memberId, arguments['to'] as String? ?? '', arguments['text'] as String? ?? ''));
case 'broadcast':
return _result(broker.broadcast(memberId, arguments['text'] as String? ?? ''));
case 'list_teammates':
return _result(broker.listTeammates(memberId));
case 'inbox':
return _result(broker.inbox(memberId));
case 'claim_task':
return _result(broker.claimTask(memberId, id: arguments['id'] as String?, title: arguments['title'] as String?));
case 'task_status':
return _result(
broker.taskStatus(memberId, id: arguments['id'] as String?, status: arguments['status'] as String?, title: arguments['title'] as String?));
default:
return _error('Unknown team tool: $name');
}
}
Map<String, dynamic> _result(Map<String, dynamic> value) => {
'content': [
{'type': 'text', 'text': jsonEncode(value)},
],
'isError': value['ok'] == false,
};
Map<String, dynamic> _error(String message) => {
'content': [
{'type': 'text', 'text': message},
],
'isError': true,
};
}
const _toolDefs = <Map<String, dynamic>>[
{
'name': 'send_message',
'description': 'Send a direct message to one teammate by name. It is delivered into their next turn.',
'inputSchema': {
'type': 'object',
'properties': {
'to': {'type': 'string', 'description': 'Teammate name (see list_teammates).'},
'text': {'type': 'string', 'description': 'Message body.'},
},
'required': ['to', 'text'],
'additionalProperties': false,
},
},
{
'name': 'broadcast',
'description': 'Send a message to every other teammate at once.',
'inputSchema': {
'type': 'object',
'properties': {
'text': {'type': 'string', 'description': 'Message body.'},
},
'required': ['text'],
'additionalProperties': false,
},
},
{
'name': 'list_teammates',
'description': 'List the other members of the team with their roles.',
'inputSchema': {'type': 'object', 'properties': <String, dynamic>{}, 'additionalProperties': false},
},
{
'name': 'inbox',
'description': 'Read and clear your pending team messages.',
'inputSchema': {'type': 'object', 'properties': <String, dynamic>{}, 'additionalProperties': false},
},
{
'name': 'claim_task',
'description': 'Claim a shared task by id, or create a new task (by title) already claimed by you.',
'inputSchema': {
'type': 'object',
'properties': {
'id': {'type': 'string', 'description': 'Existing task id to claim.'},
'title': {'type': 'string', 'description': 'Title for a new task to create and claim.'},
},
'additionalProperties': false,
},
},
{
'name': 'task_status',
'description': 'List all shared tasks, update a task status (open/claimed/done) by id, or create a new open task by title.',
'inputSchema': {
'type': 'object',
'properties': {
'id': {'type': 'string', 'description': 'Task id to update.'},
'status': {'type': 'string', 'description': 'New status: open, claimed, or done.'},
'title': {'type': 'string', 'description': 'Title for a new open task.'},
},
'additionalProperties': false,
},
},
];