add a clide-hosted team coordination broker over the MCP control channel

Claude's tmux team mode let teammates message each other and share a task
list; that mode is undocumented and unavailable headless. clide rebuilds
the same behavior over its own managed sessions, as the broker.

Verified live against claude 2.1.150 that a spawner can host an in-process
("SDK") MCP server entirely over the stream-json control channel — no
subprocess, no --mcp-config, no socket: declare the server name in the
initialize handshake's sdkMcpServers, answer the mcp_message JSON-RPC
round-trips (initialize / tools/list / tools/call) under
response.response.mcp_response. SDK tool calls are permission-gated through
the existing can_use_tool path. Documented in the 2.1.150 spike §6.

StreamJsonSession gains an McpServer hosting seam; TeamBroker + TeamMcpServer
expose send_message / broadcast / list_teammates / inbox / claim_task /
task_status, all routed through one shared broker. The orchestrator owns the
broker, registers each team session, delivers a message into the target's
next turn on its stdin, and injects roster + role via --append-system-prompt.
Solo sessions are unchanged (no MCP server, no initialize handshake).

T-170, D-77.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:37:53 +02:00
co-authored by Claude Opus 4.7
parent 3e7b600815
commit 6c5c67f6dc
10 changed files with 805 additions and 6 deletions
@@ -16,6 +16,7 @@ import 'dart:async';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/team_broker.dart';
import 'package:flutter/foundation.dart';
/// Creates the subprocess for a session — production uses
@@ -37,6 +38,8 @@ class SpawnSpec {
this.resume = false,
this.env,
this.visible = true,
this.team = false,
this.memberName,
});
final String id;
@@ -48,6 +51,15 @@ class SpawnSpec {
final bool resume;
final Map<String, String>? env;
final bool visible;
/// Join the team broker: host the `clide-team` MCP server and inject
/// team-awareness into the system prompt (T-170). Solo sessions leave this
/// false and behave exactly as before.
final bool team;
/// Name teammates address this session by (`send_message(to: …)`); defaults
/// to [role] when omitted. Only meaningful when [team] is true.
final String? memberName;
}
/// One clide-managed session: the process wrapper plus the conversation it
@@ -85,6 +97,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
final ProcessFactory _factory;
final _sessions = <String, ManagedSession>{};
/// The shared team coordination state. Team sessions host an MCP server that
/// routes through this; a `send_message` is delivered into the target
/// session's next turn (T-170).
late final TeamBroker broker = TeamBroker(deliver: _deliverToSession);
void _deliverToSession(String toId, String text) => _sessions[toId]?.session.send(text);
static Future<StreamJsonProcess> _spawnClaude({required List<String> sessionArgs, required String cwd, Map<String, String>? env}) =>
ClaudeStreamJsonProcess.start(sessionArgs: sessionArgs, cwd: cwd, env: env);
@@ -105,12 +124,24 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
final existing = _sessions[spec.id];
if (existing != null) return existing;
// Team sessions host the clide-team MCP server and get a roster + role
// injected into their system prompt (T-170). Register the member before
// spawning so a peer that messages it immediately resolves.
final mcpServers = <McpServer>[];
var sessionArgs = claudeLaunchArgs(spec.sessionId, resume: spec.resume);
if (spec.team) {
final name = spec.memberName ?? spec.role;
broker.addMember(TeamMemberRef(id: spec.id, name: name, role: spec.role));
mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id));
sessionArgs = ['--append-system-prompt', _teamSystemPrompt(name, spec.role), ...sessionArgs];
}
final proc = await _factory(
sessionArgs: claudeLaunchArgs(spec.sessionId, resume: spec.resume),
sessionArgs: sessionArgs,
cwd: spec.cwd,
env: spec.env,
);
final session = StreamJsonSession(proc)..start();
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
final conversation = ConversationController(stream: session.items, onDispose: session.dispose);
final managed = ManagedSession(
id: spec.id,
@@ -142,10 +173,19 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
Future<void> close(String id) async {
final m = _sessions.remove(id);
if (m == null) return;
broker.removeMember(id);
m.conversation.dispose();
notifyListeners();
}
/// The team-awareness preamble injected via `--append-system-prompt` (T-170).
static String _teamSystemPrompt(String name, String role) => 'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
'Coordinate with teammates using the clide-team MCP tools: '
'send_message(to, text) to message one teammate by name, broadcast(text) to message all, '
'list_teammates() to see the roster, inbox() to read messages sent to you, and '
'claim_task/task_status for the shared task list. '
'Messages from teammates arrive in your conversation prefixed with "[team]".';
@override
void dispose() {
for (final m in _sessions.values) {
+133 -1
View File
@@ -78,6 +78,37 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
}
}
/// An in-process MCP server clide hosts for a session, entirely over the
/// stream-json control channel — no subprocess, no `--mcp-config`, no socket
/// (T-170, D-77).
///
/// Registration: the session lists this server's [name] in the `initialize`
/// control_request's `sdkMcpServers`. claude then drives the MCP JSON-RPC
/// handshake (`initialize` → `notifications/initialized` → `tools/list`) and
/// each `tools/call` as `mcp_message` control_requests, which the session
/// answers with the JSON-RPC result wrapped in `response.response.mcp_response`.
/// claude exposes the tools to the model as `mcp__<name>__<tool>` and gates each
/// call through the normal `can_use_tool` channel. Verified live against claude
/// 2.1.150 — see docs/spikes/cc-stream-json-control-protocol-2.1.150.md §6.
///
/// Implementations stay Flutter-free (this whole module runs under `dart test`).
abstract class McpServer {
/// Server name; claude addresses it as `server_name` and exposes its tools
/// as `mcp__<name>__<tool>`.
String get name;
/// Reported in the `initialize` result's `serverInfo.version`.
String get version;
/// Tool definitions returned for `tools/list` — each
/// `{name, description, inputSchema}`.
List<Map<String, dynamic>> get tools;
/// Run a `tools/call`. Returns an MCP result object
/// (`{content: [{type: 'text', text: ...}], isError: bool}`).
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
}
/// An interactive prompt Claude is blocked on, from the stream-json control
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
/// an `AskUserQuestion`. Pure data; the decision goes back via
@@ -159,9 +190,14 @@ final class DenyTool extends ToolDecision {
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
/// answers control-channel prompts, and sends user messages.
class StreamJsonSession {
StreamJsonSession(this._proc);
StreamJsonSession(this._proc, {List<McpServer> mcpServers = const []}) : _mcpServers = mcpServers;
final StreamJsonProcess _proc;
/// In-process MCP servers hosted for this session over the control channel
/// (T-170). Declared in the `initialize` handshake; their `mcp_message`
/// round-trips are answered by [_handleMcpMessage].
final List<McpServer> _mcpServers;
final _items = StreamController<ConversationItem>.broadcast();
final _statusCtl = StreamController<SessionStatus>.broadcast();
StreamSubscription<String>? _sub;
@@ -216,6 +252,20 @@ class StreamJsonSession {
/// Begin consuming the process's event stream.
void start() {
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
// Declaring our in-process MCP servers in the `initialize` handshake is what
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
// when we actually host a server, so a plain session is unchanged.
if (_mcpServers.isNotEmpty) {
_proc.writeLine(jsonEncode({
'type': 'control_request',
'request_id': 'init-${_localSeq++}',
'request': {
'subtype': 'initialize',
'hooks': <String, dynamic>{},
'sdkMcpServers': [for (final s in _mcpServers) s.name],
},
}));
}
}
void _onLine(String line) {
@@ -270,12 +320,94 @@ class StreamJsonSession {
_pendingCtl.add(pendingPrompt);
return; // awaits resolvePrompt
}
// An MCP JSON-RPC round-trip for one of our hosted servers (T-170).
if (request['subtype'] == 'mcp_message') {
unawaited(_handleMcpMessage(rid, request.cast<String, dynamic>()));
return;
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unsupported control request subtype: ${request['subtype']}'},
}));
}
/// Answer an `mcp_message` control_request: dispatch its JSON-RPC to the named
/// hosted [McpServer] and reply with the result under `response.mcp_response`
/// (T-170). Every request — including notifications — is answered, or claude's
/// turn stalls waiting on us.
Future<void> _handleMcpMessage(String rid, Map<String, dynamic> request) async {
final serverName = request['server_name'] as String?;
final message = (request['message'] as Map?)?.cast<String, dynamic>();
final server = _mcpServerNamed(serverName);
final Map<String, dynamic> mcpResponse;
if (server == null || message == null) {
mcpResponse = {
'jsonrpc': '2.0',
'id': message?['id'],
'error': {'code': -32601, 'message': 'Unknown MCP server: $serverName'},
};
} else {
mcpResponse = await _dispatchMcp(server, message);
}
_proc.writeLine(jsonEncode({
'type': 'control_response',
'response': {
'subtype': 'success',
'request_id': rid,
'response': {'mcp_response': mcpResponse}
},
}));
}
McpServer? _mcpServerNamed(String? name) {
for (final s in _mcpServers) {
if (s.name == name) return s;
}
return null;
}
/// Map one MCP JSON-RPC method to its response object. The framing (envelope,
/// protocol version, capabilities) lives here so a CC drift is a one-file fix.
Future<Map<String, dynamic>> _dispatchMcp(McpServer server, Map<String, dynamic> msg) async {
final method = msg['method'] as String?;
final id = msg['id'];
switch (method) {
case 'initialize':
final params = (msg['params'] as Map?)?.cast<String, dynamic>();
return {
'jsonrpc': '2.0',
'id': id,
'result': {
'protocolVersion': params?['protocolVersion'] ?? '2025-11-25',
'capabilities': {
'tools': {'listChanged': false},
},
'serverInfo': {'name': server.name, 'version': server.version},
},
};
case 'notifications/initialized':
return {'jsonrpc': '2.0', 'id': id ?? 0, 'result': <String, dynamic>{}};
case 'tools/list':
return {
'jsonrpc': '2.0',
'id': id,
'result': {'tools': server.tools},
};
case 'tools/call':
final params = (msg['params'] as Map?)?.cast<String, dynamic>() ?? const {};
final toolName = params['name'] as String? ?? '';
final args = (params['arguments'] as Map?)?.cast<String, dynamic>() ?? <String, dynamic>{};
final result = await server.callTool(toolName, args);
return {'jsonrpc': '2.0', 'id': id, 'result': result};
default:
return {
'jsonrpc': '2.0',
'id': id,
'error': {'code': -32601, 'message': 'Method not found: $method'},
};
}
}
/// Answer a [ToolPrompt] over the control channel, by its
/// [ToolPrompt.promptId]. No-op if unknown or already resolved. Advances the
/// queue so the next pending prompt (if any) surfaces.
+315
View File
@@ -0,0 +1,315 @@
/// 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: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.broadcast = false});
final String from;
final String text;
final DateTime at;
final bool broadcast;
Map<String, dynamic> toJson() => {
'from': from,
'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.
class TeamBroker {
TeamBroker({MessageDelivery? deliver}) : _deliver = deliver;
final MessageDelivery? _deliver;
final _members = <String, TeamMemberRef>{};
final _inboxes = <String, List<TeamMessage>>{};
final _tasks = <String, TeamTask>{};
int _taskSeq = 0;
/// 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);
if (name == null) return;
for (final t in _tasks.values) {
if (t.owner == name) {
t.owner = null;
if (t.status == 'claimed') t.status = 'open';
}
}
}
/// 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]) ---------------------
/// 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), 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, 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';
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;
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;
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);
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}) {
(_inboxes[toId] ??= <TeamMessage>[]).add(msg);
final tag = broadcast ? '${msg.from} (broadcast)' : msg.from;
_deliver?.call(toId, '[team] $tag: ${msg.text}');
}
}
/// 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,
},
},
];