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:
@@ -2241,3 +2241,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-183', 'description', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Each category is an EXPANDABLE accordion section (no ellipsis truncation): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS, MCP SERVERS, SETTINGS, each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what that skill/agent/command says. Non-file entries render inline: permissions (allow/ask/deny, colour-coded), MCP servers, model, output style; SETTINGS can open settings.json. Show local+global layering (local wins) and that it is watched/live. Acceptance: sections expand/collapse to the full list; clicking a file-backed item opens its .md in the reader; permissions colour-coded by rule kind; updates live on a watched .claude change; widget tests for expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', NULL, '2026-05-25 09:35:47', '2026-05-25 09:35:47', '2026-05-25 09:35:47', NULL, '33086bf1d766da9bb8e12ddeeaa8ed92', 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-182', 'description', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view. Default to Activity. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; existing stats/team content slots into Activity/Team unchanged; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', NULL, '2026-05-25 09:38:15', '2026-05-25 09:38:15', '2026-05-25 09:38:15', NULL, 'a41f10a11412d0f7ee0cd5e0e7700488', 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-169', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 10:49:30', '2026-05-25 10:49:30', '2026-05-25 10:49:30', NULL, '3269bcb75eb13dffc13be53c9f71ca73', 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-169', 'status', 'in_progress', 'done', NULL, '2026-05-25 12:12:29', '2026-05-25 12:12:29', '2026-05-25 12:12:29', NULL, 'c93755e2b277ae1b120a58fe3ea59246', 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-170', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 12:12:33', '2026-05-25 12:12:33', '2026-05-25 12:12:33', NULL, '494120eadfe8d6ba67c065fa63180cda', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2759,3 +2759,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-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-25 09:35:47', NULL, 'a4962b8a69cffe93b72275d098251e1c', 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-182', 'task', 'T-164', 'Claude sidebar sub-tabs: Activity / Team / Config', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:26:31', '2026-05-25 09:38:15', NULL, 'de0ab01119b20a1ef66ecba1821cdcd4', 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-169', 'task', 'T-164', 'P2: session orchestrator — one managed-session primitive (spawn/show/hide)', 'Generalize the Claude pane into a clide-managed SESSION primitive: clide spawns/owns each stream-json Claude process; a pane is just a view on a session; teammate / secondary tab / forked branch / inline subagent are all the same thing. Provide spawn / show / hide / close over the set of live sessions; visibility is a UI toggle, not a lifecycle change (sessions stay live when hidden). Re-point the T-139 TeamObserver from observing a Claude tmux team to ORCHESTRATING clide-managed sessions; the T-140 tiles render whatever sessions are shown. Acceptance: multiple managed sessions run concurrently; each can be shown/hidden as a pane from a roster without tearing down its process; tests for the orchestrator lifecycle. Blocked by T-165 (phase 1 transport).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:49', '2026-05-25 10:49:30', NULL, '698c3527c5cdfba6a19b51fee6cc3cbc', 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-169', 'task', 'T-164', 'P2: session orchestrator — one managed-session primitive (spawn/show/hide)', 'Generalize the Claude pane into a clide-managed SESSION primitive: clide spawns/owns each stream-json Claude process; a pane is just a view on a session; teammate / secondary tab / forked branch / inline subagent are all the same thing. Provide spawn / show / hide / close over the set of live sessions; visibility is a UI toggle, not a lifecycle change (sessions stay live when hidden). Re-point the T-139 TeamObserver from observing a Claude tmux team to ORCHESTRATING clide-managed sessions; the T-140 tiles render whatever sessions are shown. Acceptance: multiple managed sessions run concurrently; each can be shown/hidden as a pane from a roster without tearing down its process; tests for the orchestrator lifecycle. Blocked by T-165 (phase 1 transport).', 'done', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:49', '2026-05-25 12:12:29', NULL, '187a203302a7f0eb983acd61122a050a', 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-170', 'task', 'T-164', 'P2: clide-hosted MCP coordination broker + team-awareness injection', 'Give clide-managed sessions team behavior that Claude''s tmux mode provided (and that is undocumented + headless-unavailable). clide hosts a small MCP server (attached to each agent via --mcp-config) exposing team tools: send_message(to,text), broadcast(text), list_teammates(), inbox(), and a shared claim_task/task_status. clide is the BROKER — a tool call from agent A is delivered into agent B''s next turn on B''s stream-json stdin; clide owns routing/ordering. Inject team-awareness into each agent via --append-system-prompt (roster + role) and/or --agents. Acceptance: two managed agents exchange messages through the broker and see a shared task list; a lead can address one teammate by name; tests for the broker routing + the MCP tool surface. Blocked by T-169.', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:27:01', '2026-05-25 12:12:33', NULL, '8238e6266f9adc7e7e5e4e5d08813de1', 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);
|
||||
|
||||
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Team coordination broker (T-170, D-77) — clide hosts an in-process MCP
|
||||
server (`clide-team`) for managed sessions over the stream-json control
|
||||
channel, giving agents tools to message each other, broadcast, see the
|
||||
roster, read an inbox, and share a task list. Each agent's role and the
|
||||
roster are injected into its system prompt.
|
||||
- Interrupt a running Claude turn (D-78) — Escape in the composer (when no
|
||||
typeahead is open) or a Stop button shown while busy cancels the current
|
||||
turn over the stream-json control channel. The escape hatch from a
|
||||
|
||||
@@ -211,8 +211,50 @@ if it shifts we start from a researched menu, not a blank page.
|
||||
**Containment:** all protocol framing lives behind one module (per D-77), so swapping
|
||||
transports is a one-seam change. Re-capture fixtures per pinned version.
|
||||
|
||||
## 6. Hosting an in-process MCP server over the control channel (T-170 — VERIFIED)
|
||||
Verified live against 2.1.150 (2026-05-25): clide can host an MCP server whose tools
|
||||
claude calls, **entirely over the stream-json control channel** — no subprocess, no
|
||||
`--mcp-config` file, no socket. This is the cleanest fit for the single-process
|
||||
guardrail and is what the team broker (T-170) is built on.
|
||||
|
||||
**Registration — `initialize` handshake only (no `--mcp-config` needed).** List the
|
||||
server name(s) in the `initialize` control_request's `sdkMcpServers`:
|
||||
```json
|
||||
// → {"type":"control_request","request_id":"init-1","request":{
|
||||
// "subtype":"initialize","hooks":{},"sdkMcpServers":["clide-team"]}}
|
||||
```
|
||||
A run with **no `--mcp-config` flag at all** but this handshake worked end-to-end — the
|
||||
flag is not required for SDK (in-process) servers. The server name surfaces tools to the
|
||||
model as `mcp__<server>__<tool>` (e.g. `mcp__clide-team__ping`).
|
||||
|
||||
**Handshake claude then drives (inbound `mcp_message` control_requests).** For each,
|
||||
the message is a JSON-RPC object; reply with a `control_response` carrying the JSON-RPC
|
||||
result under **`response.response.mcp_response`**:
|
||||
```json
|
||||
// ← {"type":"control_request","request_id":"<rid>","request":{
|
||||
// "subtype":"mcp_message","server_name":"clide-team",
|
||||
// "message":{"method":"initialize","params":{"protocolVersion":"2025-11-25",…},"jsonrpc":"2.0","id":0}}}
|
||||
// → {"type":"control_response","response":{"subtype":"success","request_id":"<rid>",
|
||||
// "response":{"mcp_response":{"jsonrpc":"2.0","id":0,"result":{
|
||||
// "protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":false}},
|
||||
// "serverInfo":{"name":"clide-team","version":"0.0.1"}}}}}}
|
||||
```
|
||||
Sequence observed: `initialize` → `notifications/initialized` (no `id`; still answer it)
|
||||
→ `tools/list` → (model calls a tool) → `tools/call`. The `tools/call` message:
|
||||
`{"method":"tools/call","params":{"name":"ping","arguments":{…},"_meta":{"claudecode/toolUseId":…,"progressToken":…}},"jsonrpc":"2.0","id":2}`;
|
||||
answer with `mcp_response.result = {"content":[{"type":"text","text":…}],"isError":false}`.
|
||||
|
||||
**SDK MCP tool calls ARE permission-gated.** Before the `tools/call`, claude sends a
|
||||
normal `can_use_tool` for `mcp__clide-team__ping` (with `permission_suggestions` →
|
||||
`addRules`). So the broker's tools flow through the same allow/deny path as any tool —
|
||||
no special-casing needed; the existing `can_use_tool` handler covers them.
|
||||
|
||||
Provenance: two live capture runs (`init-strings`, no `--mcp-config`; and `mcpconfig`)
|
||||
both completed the full round-trip returning `pong-from-clide`. The raw logs aren't
|
||||
committed (the `initialize` response embeds account email/org); the contract is instead
|
||||
pinned in the transport tests (`MCP server hosting (T-170)`).
|
||||
|
||||
## Not done / open
|
||||
- `hook_callback` and `mcp_message` round-trips not exercised live (shapes from the
|
||||
binary only) — needed for the clide-hosted MCP broker (T-170).
|
||||
- `hook_callback` round-trip not exercised live (shape from the binary only).
|
||||
- Image/file paste intake over stream-json `content` blocks not tested here.
|
||||
- Remaining-usage budget % still not exposed (only `contextWindow` size, in `result`).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -6,11 +6,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _FakeProc implements StreamJsonProcess {
|
||||
final _ctl = StreamController<String>.broadcast();
|
||||
final List<String> writes = [];
|
||||
bool killed = false;
|
||||
@override
|
||||
Stream<String> get lines => _ctl.stream;
|
||||
@override
|
||||
void writeLine(String line) {}
|
||||
void writeLine(String line) => writes.add(line);
|
||||
@override
|
||||
Future<void> kill() async => killed = true;
|
||||
}
|
||||
@@ -89,4 +90,36 @@ void main() {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(created.every((p) => p.killed), isTrue);
|
||||
});
|
||||
|
||||
group('team broker wiring (T-170)', () {
|
||||
SpawnSpec teamSpec(String id, String name, String role) => SpawnSpec(id: id, role: role, sessionId: '$id-uuid', cwd: '/repo', team: true, memberName: name);
|
||||
|
||||
test('team sessions register in the broker; solo sessions do not', () async {
|
||||
await orch.spawn(spec('solo'));
|
||||
expect(orch.broker.members, isEmpty);
|
||||
await orch.spawn(teamSpec('primary', 'lead', 'lead'));
|
||||
expect(orch.broker.members.map((m) => m.name), ['lead']);
|
||||
});
|
||||
|
||||
test('a message between team members is delivered into the target session stdin', () async {
|
||||
await orch.spawn(teamSpec('primary', 'lead', 'lead'));
|
||||
await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate'));
|
||||
orch.broker.sendMessage('primary', 'tyre', 'pick up T-9');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final tyreProc = created[1];
|
||||
expect(tyreProc.writes.any((w) => w.contains('[team] lead: pick up T-9')), isTrue);
|
||||
});
|
||||
|
||||
test('a team session declares the clide-team MCP server in its init handshake', () async {
|
||||
await orch.spawn(teamSpec('primary', 'lead', 'lead'));
|
||||
expect(created.single.writes.any((w) => w.contains('"sdkMcpServers":["clide-team"]')), isTrue);
|
||||
});
|
||||
|
||||
test('closing a team member removes it from the broker roster', () async {
|
||||
await orch.spawn(teamSpec('primary', 'lead', 'lead'));
|
||||
await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate'));
|
||||
await orch.close('teammate:tyre');
|
||||
expect(orch.broker.members.map((m) => m.name), ['lead']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,38 @@ class _FakeProc implements StreamJsonProcess {
|
||||
void emit(String line) => _ctl.add(line);
|
||||
}
|
||||
|
||||
class _FakeMcpServer implements McpServer {
|
||||
@override
|
||||
String get name => 'clide-team';
|
||||
@override
|
||||
String get version => '9.9.9';
|
||||
final List<String> calls = [];
|
||||
@override
|
||||
List<Map<String, dynamic>> get tools => [
|
||||
{
|
||||
'name': 'ping',
|
||||
'description': 'p',
|
||||
'inputSchema': {'type': 'object', 'properties': <String, dynamic>{}},
|
||||
},
|
||||
];
|
||||
@override
|
||||
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments) async {
|
||||
calls.add(name);
|
||||
return {
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'pong'},
|
||||
],
|
||||
'isError': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
String mcpMessage(String rid, Map<String, dynamic> message, {String server = 'clide-team'}) => jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': rid,
|
||||
'request': {'subtype': 'mcp_message', 'server_name': server, 'message': message},
|
||||
});
|
||||
|
||||
String assistantText(String text) => jsonEncode({
|
||||
'type': 'assistant',
|
||||
'uuid': 'a1',
|
||||
@@ -361,4 +393,72 @@ void main() {
|
||||
await session.dispose();
|
||||
expect(proc.killed, isTrue);
|
||||
});
|
||||
|
||||
group('MCP server hosting (T-170)', () {
|
||||
late _FakeProc mproc;
|
||||
late StreamJsonSession msession;
|
||||
late _FakeMcpServer server;
|
||||
|
||||
setUp(() {
|
||||
mproc = _FakeProc();
|
||||
server = _FakeMcpServer();
|
||||
msession = StreamJsonSession(mproc, mcpServers: [server]);
|
||||
msession.start();
|
||||
});
|
||||
|
||||
tearDown(() => msession.dispose());
|
||||
|
||||
Map<String, dynamic> mcpResponseOf(String write) {
|
||||
final resp = jsonDecode(write) as Map<String, dynamic>;
|
||||
return ((resp['response'] as Map)['response'] as Map)['mcp_response'] as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
test('declares its sdkMcpServers in the initialize handshake', () {
|
||||
final init = mproc.writes.map((w) => jsonDecode(w) as Map).firstWhere(
|
||||
(m) => (m['request'] as Map?)?['subtype'] == 'initialize',
|
||||
);
|
||||
expect((init['request'] as Map)['sdkMcpServers'], ['clide-team']);
|
||||
});
|
||||
|
||||
test('answers mcp initialize with our serverInfo', () async {
|
||||
mproc.emit(mcpMessage('m1', {
|
||||
'method': 'initialize',
|
||||
'params': {'protocolVersion': '2025-11-25'},
|
||||
'jsonrpc': '2.0',
|
||||
'id': 0,
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final r = mcpResponseOf(mproc.writes.last);
|
||||
expect((r['result'] as Map)['serverInfo'], {'name': 'clide-team', 'version': '9.9.9'});
|
||||
});
|
||||
|
||||
test('answers tools/list with the server tools', () async {
|
||||
mproc.emit(mcpMessage('m2', {'method': 'tools/list', 'jsonrpc': '2.0', 'id': 1}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final r = mcpResponseOf(mproc.writes.last);
|
||||
final tools = (r['result'] as Map)['tools'] as List;
|
||||
expect(tools.single['name'], 'ping');
|
||||
});
|
||||
|
||||
test('routes tools/call to the server and returns its result', () async {
|
||||
mproc.emit(mcpMessage('m3', {
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'ping', 'arguments': <String, dynamic>{}},
|
||||
'jsonrpc': '2.0',
|
||||
'id': 2,
|
||||
}));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(server.calls, ['ping']);
|
||||
final r = mcpResponseOf(mproc.writes.last);
|
||||
final content = (r['result'] as Map)['content'] as List;
|
||||
expect(content.single['text'], 'pong');
|
||||
});
|
||||
|
||||
test('an mcp_message for an unknown server is answered with an error', () async {
|
||||
mproc.emit(mcpMessage('m4', {'method': 'tools/list', 'jsonrpc': '2.0', 'id': 3}, server: 'nope'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final r = mcpResponseOf(mproc.writes.last);
|
||||
expect(r['error'], isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Decode a [TeamMcpServer] tool result's single text block back into the
|
||||
/// structured map the broker returned.
|
||||
Map<String, dynamic> decode(Map<String, dynamic> mcpResult) {
|
||||
final text = ((mcpResult['content'] as List).single as Map)['text'] as String;
|
||||
return jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late TeamBroker broker;
|
||||
late List<(String, String)> delivered; // (toMemberId, text)
|
||||
late TeamMcpServer lead;
|
||||
late TeamMcpServer tyre;
|
||||
|
||||
setUp(() {
|
||||
delivered = [];
|
||||
broker = TeamBroker(deliver: (to, text) => delivered.add((to, text)));
|
||||
broker.addMember(const TeamMemberRef(id: 'primary', name: 'lead', role: 'lead'));
|
||||
broker.addMember(const TeamMemberRef(id: 'teammate:tyre', name: 'tyre', role: 'teammate'));
|
||||
lead = TeamMcpServer(broker: broker, memberId: 'primary');
|
||||
tyre = TeamMcpServer(broker: broker, memberId: 'teammate:tyre');
|
||||
});
|
||||
|
||||
test('send_message delivers into the named teammate next turn', () async {
|
||||
final r = decode(await lead.callTool('send_message', {'to': 'tyre', 'text': 'pick up T-9'}));
|
||||
expect(r['ok'], isTrue);
|
||||
expect(r['to'], 'tyre');
|
||||
expect(delivered, [('teammate:tyre', '[team] lead: pick up T-9')]);
|
||||
});
|
||||
|
||||
test('addressing an unknown teammate fails with a helpful error', () async {
|
||||
final result = await lead.callTool('send_message', {'to': 'ghost', 'text': 'hi'});
|
||||
expect(result['isError'], isTrue);
|
||||
expect(decode(result)['ok'], isFalse);
|
||||
expect(delivered, isEmpty);
|
||||
});
|
||||
|
||||
test('the recipient can read the message from its inbox', () async {
|
||||
await lead.callTool('send_message', {'to': 'tyre', 'text': 'hello'});
|
||||
final box = decode(await tyre.callTool('inbox', {}));
|
||||
final msgs = box['messages'] as List;
|
||||
expect(msgs.single['from'], 'lead');
|
||||
expect(msgs.single['text'], 'hello');
|
||||
// Draining: a second read is empty.
|
||||
expect((decode(await tyre.callTool('inbox', {}))['messages'] as List), isEmpty);
|
||||
});
|
||||
|
||||
test('broadcast reaches every other member but not the sender', () async {
|
||||
broker.addMember(const TeamMemberRef(id: 'teammate:qatux', name: 'qatux', role: 'teammate'));
|
||||
final r = decode(await lead.callTool('broadcast', {'text': 'standup'}));
|
||||
expect((r['recipients'] as List).toSet(), {'tyre', 'qatux'});
|
||||
expect(delivered.map((d) => d.$1).toSet(), {'teammate:tyre', 'teammate:qatux'});
|
||||
});
|
||||
|
||||
test('list_teammates returns the other members with roles', () async {
|
||||
final r = decode(await lead.callTool('list_teammates', {}));
|
||||
final mates = r['teammates'] as List;
|
||||
expect(mates.single, {'name': 'tyre', 'role': 'teammate'});
|
||||
});
|
||||
|
||||
test('a claimed task is visible to every member as shared state', () async {
|
||||
final claimed = decode(await tyre.callTool('claim_task', {'title': 'wire the broker'}));
|
||||
final taskId = (claimed['task'] as Map)['id'] as String;
|
||||
expect((claimed['task'] as Map)['owner'], 'tyre');
|
||||
|
||||
final seenByLead = decode(await lead.callTool('task_status', {}));
|
||||
final tasks = seenByLead['tasks'] as List;
|
||||
expect(tasks.single['id'], taskId);
|
||||
expect(tasks.single['status'], 'claimed');
|
||||
|
||||
final done = decode(await lead.callTool('task_status', {'id': taskId, 'status': 'done'}));
|
||||
expect((done['task'] as Map)['status'], 'done');
|
||||
});
|
||||
|
||||
test('removing a member releases its claimed tasks', () async {
|
||||
final claimed = decode(await tyre.callTool('claim_task', {'title': 'temp'}));
|
||||
final taskId = (claimed['task'] as Map)['id'] as String;
|
||||
broker.removeMember('teammate:tyre');
|
||||
final tasks = decode(await lead.callTool('task_status', {}))['tasks'] as List;
|
||||
final t = tasks.firstWhere((t) => t['id'] == taskId);
|
||||
expect(t['status'], 'open');
|
||||
expect(t.containsKey('owner'), isFalse);
|
||||
});
|
||||
|
||||
test('claim_task by id claims an existing open task', () async {
|
||||
final created = decode(await lead.callTool('task_status', {'title': 'open work'}));
|
||||
final id = (created['task'] as Map)['id'] as String;
|
||||
expect((created['task'] as Map)['status'], 'open');
|
||||
|
||||
final claimed = decode(await tyre.callTool('claim_task', {'id': id}));
|
||||
expect((claimed['task'] as Map)['status'], 'claimed');
|
||||
expect((claimed['task'] as Map)['owner'], 'tyre');
|
||||
});
|
||||
|
||||
test('claim_task with neither id nor title is an error', () async {
|
||||
final r = await lead.callTool('claim_task', {});
|
||||
expect(r['isError'], isTrue);
|
||||
});
|
||||
|
||||
test('claiming an unknown task id is an error', () async {
|
||||
final r = await lead.callTool('claim_task', {'id': 'task-999'});
|
||||
expect(r['isError'], isTrue);
|
||||
});
|
||||
|
||||
test('task_status on an unknown id is an error', () async {
|
||||
final r = await lead.callTool('task_status', {'id': 'task-999', 'status': 'done'});
|
||||
expect(r['isError'], isTrue);
|
||||
});
|
||||
|
||||
test('an unknown team tool is an error', () async {
|
||||
final r = await lead.callTool('nope', {});
|
||||
expect(r['isError'], isTrue);
|
||||
});
|
||||
|
||||
test('removing an unknown member is a no-op', () {
|
||||
broker.removeMember('teammate:ghost');
|
||||
expect(broker.members.map((m) => m.name).toSet(), {'lead', 'tyre'});
|
||||
});
|
||||
|
||||
test('the MCP tool surface lists all six team tools', () {
|
||||
final names = lead.tools.map((t) => t['name']).toSet();
|
||||
expect(names, {'send_message', 'broadcast', 'list_teammates', 'inbox', 'claim_task', 'task_status'});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user