multiagents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1000 @@
1
+ #!/usr/bin/env bun
2
+ // ============================================================================
3
+ // multiagents — Orchestrator MCP Server
4
+ // ============================================================================
5
+ // MCP server for Claude Desktop that provides tools to manage a team of
6
+ // headless AI agents. Exposes create_team, status, broadcast, control, etc.
7
+ // ============================================================================
8
+
9
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import {
12
+ CallToolRequestSchema,
13
+ ListToolsRequestSchema,
14
+ } from "@modelcontextprotocol/sdk/types.js";
15
+ import type { Subprocess } from "bun";
16
+
17
+ import { BrokerClient } from "../shared/broker-client.ts";
18
+ import type { AgentLaunchConfig } from "../shared/types.ts";
19
+ import { log, getGitRoot, slugify } from "../shared/utils.ts";
20
+ import {
21
+ DEFAULT_BROKER_PORT,
22
+ BROKER_HOSTNAME,
23
+ GUARDRAIL_CHECK_INTERVAL,
24
+ CONFLICT_CHECK_INTERVAL,
25
+ } from "../shared/constants.ts";
26
+
27
+ import { detectAgent, launchAgent, announceNewMember, buildTeamContext } from "./launcher.ts";
28
+ import { monitorProcess, type AgentEvent } from "./monitor.ts";
29
+ import { getTeamStatus, formatTeamStatusForDisplay } from "./progress.ts";
30
+ import { checkGuardrails, enforceGuardrails } from "./guardrails.ts";
31
+ import { handleAgentCrash } from "./recovery.ts";
32
+ import { controlSession, broadcastToTeam, resolveTarget } from "./session-control.ts";
33
+
34
+ const LOG_PREFIX = "orchestrator";
35
+ const BROKER_URL = `http://${BROKER_HOSTNAME}:${DEFAULT_BROKER_PORT}`;
36
+
37
+ // Track active processes per session
38
+ const activeProcesses: Map<string, Map<number, Subprocess>> = new Map();
39
+ // Track pending events to push as notifications
40
+ const pendingEvents: AgentEvent[] = [];
41
+
42
+ // --- Dashboard auto-launch ---
43
+
44
+ const CLI_PATH = new URL("../cli.ts", import.meta.url).pathname;
45
+
46
+ /**
47
+ * Launch the TUI dashboard in a new terminal window.
48
+ * Tries macOS Terminal.app first via `open`, falls back to detached process.
49
+ */
50
+ function launchDashboard(sessionId: string, projectDir: string): void {
51
+ try {
52
+ const platform = process.platform;
53
+ if (platform === "darwin") {
54
+ // macOS: open a new Terminal.app tab running the dashboard
55
+ const script = `tell application "Terminal" to do script "cd ${projectDir} && bun ${CLI_PATH} dashboard ${sessionId}"`;
56
+ Bun.spawn(["osascript", "-e", script], {
57
+ stdio: ["ignore", "ignore", "ignore"],
58
+ }).unref();
59
+ } else if (platform === "linux") {
60
+ // Linux: try common terminal emulators
61
+ for (const term of ["gnome-terminal", "xterm", "konsole"]) {
62
+ const which = Bun.spawnSync(["which", term]);
63
+ if (which.exitCode === 0) {
64
+ Bun.spawn([term, "--", "bun", CLI_PATH, "dashboard", sessionId], {
65
+ cwd: projectDir,
66
+ stdio: ["ignore", "ignore", "ignore"],
67
+ }).unref();
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ log(LOG_PREFIX, `Dashboard launched for session ${sessionId}`);
73
+ } catch (e) {
74
+ log(LOG_PREFIX, `Dashboard auto-launch failed (non-critical): ${e}`);
75
+ }
76
+ }
77
+
78
+ // --- Broker lifecycle ---
79
+
80
+ async function ensureBroker(brokerClient: BrokerClient): Promise<void> {
81
+ if (await brokerClient.isAlive()) {
82
+ log(LOG_PREFIX, "Broker already running");
83
+ return;
84
+ }
85
+
86
+ log(LOG_PREFIX, "Starting broker daemon...");
87
+ const brokerScript = new URL("../broker.ts", import.meta.url).pathname;
88
+ const proc = Bun.spawn(["bun", brokerScript], {
89
+ stdio: ["ignore", "ignore", "inherit"],
90
+ });
91
+ proc.unref();
92
+
93
+ for (let i = 0; i < 30; i++) {
94
+ await new Promise((r) => setTimeout(r, 200));
95
+ if (await brokerClient.isAlive()) {
96
+ log(LOG_PREFIX, "Broker started");
97
+ return;
98
+ }
99
+ }
100
+ throw new Error("Failed to start broker daemon after 6 seconds");
101
+ }
102
+
103
+ // --- Event handler ---
104
+
105
+ function handleEvent(event: AgentEvent): void {
106
+ log(LOG_PREFIX, `[${event.severity}] ${event.message}`);
107
+
108
+ // Auto-respawn on crash (unless flapping)
109
+ if (event.type === "agent_crashed" && event.data && !event.data.is_flapping) {
110
+ const sessionProcesses = activeProcesses.get(event.sessionId);
111
+ if (sessionProcesses) {
112
+ // Trigger async respawn — handleAgentCrash is the sole source of the crash event
113
+ const brokerClient = new BrokerClient(BROKER_URL);
114
+ handleAgentCrash(event.slotId, event.data.exit_code as number, event.sessionId, brokerClient)
115
+ .then((crashEvent) => {
116
+ pendingEvents.push(crashEvent);
117
+ })
118
+ .catch((err) => log(LOG_PREFIX, `Crash handler error: ${err}`));
119
+ }
120
+ } else if (event.type === "agent_completed" && event.data) {
121
+ // Codex agents exit with code 0 when they finish their turn, but they may
122
+ // not have actually completed their task. Check task_state — if the agent
123
+ // hasn't signaled done (still "idle" or "addressing_feedback"), auto-respawn
124
+ // with a continuation prompt so it picks up where it left off.
125
+ const brokerClient = new BrokerClient(BROKER_URL);
126
+ autoRestartIfIncomplete(event.slotId, event.sessionId, brokerClient)
127
+ .then((restarted) => {
128
+ if (!restarted) {
129
+ pendingEvents.push(event);
130
+ }
131
+ })
132
+ .catch((err) => {
133
+ log(LOG_PREFIX, `Auto-restart check error: ${err}`);
134
+ pendingEvents.push(event);
135
+ });
136
+ } else {
137
+ pendingEvents.push(event);
138
+ }
139
+ }
140
+
141
+ /** Check if a completed agent actually finished its task; if not, respawn it. */
142
+ async function autoRestartIfIncomplete(
143
+ slotId: number,
144
+ sessionId: string,
145
+ brokerClient: BrokerClient,
146
+ ): Promise<boolean> {
147
+ try {
148
+ const taskInfo = await brokerClient.getTaskState(slotId);
149
+ const state = taskInfo.task_state;
150
+ const name = taskInfo.display_name ?? `Slot ${slotId}`;
151
+
152
+ // Only restart if the agent hasn't completed its work
153
+ if (state === "idle" || state === "addressing_feedback") {
154
+ log(LOG_PREFIX, `${name} exited with code 0 but task_state="${state}" — auto-restarting`);
155
+
156
+ // Find the session's project dir from active sessions
157
+ const sessionMeta = activeSessions.get(sessionId);
158
+ if (!sessionMeta) {
159
+ log(LOG_PREFIX, `No session metadata for ${sessionId}, cannot restart`);
160
+ return false;
161
+ }
162
+
163
+ // Use respawnAgent from recovery module — it preserves context
164
+ const { respawnAgent } = await import("./recovery.ts");
165
+ const result = await respawnAgent(sessionId, slotId, brokerClient, sessionMeta.projectDir);
166
+
167
+ // Track the new process
168
+ const sessionProcesses = activeProcesses.get(sessionId);
169
+ if (sessionProcesses && result.pid) {
170
+ // The new process is tracked by respawnAgent's launchAgent call
171
+ log(LOG_PREFIX, `Auto-restarted ${name} (PID ${result.pid})`);
172
+ }
173
+
174
+ pendingEvents.push({
175
+ type: "agent_restarted",
176
+ severity: "info",
177
+ slotId,
178
+ sessionId,
179
+ message: `${name} auto-restarted (was task_state="${state}", exited code 0)`,
180
+ });
181
+ return true;
182
+ }
183
+
184
+ // Agent completed normally (done_pending_review, approved, released)
185
+ log(LOG_PREFIX, `${name} completed with task_state="${state}" — no restart needed`);
186
+ return false;
187
+ } catch (err) {
188
+ log(LOG_PREFIX, `Failed to check task state for slot ${slotId}: ${err}`);
189
+ return false;
190
+ }
191
+ }
192
+
193
+ /** Track session metadata for restart purposes. */
194
+ const activeSessions: Map<string, { projectDir: string }> = new Map();
195
+
196
+ // --- MCP Server Setup ---
197
+
198
+ const server = new Server(
199
+ { name: "multiagents-orch", version: "1.0.0" },
200
+ { capabilities: { tools: {} } },
201
+ );
202
+
203
+ // Tool definitions
204
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
205
+ tools: [
206
+ {
207
+ name: "create_team",
208
+ description: "Create a new multi-agent team session. Launches headless agents with assigned roles and file ownership.",
209
+ inputSchema: {
210
+ type: "object" as const,
211
+ properties: {
212
+ project_dir: { type: "string", description: "Absolute path to the project directory" },
213
+ session_name: { type: "string", description: "Human-readable session name (e.g. 'Auth Implementation')" },
214
+ agents: {
215
+ type: "array",
216
+ items: {
217
+ type: "object",
218
+ properties: {
219
+ agent_type: { type: "string", enum: ["claude", "codex", "gemini"] },
220
+ name: { type: "string" },
221
+ role: { type: "string" },
222
+ role_description: { type: "string" },
223
+ initial_task: { type: "string" },
224
+ file_ownership: { type: "array", items: { type: "string" } },
225
+ },
226
+ required: ["agent_type", "name", "role", "role_description", "initial_task"],
227
+ },
228
+ },
229
+ plan: {
230
+ type: "array",
231
+ description: "Optional plan items to track progress. Each item has a label and optional agent_name assignment.",
232
+ items: {
233
+ type: "object",
234
+ properties: {
235
+ label: { type: "string", description: "What needs to be done" },
236
+ agent_name: { type: "string", description: "Name of the agent assigned to this item" },
237
+ },
238
+ required: ["label"],
239
+ },
240
+ },
241
+ },
242
+ required: ["project_dir", "session_name", "agents"],
243
+ },
244
+ },
245
+ {
246
+ name: "get_team_status",
247
+ description: "Get current status of all agents in a session, including health, progress, and issues.",
248
+ inputSchema: {
249
+ type: "object" as const,
250
+ properties: {
251
+ session_id: { type: "string" },
252
+ },
253
+ required: ["session_id"],
254
+ },
255
+ },
256
+ {
257
+ name: "broadcast_to_team",
258
+ description: "Send a message to all connected agents in the session.",
259
+ inputSchema: {
260
+ type: "object" as const,
261
+ properties: {
262
+ session_id: { type: "string" },
263
+ message: { type: "string" },
264
+ exclude_roles: { type: "array", items: { type: "string" } },
265
+ },
266
+ required: ["session_id", "message"],
267
+ },
268
+ },
269
+ {
270
+ name: "direct_agent",
271
+ description: "Send a direct message to a specific agent by name, role, or slot ID.",
272
+ inputSchema: {
273
+ type: "object" as const,
274
+ properties: {
275
+ session_id: { type: "string" },
276
+ target: { type: "string", description: "Agent name, role, or slot ID" },
277
+ message: { type: "string" },
278
+ },
279
+ required: ["session_id", "target", "message"],
280
+ },
281
+ },
282
+ {
283
+ name: "add_agent",
284
+ description: "Add a new agent to an existing session.",
285
+ inputSchema: {
286
+ type: "object" as const,
287
+ properties: {
288
+ session_id: { type: "string" },
289
+ agent_type: { type: "string", enum: ["claude", "codex", "gemini"] },
290
+ name: { type: "string" },
291
+ role: { type: "string" },
292
+ role_description: { type: "string" },
293
+ initial_task: { type: "string" },
294
+ file_ownership: { type: "array", items: { type: "string" } },
295
+ },
296
+ required: ["session_id", "agent_type", "name", "role", "role_description", "initial_task"],
297
+ },
298
+ },
299
+ {
300
+ name: "remove_agent",
301
+ description: "Gracefully stop and remove an agent from the session.",
302
+ inputSchema: {
303
+ type: "object" as const,
304
+ properties: {
305
+ session_id: { type: "string" },
306
+ target: { type: "string", description: "Agent name, role, or slot ID" },
307
+ },
308
+ required: ["session_id", "target"],
309
+ },
310
+ },
311
+ {
312
+ name: "control_session",
313
+ description: "Control the session: pause_all, resume_all, pause_agent, resume_agent, extend_budget, set_budget, status.",
314
+ inputSchema: {
315
+ type: "object" as const,
316
+ properties: {
317
+ session_id: { type: "string" },
318
+ action: { type: "string", enum: ["pause_all", "resume_all", "pause_agent", "resume_agent", "extend_budget", "set_budget", "status"] },
319
+ target: { type: "string", description: "Agent name/role/slot for agent-level actions, or guardrail_id for set_budget" },
320
+ value: { type: "number", description: "New value for budget actions" },
321
+ },
322
+ required: ["session_id", "action"],
323
+ },
324
+ },
325
+ {
326
+ name: "adjust_guardrail",
327
+ description: "View or update guardrail limits for a session.",
328
+ inputSchema: {
329
+ type: "object" as const,
330
+ properties: {
331
+ session_id: { type: "string" },
332
+ action: { type: "string", enum: ["view", "update"] },
333
+ guardrail_id: { type: "string" },
334
+ new_value: { type: "number" },
335
+ },
336
+ required: ["session_id", "action"],
337
+ },
338
+ },
339
+ {
340
+ name: "get_session_log",
341
+ description: "Get the message history for a session.",
342
+ inputSchema: {
343
+ type: "object" as const,
344
+ properties: {
345
+ session_id: { type: "string" },
346
+ limit: { type: "number" },
347
+ since: { type: "number", description: "Epoch ms timestamp to get messages after" },
348
+ },
349
+ required: ["session_id"],
350
+ },
351
+ },
352
+ {
353
+ name: "release_agent",
354
+ description: "Release a specific agent, allowing it to disconnect. Use after their work is approved and complete.",
355
+ inputSchema: {
356
+ type: "object" as const,
357
+ properties: {
358
+ session_id: { type: "string" },
359
+ target: { type: "string", description: "Agent name, role, or slot ID" },
360
+ message: { type: "string", description: "Optional release message" },
361
+ },
362
+ required: ["session_id", "target"],
363
+ },
364
+ },
365
+ {
366
+ name: "release_all",
367
+ description: "Release all agents in a session, allowing them to disconnect. Use when the entire team's work is complete.",
368
+ inputSchema: {
369
+ type: "object" as const,
370
+ properties: {
371
+ session_id: { type: "string" },
372
+ message: { type: "string", description: "Optional release message" },
373
+ },
374
+ required: ["session_id"],
375
+ },
376
+ },
377
+ {
378
+ name: "end_session",
379
+ description: "End a session: stop all agents, archive the session. Optionally create a PR.",
380
+ inputSchema: {
381
+ type: "object" as const,
382
+ properties: {
383
+ session_id: { type: "string" },
384
+ create_pr: { type: "boolean" },
385
+ },
386
+ required: ["session_id"],
387
+ },
388
+ },
389
+ {
390
+ name: "cleanup_dead_slots",
391
+ description: "Remove disconnected/dead slots from a session that will never reconnect. Useful when crashed agents left stale slots behind.",
392
+ inputSchema: {
393
+ type: "object" as const,
394
+ properties: {
395
+ session_id: { type: "string" },
396
+ keep_released: { type: "boolean", description: "If true, keep slots in 'released' state (default: false — removes all non-connected)" },
397
+ },
398
+ required: ["session_id"],
399
+ },
400
+ },
401
+ ],
402
+ }));
403
+
404
+ // Tool implementations
405
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
406
+ const { name, arguments: args } = request.params;
407
+ const brokerClient = new BrokerClient(BROKER_URL);
408
+
409
+ try {
410
+ switch (name) {
411
+ // ---- create_team ----
412
+ case "create_team": {
413
+ const { project_dir, session_name, agents, plan: planItems } = args as {
414
+ project_dir: string;
415
+ session_name: string;
416
+ agents: AgentLaunchConfig[];
417
+ plan?: { label: string; agent_name?: string }[];
418
+ };
419
+
420
+ // Detect available agents
421
+ for (const agentCfg of agents) {
422
+ const detection = await detectAgent(agentCfg.agent_type);
423
+ if (!detection.available) {
424
+ return { content: [{ type: "text", text: `Error: ${agentCfg.agent_type} CLI not found on PATH. Install it first.` }] };
425
+ }
426
+ }
427
+
428
+ const sessionId = slugify(session_name);
429
+ const gitRoot = await getGitRoot(project_dir);
430
+
431
+ // Create session in broker
432
+ await brokerClient.createSession({
433
+ id: sessionId,
434
+ name: session_name,
435
+ project_dir,
436
+ git_root: gitRoot,
437
+ });
438
+
439
+ const sessionProcs = new Map<number, Subprocess>();
440
+ activeProcesses.set(sessionId, sessionProcs);
441
+ activeSessions.set(sessionId, { projectDir: project_dir });
442
+
443
+ // Launch each agent
444
+ const launchedAgents: { name: string; slot_id: number; pid: number }[] = [];
445
+ for (const agentCfg of agents) {
446
+ const result = await launchAgent(sessionId, project_dir, agentCfg, brokerClient);
447
+ sessionProcs.set(result.slotId, result.process);
448
+
449
+ // Start monitoring
450
+ monitorProcess(result.process, result.slotId, sessionId, brokerClient, handleEvent);
451
+
452
+ // Announce to previously launched agents
453
+ const slot = await brokerClient.getSlot(result.slotId);
454
+ await announceNewMember(sessionId, slot, agentCfg, brokerClient);
455
+
456
+ launchedAgents.push({
457
+ name: agentCfg.name,
458
+ slot_id: result.slotId,
459
+ pid: result.pid,
460
+ });
461
+ }
462
+
463
+ // Create plan if provided, then broadcast to each agent with their assigned items
464
+ let planSummary = "";
465
+ if (planItems && planItems.length > 0) {
466
+ const slotByName = new Map(launchedAgents.map((a) => [a.name, a.slot_id]));
467
+ const lastAgentSlot = launchedAgents[launchedAgents.length - 1]?.slot_id;
468
+
469
+ // Every plan item MUST have an assignee — unassigned items go to
470
+ // the last agent in the list (responsible for final verification)
471
+ const items = planItems.map((item) => {
472
+ const resolved = item.agent_name ? slotByName.get(item.agent_name) : undefined;
473
+ return {
474
+ label: item.label,
475
+ assigned_to_slot: resolved ?? lastAgentSlot,
476
+ };
477
+ });
478
+ await brokerClient.createPlan({
479
+ session_id: sessionId,
480
+ title: session_name,
481
+ items,
482
+ });
483
+
484
+ // Fetch the created plan to get actual item IDs
485
+ const plan = await brokerClient.getPlan(sessionId);
486
+ if (plan?.items) {
487
+ // Send each agent their personalized plan context
488
+ for (const agent of launchedAgents) {
489
+ const myItems = plan.items.filter((i: any) => i.assigned_to_slot === agent.slot_id);
490
+ if (myItems.length === 0) continue;
491
+
492
+ const slot = await brokerClient.getSlot(agent.slot_id);
493
+ if (!slot?.peer_id) continue;
494
+
495
+ const itemLines = myItems.map((i: any) =>
496
+ ` [ ] #${i.id}: ${i.label}`
497
+ ).join("\n");
498
+
499
+ await brokerClient.sendMessage({
500
+ from_id: "orchestrator",
501
+ to_id: slot.peer_id,
502
+ text: `PLAN — Your assigned items:\n${itemLines}\n\nAs you complete each item, call: update_plan({item_id: <ID>, status: "done"}).\nCall get_plan to see the full plan anytime.`,
503
+ msg_type: "system",
504
+ session_id: sessionId,
505
+ });
506
+ }
507
+ }
508
+
509
+ planSummary = `\nPlan: ${planItems.length} items tracked.`;
510
+ }
511
+
512
+ const status = await getTeamStatus(sessionId, brokerClient);
513
+ const display = formatTeamStatusForDisplay(status);
514
+
515
+ // Auto-launch dashboard in a new terminal
516
+ launchDashboard(sessionId, project_dir);
517
+
518
+ return {
519
+ content: [{
520
+ type: "text",
521
+ text: `Session "${sessionId}" created with ${launchedAgents.length} agents.${planSummary}\n\n${display}\n\nDashboard launched — run \`bun cli.ts dashboard ${sessionId}\` to reopen.`,
522
+ }],
523
+ };
524
+ }
525
+
526
+ // ---- get_team_status ----
527
+ case "get_team_status": {
528
+ const { session_id } = args as { session_id: string };
529
+ const status = await getTeamStatus(session_id, brokerClient);
530
+ const display = formatTeamStatusForDisplay(status);
531
+
532
+ // Include any pending events
533
+ const events = pendingEvents.splice(0, pendingEvents.length);
534
+ const eventText = events.length > 0
535
+ ? "\n\nRecent events:\n" + events.map((e) => `[${e.severity}] ${e.message}`).join("\n")
536
+ : "";
537
+
538
+ return { content: [{ type: "text", text: display + eventText }] };
539
+ }
540
+
541
+ // ---- broadcast_to_team ----
542
+ case "broadcast_to_team": {
543
+ const { session_id, message, exclude_roles } = args as {
544
+ session_id: string;
545
+ message: string;
546
+ exclude_roles?: string[];
547
+ };
548
+ const result = await broadcastToTeam(session_id, message, brokerClient, exclude_roles);
549
+ return { content: [{ type: "text", text: `Broadcast delivered to ${result.delivered_to} agents.` }] };
550
+ }
551
+
552
+ // ---- direct_agent ----
553
+ case "direct_agent": {
554
+ const { session_id, target, message } = args as {
555
+ session_id: string;
556
+ target: string;
557
+ message: string;
558
+ };
559
+ const slot = await resolveTarget(session_id, target, brokerClient);
560
+ if (!slot || !slot.peer_id) {
561
+ return { content: [{ type: "text", text: `Could not find connected agent matching "${target}".` }] };
562
+ }
563
+
564
+ await brokerClient.sendMessage({
565
+ from_id: "orchestrator",
566
+ to_id: slot.peer_id,
567
+ text: message,
568
+ msg_type: "chat",
569
+ session_id,
570
+ });
571
+
572
+ return { content: [{ type: "text", text: `Message sent to ${slot.display_name ?? `slot ${slot.id}`}.` }] };
573
+ }
574
+
575
+ // ---- add_agent ----
576
+ case "add_agent": {
577
+ const { session_id, agent_type, name: agentName, role, role_description, initial_task, file_ownership } = args as {
578
+ session_id: string;
579
+ agent_type: "claude" | "codex" | "gemini";
580
+ name: string;
581
+ role: string;
582
+ role_description: string;
583
+ initial_task: string;
584
+ file_ownership?: string[];
585
+ };
586
+
587
+ const detection = await detectAgent(agent_type);
588
+ if (!detection.available) {
589
+ return { content: [{ type: "text", text: `Error: ${agent_type} CLI not found.` }] };
590
+ }
591
+
592
+ const session = await brokerClient.getSession(session_id);
593
+ const config: AgentLaunchConfig = {
594
+ agent_type,
595
+ name: agentName,
596
+ role,
597
+ role_description,
598
+ initial_task,
599
+ file_ownership,
600
+ };
601
+
602
+ const result = await launchAgent(session_id, session.project_dir, config, brokerClient);
603
+
604
+ const sessionProcs = activeProcesses.get(session_id) ?? new Map();
605
+ sessionProcs.set(result.slotId, result.process);
606
+ activeProcesses.set(session_id, sessionProcs);
607
+ if (!activeSessions.has(session_id)) {
608
+ activeSessions.set(session_id, { projectDir: session.project_dir });
609
+ }
610
+
611
+ monitorProcess(result.process, result.slotId, session_id, brokerClient, handleEvent);
612
+
613
+ const slot = await brokerClient.getSlot(result.slotId);
614
+ await announceNewMember(session_id, slot, config, brokerClient);
615
+
616
+ return {
617
+ content: [{
618
+ type: "text",
619
+ text: `Added ${agentName} (${agent_type}) as ${role} in slot ${result.slotId} (PID ${result.pid}).`,
620
+ }],
621
+ };
622
+ }
623
+
624
+ // ---- remove_agent ----
625
+ case "remove_agent": {
626
+ const { session_id, target } = args as { session_id: string; target: string };
627
+ const slot = await resolveTarget(session_id, target, brokerClient);
628
+ if (!slot) {
629
+ return { content: [{ type: "text", text: `Could not find agent matching "${target}".` }] };
630
+ }
631
+
632
+ // Kill the process if we have it
633
+ const sessionProcs = activeProcesses.get(session_id);
634
+ if (sessionProcs) {
635
+ const proc = sessionProcs.get(slot.id);
636
+ if (proc) {
637
+ proc.kill();
638
+ sessionProcs.delete(slot.id);
639
+ }
640
+ }
641
+
642
+ // Update slot to disconnected
643
+ await brokerClient.updateSlot({ id: slot.id, status: "disconnected" });
644
+
645
+ // Notify remaining team
646
+ const slots = await brokerClient.listSlots(session_id);
647
+ for (const s of slots) {
648
+ if (s.id !== slot.id && s.status === "connected" && s.peer_id) {
649
+ await brokerClient.sendMessage({
650
+ from_id: "orchestrator",
651
+ to_id: s.peer_id,
652
+ text: `[Team Update] ${slot.display_name ?? `Agent #${slot.id}`} (${slot.role ?? "unassigned"}) has been removed from the team.`,
653
+ msg_type: "team_change",
654
+ session_id,
655
+ });
656
+ }
657
+ }
658
+
659
+ return { content: [{ type: "text", text: `Removed ${slot.display_name ?? `slot ${slot.id}`} from the session.` }] };
660
+ }
661
+
662
+ // ---- control_session ----
663
+ case "control_session": {
664
+ const { session_id, action, target, value } = args as {
665
+ session_id: string;
666
+ action: string;
667
+ target?: string;
668
+ value?: number;
669
+ };
670
+ const result = await controlSession(session_id, action, brokerClient, target, value);
671
+ return { content: [{ type: "text", text: result.message }] };
672
+ }
673
+
674
+ // ---- adjust_guardrail ----
675
+ case "adjust_guardrail": {
676
+ const { session_id, action, guardrail_id, new_value } = args as {
677
+ session_id: string;
678
+ action: "view" | "update";
679
+ guardrail_id?: string;
680
+ new_value?: number;
681
+ };
682
+
683
+ if (action === "view") {
684
+ const checks = await checkGuardrails(session_id, brokerClient);
685
+ const lines = checks.map((c) => {
686
+ const icon = c.status === "triggered" ? "[!]" : c.status === "warning" ? "[?]" : "[ok]";
687
+ return `${icon} ${c.message}`;
688
+ });
689
+ return { content: [{ type: "text", text: lines.join("\n") }] };
690
+ }
691
+
692
+ if (action === "update") {
693
+ if (!guardrail_id || new_value === undefined) {
694
+ return { content: [{ type: "text", text: "guardrail_id and new_value required for update." }] };
695
+ }
696
+ const updated = await brokerClient.updateGuardrail({
697
+ session_id,
698
+ guardrail_id,
699
+ new_value,
700
+ changed_by: "orchestrator",
701
+ });
702
+ return { content: [{ type: "text", text: `Updated ${guardrail_id} to ${new_value} ${updated.unit}.` }] };
703
+ }
704
+
705
+ return { content: [{ type: "text", text: `Unknown guardrail action: ${action}` }] };
706
+ }
707
+
708
+ // ---- get_session_log ----
709
+ case "get_session_log": {
710
+ const { session_id, limit, since } = args as {
711
+ session_id: string;
712
+ limit?: number;
713
+ since?: number;
714
+ };
715
+ const messages = await brokerClient.getMessageLog(session_id, { limit: limit ?? 50, since });
716
+ const lines = messages.map((m) => {
717
+ const from = m.from_slot_id !== null ? `slot ${m.from_slot_id}` : m.from_id;
718
+ const to = m.to_slot_id !== null ? `slot ${m.to_slot_id}` : m.to_id;
719
+ return `[${m.sent_at}] ${from} -> ${to} (${m.msg_type}): ${m.text.slice(0, 200)}`;
720
+ });
721
+ return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No messages found." }] };
722
+ }
723
+
724
+ // ---- release_agent ----
725
+ case "release_agent": {
726
+ const { session_id, target, message } = args as { session_id: string; target: string; message?: string };
727
+ const slot = await resolveTarget(session_id, target, brokerClient);
728
+ if (!slot) {
729
+ const slots = await brokerClient.listSlots(session_id);
730
+ return { content: [{ type: "text", text: `Agent "${target}" not found. Available: ${slots.map(s => s.display_name || s.role || s.id).join(", ")}` }] };
731
+ }
732
+ const result = await brokerClient.releaseAgent({
733
+ session_id,
734
+ target_slot_id: slot.id,
735
+ released_by: "__orchestrator__",
736
+ message,
737
+ });
738
+ return {
739
+ content: [{ type: "text", text: `Released ${slot.display_name || slot.role || slot.id}. Task state: ${result.task_state}. Agent can now disconnect.` }],
740
+ };
741
+ }
742
+
743
+ // ---- release_all ----
744
+ case "release_all": {
745
+ const { session_id, message } = args as { session_id: string; message?: string };
746
+ const slots = await brokerClient.listSlots(session_id);
747
+ let released = 0;
748
+ for (const slot of slots) {
749
+ if (slot.task_state !== "released" && slot.task_state !== "idle") {
750
+ await brokerClient.releaseAgent({
751
+ session_id,
752
+ target_slot_id: slot.id,
753
+ released_by: "__orchestrator__",
754
+ message: message || "All agents released. Session complete.",
755
+ });
756
+ released++;
757
+ }
758
+ }
759
+ return {
760
+ content: [{ type: "text", text: `Released ${released} agent(s). All agents can now disconnect.` }],
761
+ };
762
+ }
763
+
764
+ // ---- cleanup_dead_slots ----
765
+ case "cleanup_dead_slots": {
766
+ const { session_id, keep_released } = args as { session_id: string; keep_released?: boolean };
767
+ const slots = await brokerClient.listSlots(session_id);
768
+ let removed = 0;
769
+ const removedNames: string[] = [];
770
+
771
+ for (const slot of slots) {
772
+ const isDead = slot.status === "disconnected" && slot.task_state !== "released";
773
+ const isReleased = slot.task_state === "released";
774
+ const shouldRemove = isDead || (isReleased && !keep_released);
775
+
776
+ if (shouldRemove) {
777
+ // Delete the slot from the DB
778
+ try {
779
+ await brokerClient.post("/slots/delete", { id: slot.id });
780
+ } catch {
781
+ // If no delete endpoint, mark it archived via status
782
+ await brokerClient.updateSlot({ id: slot.id, status: "archived" as any });
783
+ }
784
+ removedNames.push(slot.display_name || slot.role || `slot-${slot.id}`);
785
+ removed++;
786
+
787
+ // Also remove from active process tracking
788
+ const sessionProcs = activeProcesses.get(session_id);
789
+ if (sessionProcs) {
790
+ const proc = sessionProcs.get(slot.id);
791
+ if (proc) {
792
+ try { proc.kill(); } catch { /* already dead */ }
793
+ sessionProcs.delete(slot.id);
794
+ }
795
+ }
796
+ }
797
+ }
798
+
799
+ return {
800
+ content: [{
801
+ type: "text",
802
+ text: removed > 0
803
+ ? `Cleaned up ${removed} dead slot(s): ${removedNames.join(", ")}`
804
+ : `No dead slots found. All ${slots.length} slot(s) are active.`,
805
+ }],
806
+ };
807
+ }
808
+
809
+ // ---- end_session ----
810
+ case "end_session": {
811
+ const { session_id, create_pr } = args as { session_id: string; create_pr?: boolean };
812
+
813
+ // Kill all active processes
814
+ const sessionProcs = activeProcesses.get(session_id);
815
+ if (sessionProcs) {
816
+ for (const [slotId, proc] of sessionProcs) {
817
+ log(LOG_PREFIX, `Stopping agent in slot ${slotId}`);
818
+ proc.kill();
819
+ }
820
+ sessionProcs.clear();
821
+ activeProcesses.delete(session_id);
822
+ }
823
+
824
+ // Mark all slots as disconnected
825
+ const slots = await brokerClient.listSlots(session_id);
826
+ for (const slot of slots) {
827
+ if (slot.status === "connected") {
828
+ await brokerClient.updateSlot({ id: slot.id, status: "disconnected" });
829
+ }
830
+ }
831
+
832
+ // Archive session
833
+ await brokerClient.updateSession({
834
+ id: session_id,
835
+ status: "archived",
836
+ });
837
+
838
+ let prMessage = "";
839
+ if (create_pr) {
840
+ prMessage = "\nNote: PR creation should be done by running `gh pr create` in the project directory.";
841
+ }
842
+
843
+ return {
844
+ content: [{
845
+ type: "text",
846
+ text: `Session "${session_id}" ended. ${slots.length} agents stopped. Session archived.${prMessage}`,
847
+ }],
848
+ };
849
+ }
850
+
851
+ default:
852
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
853
+ }
854
+ } catch (err) {
855
+ const errMsg = err instanceof Error ? err.message : String(err);
856
+ log(LOG_PREFIX, `Tool error (${name}): ${errMsg}`);
857
+ return { content: [{ type: "text", text: `Error: ${errMsg}` }], isError: true };
858
+ }
859
+ });
860
+
861
+ // --- Background loops ---
862
+
863
+ let guardrailTimer: ReturnType<typeof setInterval> | null = null;
864
+ let conflictTimer: ReturnType<typeof setInterval> | null = null;
865
+
866
+ function startBackgroundLoops(brokerClient: BrokerClient): void {
867
+ // Guardrail check loop
868
+ guardrailTimer = setInterval(async () => {
869
+ for (const sessionId of activeProcesses.keys()) {
870
+ try {
871
+ await enforceGuardrails(sessionId, brokerClient, handleEvent);
872
+ } catch (err) {
873
+ log(LOG_PREFIX, `Guardrail check error for ${sessionId}: ${err}`);
874
+ }
875
+ }
876
+ }, GUARDRAIL_CHECK_INTERVAL);
877
+
878
+ // Dead slot auto-cleanup loop — every 60s, remove slots disconnected for >5 min
879
+ setInterval(async () => {
880
+ const now = Date.now();
881
+ for (const sessionId of activeProcesses.keys()) {
882
+ try {
883
+ const slots = await brokerClient.listSlots(sessionId);
884
+ for (const slot of slots) {
885
+ if (slot.status !== "disconnected") continue;
886
+ // Skip if recently disconnected (might be restarting)
887
+ const disconnectedAt = slot.last_disconnected ?? 0;
888
+ const deadFor = now - disconnectedAt;
889
+ if (deadFor < 5 * 60 * 1000) continue; // <5 min, might reconnect
890
+
891
+ // Auto-cleanup: mark as archived
892
+ try {
893
+ await brokerClient.updateSlot({ id: slot.id, status: "archived" as any });
894
+ log(LOG_PREFIX, `Auto-cleaned dead slot ${slot.id} (${slot.display_name || slot.role}) — disconnected for ${Math.round(deadFor / 60000)}m`);
895
+ } catch { /* best effort */ }
896
+ }
897
+ } catch { /* ok */ }
898
+ }
899
+ }, 60_000);
900
+
901
+ // Stuck agent nudge loop — every 45s, nudge agents silent for >2 min
902
+ setInterval(async () => {
903
+ for (const sessionId of activeProcesses.keys()) {
904
+ try {
905
+ const slots = await brokerClient.listSlots(sessionId);
906
+ for (const slot of slots) {
907
+ if (slot.status !== "connected" || !slot.peer_id) continue;
908
+ if (slot.paused === true || (slot.paused as unknown as number) === 1) continue;
909
+
910
+ // Check last_connected or context_snapshot for activity
911
+ const lastActivity = (() => {
912
+ if (slot.context_snapshot) {
913
+ try {
914
+ const snap = JSON.parse(slot.context_snapshot);
915
+ if (snap.updated_at) return snap.updated_at;
916
+ } catch { /* ok */ }
917
+ }
918
+ return slot.last_connected ?? 0;
919
+ })();
920
+
921
+ const silentMs = Date.now() - lastActivity;
922
+ if (silentMs > 2 * 60 * 1000) {
923
+ // Nudge the agent
924
+ await brokerClient.sendMessage({
925
+ from_id: "orchestrator",
926
+ to_id: slot.peer_id,
927
+ text: `[NUDGE] You have been silent for ${Math.round(silentMs / 60000)} minutes. ` +
928
+ `Check your teammates with check_team_status and check_messages. ` +
929
+ `If you are done, call signal_done. If you are blocked, message your team for help.`,
930
+ msg_type: "system",
931
+ session_id: sessionId,
932
+ });
933
+ log(LOG_PREFIX, `Nudged silent agent ${slot.display_name || slot.id} (${Math.round(silentMs / 60000)}m silent)`);
934
+ }
935
+ }
936
+ } catch { /* ok */ }
937
+ }
938
+ }, 45_000);
939
+
940
+ // Conflict detection loop — basic git status monitoring
941
+ conflictTimer = setInterval(async () => {
942
+ for (const sessionId of activeProcesses.keys()) {
943
+ try {
944
+ const session = await brokerClient.getSession(sessionId);
945
+ if (session.status !== "active" || !session.git_root) continue;
946
+
947
+ const proc = Bun.spawn(["git", "status", "--porcelain"], {
948
+ cwd: session.project_dir,
949
+ stdout: "pipe",
950
+ stderr: "ignore",
951
+ });
952
+ const output = await new Response(proc.stdout).text();
953
+ const exitCode = await proc.exited;
954
+
955
+ if (exitCode === 0) {
956
+ // Check for merge conflicts (lines starting with UU, AA, DD, etc.)
957
+ const conflictLines = output
958
+ .split("\n")
959
+ .filter((l) => /^(UU|AA|DD|AU|UA|DU|UD)\s/.test(l));
960
+
961
+ if (conflictLines.length > 0) {
962
+ handleEvent({
963
+ type: "git_conflict",
964
+ severity: "critical",
965
+ slotId: -1,
966
+ sessionId,
967
+ message: `Git conflicts detected: ${conflictLines.length} file(s)`,
968
+ data: { files: conflictLines.map((l) => l.slice(3).trim()) },
969
+ });
970
+ }
971
+ }
972
+ } catch (err) {
973
+ log(LOG_PREFIX, `Conflict check error for ${sessionId}: ${err}`);
974
+ }
975
+ }
976
+ }, CONFLICT_CHECK_INTERVAL);
977
+ }
978
+
979
+ // --- Main ---
980
+
981
+ async function main(): Promise<void> {
982
+ const brokerClient = new BrokerClient(BROKER_URL);
983
+
984
+ // Ensure broker is running
985
+ await ensureBroker(brokerClient);
986
+
987
+ // Start background loops
988
+ startBackgroundLoops(brokerClient);
989
+
990
+ // Connect MCP over stdio
991
+ const transport = new StdioServerTransport();
992
+ await server.connect(transport);
993
+
994
+ log(LOG_PREFIX, "Orchestrator MCP server running on stdio");
995
+ }
996
+
997
+ main().catch((err) => {
998
+ console.error(`[orchestrator] Fatal: ${err}`);
999
+ process.exit(1);
1000
+ });