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.
- package/.mcp.json +12 -0
- package/README.md +184 -0
- package/adapters/base-adapter.ts +1493 -0
- package/adapters/claude-adapter.ts +66 -0
- package/adapters/codex-adapter.ts +135 -0
- package/adapters/gemini-adapter.ts +129 -0
- package/broker.ts +1263 -0
- package/cli/commands.ts +194 -0
- package/cli/dashboard.ts +988 -0
- package/cli/session.ts +278 -0
- package/cli/setup.ts +257 -0
- package/cli.ts +17 -0
- package/index.ts +41 -0
- package/noop-mcp.ts +63 -0
- package/orchestrator/guardrails.ts +243 -0
- package/orchestrator/launcher.ts +433 -0
- package/orchestrator/monitor.ts +285 -0
- package/orchestrator/orchestrator-server.ts +1000 -0
- package/orchestrator/progress.ts +214 -0
- package/orchestrator/recovery.ts +176 -0
- package/orchestrator/session-control.ts +343 -0
- package/package.json +70 -0
- package/scripts/postinstall.ts +84 -0
- package/scripts/version.ts +62 -0
- package/server.ts +52 -0
- package/shared/broker-client.ts +243 -0
- package/shared/constants.ts +148 -0
- package/shared/summarize.ts +97 -0
- package/shared/types.ts +419 -0
- package/shared/utils.ts +121 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// multiagents — Guardrail Enforcement
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Checks guardrail limits (duration, message counts, agent counts, etc.)
|
|
5
|
+
// and enforces them by pausing/warning as needed.
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
import type { Guardrail, GuardrailState } from "../shared/types.ts";
|
|
9
|
+
import type { BrokerClient } from "../shared/broker-client.ts";
|
|
10
|
+
import type { AgentEvent } from "./monitor.ts";
|
|
11
|
+
import { log } from "../shared/utils.ts";
|
|
12
|
+
|
|
13
|
+
const LOG_PREFIX = "guardrails";
|
|
14
|
+
|
|
15
|
+
/** Result of checking a single guardrail. */
|
|
16
|
+
export interface GuardrailCheck {
|
|
17
|
+
guardrail: GuardrailState;
|
|
18
|
+
status: "ok" | "warning" | "triggered";
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check all guardrails for a session and return their current status.
|
|
24
|
+
* Fetches guardrail state from the broker (which computes usage).
|
|
25
|
+
*/
|
|
26
|
+
export async function checkGuardrails(
|
|
27
|
+
sessionId: string,
|
|
28
|
+
brokerClient: BrokerClient,
|
|
29
|
+
): Promise<GuardrailCheck[]> {
|
|
30
|
+
const guardrails = await brokerClient.getGuardrails(sessionId);
|
|
31
|
+
const checks: GuardrailCheck[] = [];
|
|
32
|
+
|
|
33
|
+
for (const g of guardrails) {
|
|
34
|
+
const { usage } = g;
|
|
35
|
+
|
|
36
|
+
if (usage.status === "triggered") {
|
|
37
|
+
checks.push({
|
|
38
|
+
guardrail: g,
|
|
39
|
+
status: "triggered",
|
|
40
|
+
message: `${g.label} reached limit: ${usage.current}/${usage.limit} ${g.unit} (${Math.round(usage.percent * 100)}%)`,
|
|
41
|
+
});
|
|
42
|
+
} else if (usage.status === "warning") {
|
|
43
|
+
checks.push({
|
|
44
|
+
guardrail: g,
|
|
45
|
+
status: "warning",
|
|
46
|
+
message: `${g.label} approaching limit: ${usage.current}/${usage.limit} ${g.unit} (${Math.round(usage.percent * 100)}%)`,
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
checks.push({
|
|
50
|
+
guardrail: g,
|
|
51
|
+
status: "ok",
|
|
52
|
+
message: `${g.label}: ${usage.current}/${usage.limit} ${g.unit} (${Math.round(usage.percent * 100)}%)`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return checks;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run guardrail checks and enforce limits:
|
|
62
|
+
* - Emit warning events when warn_at_percent is reached
|
|
63
|
+
* - Pause session when a guardrail is triggered at 100%
|
|
64
|
+
*/
|
|
65
|
+
export async function enforceGuardrails(
|
|
66
|
+
sessionId: string,
|
|
67
|
+
brokerClient: BrokerClient,
|
|
68
|
+
onEvent: (event: AgentEvent) => void,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const checks = await checkGuardrails(sessionId, brokerClient);
|
|
71
|
+
|
|
72
|
+
for (const check of checks) {
|
|
73
|
+
// Skip monitor-only stats — they never trigger enforcement
|
|
74
|
+
if (check.guardrail.action === "monitor") continue;
|
|
75
|
+
|
|
76
|
+
if (check.status === "warning") {
|
|
77
|
+
onEvent({
|
|
78
|
+
type: "guardrail_warning",
|
|
79
|
+
severity: "warning",
|
|
80
|
+
slotId: -1,
|
|
81
|
+
sessionId,
|
|
82
|
+
message: check.message,
|
|
83
|
+
data: {
|
|
84
|
+
guardrail_id: check.guardrail.id,
|
|
85
|
+
usage_percent: check.guardrail.usage.percent,
|
|
86
|
+
current: check.guardrail.usage.current,
|
|
87
|
+
limit: check.guardrail.usage.limit,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (check.status === "triggered") {
|
|
93
|
+
const { guardrail } = check;
|
|
94
|
+
|
|
95
|
+
if (guardrail.action === "pause") {
|
|
96
|
+
log(LOG_PREFIX, `Guardrail triggered — pausing session: ${check.message}`);
|
|
97
|
+
await pauseForGuardrail(sessionId, guardrail, brokerClient);
|
|
98
|
+
|
|
99
|
+
onEvent({
|
|
100
|
+
type: "guardrail_triggered",
|
|
101
|
+
severity: "critical",
|
|
102
|
+
slotId: -1,
|
|
103
|
+
sessionId,
|
|
104
|
+
message: `Session paused: ${check.message}`,
|
|
105
|
+
data: {
|
|
106
|
+
guardrail_id: guardrail.id,
|
|
107
|
+
action: "pause",
|
|
108
|
+
suggested_increases: guardrail.suggested_increases,
|
|
109
|
+
adjustable: guardrail.adjustable,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
} else if (guardrail.action === "stop") {
|
|
113
|
+
log(LOG_PREFIX, `Guardrail triggered — stop action: ${check.message}`);
|
|
114
|
+
|
|
115
|
+
onEvent({
|
|
116
|
+
type: "guardrail_triggered",
|
|
117
|
+
severity: "critical",
|
|
118
|
+
slotId: -1,
|
|
119
|
+
sessionId,
|
|
120
|
+
message: `Guardrail stop: ${check.message}`,
|
|
121
|
+
data: {
|
|
122
|
+
guardrail_id: guardrail.id,
|
|
123
|
+
action: "stop",
|
|
124
|
+
suggested_increases: guardrail.suggested_increases,
|
|
125
|
+
adjustable: guardrail.adjustable,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
} else if (guardrail.action === "warn") {
|
|
129
|
+
onEvent({
|
|
130
|
+
type: "guardrail_triggered",
|
|
131
|
+
severity: "warning",
|
|
132
|
+
slotId: -1,
|
|
133
|
+
sessionId,
|
|
134
|
+
message: check.message,
|
|
135
|
+
data: {
|
|
136
|
+
guardrail_id: guardrail.id,
|
|
137
|
+
action: "warn",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pause the entire session due to a guardrail being triggered.
|
|
147
|
+
* Sends control messages to all connected agents and marks session as paused.
|
|
148
|
+
*/
|
|
149
|
+
export async function pauseForGuardrail(
|
|
150
|
+
sessionId: string,
|
|
151
|
+
guardrail: Guardrail | GuardrailState,
|
|
152
|
+
brokerClient: BrokerClient,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
// Mark session as paused
|
|
155
|
+
await brokerClient.updateSession({
|
|
156
|
+
id: sessionId,
|
|
157
|
+
status: "paused",
|
|
158
|
+
pause_reason: `Guardrail triggered: ${guardrail.label} (${guardrail.id})`,
|
|
159
|
+
paused_at: Date.now(),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Pause all connected slots
|
|
163
|
+
const slots = await brokerClient.listSlots(sessionId);
|
|
164
|
+
for (const slot of slots) {
|
|
165
|
+
if (slot.status === "connected" && !slot.paused) {
|
|
166
|
+
await brokerClient.updateSlot({
|
|
167
|
+
id: slot.id,
|
|
168
|
+
paused: true,
|
|
169
|
+
paused_at: Date.now(),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Hold messages for paused agents
|
|
173
|
+
await brokerClient.holdMessages(sessionId, slot.id);
|
|
174
|
+
|
|
175
|
+
// Send control message if agent has a peer connection
|
|
176
|
+
if (slot.peer_id) {
|
|
177
|
+
await brokerClient.sendMessage({
|
|
178
|
+
from_id: "orchestrator",
|
|
179
|
+
to_id: slot.peer_id,
|
|
180
|
+
text: JSON.stringify({
|
|
181
|
+
action: "pause",
|
|
182
|
+
reason: `Guardrail "${guardrail.label}" reached limit`,
|
|
183
|
+
guardrail_id: guardrail.id,
|
|
184
|
+
adjustable: guardrail.adjustable,
|
|
185
|
+
suggested_increases: guardrail.suggested_increases,
|
|
186
|
+
}),
|
|
187
|
+
msg_type: "control",
|
|
188
|
+
session_id: sessionId,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
log(LOG_PREFIX, `Session ${sessionId} paused for guardrail: ${guardrail.id}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resume a session after a guardrail limit has been adjusted upward.
|
|
199
|
+
* Unpauses all slots and releases held messages.
|
|
200
|
+
*/
|
|
201
|
+
export async function resumeAfterGuardrailAdjusted(
|
|
202
|
+
sessionId: string,
|
|
203
|
+
brokerClient: BrokerClient,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
// Update session status
|
|
206
|
+
await brokerClient.updateSession({
|
|
207
|
+
id: sessionId,
|
|
208
|
+
status: "active",
|
|
209
|
+
pause_reason: null,
|
|
210
|
+
paused_at: null,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Resume all paused slots
|
|
214
|
+
const slots = await brokerClient.listSlots(sessionId);
|
|
215
|
+
for (const slot of slots) {
|
|
216
|
+
if (slot.paused) {
|
|
217
|
+
await brokerClient.updateSlot({
|
|
218
|
+
id: slot.id,
|
|
219
|
+
paused: false,
|
|
220
|
+
paused_at: null,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Release held messages
|
|
224
|
+
await brokerClient.releaseHeldMessages(sessionId, slot.id);
|
|
225
|
+
|
|
226
|
+
// Send resume control message
|
|
227
|
+
if (slot.peer_id) {
|
|
228
|
+
await brokerClient.sendMessage({
|
|
229
|
+
from_id: "orchestrator",
|
|
230
|
+
to_id: slot.peer_id,
|
|
231
|
+
text: JSON.stringify({
|
|
232
|
+
action: "resume",
|
|
233
|
+
reason: "Guardrail limit adjusted",
|
|
234
|
+
}),
|
|
235
|
+
msg_type: "control",
|
|
236
|
+
session_id: sessionId,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
log(LOG_PREFIX, `Session ${sessionId} resumed after guardrail adjustment`);
|
|
243
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// multiagents — Agent Launcher
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Spawns headless agent processes with appropriate CLI flags and env vars.
|
|
5
|
+
// Ensures each agent has MCP access to the multiagents tools for inter-agent
|
|
6
|
+
// communication through the broker.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import type { Subprocess } from "bun";
|
|
10
|
+
import type { AgentType, AgentLaunchConfig, Slot } from "../shared/types.ts";
|
|
11
|
+
import type { BrokerClient } from "../shared/broker-client.ts";
|
|
12
|
+
import { log } from "../shared/utils.ts";
|
|
13
|
+
import { DEFAULT_BROKER_PORT } from "../shared/constants.ts";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
|
|
17
|
+
const LOG_PREFIX = "launcher";
|
|
18
|
+
|
|
19
|
+
/** Resolved path to cli.ts — used to build MCP server commands. */
|
|
20
|
+
const CLI_PATH = path.resolve(import.meta.dir, "..", "cli.ts");
|
|
21
|
+
|
|
22
|
+
/** Detection result for an agent CLI binary. */
|
|
23
|
+
interface AgentDetection {
|
|
24
|
+
available: boolean;
|
|
25
|
+
version?: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Result of launching an agent process. */
|
|
30
|
+
interface LaunchResult {
|
|
31
|
+
slotId: number;
|
|
32
|
+
pid: number;
|
|
33
|
+
process: Subprocess;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** CLI binary name for each agent type. */
|
|
37
|
+
const AGENT_COMMANDS: Record<AgentType, string> = {
|
|
38
|
+
claude: "claude",
|
|
39
|
+
codex: "codex",
|
|
40
|
+
gemini: "npx",
|
|
41
|
+
custom: "",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Detect whether an agent CLI is installed and available on PATH.
|
|
46
|
+
* Handles both direct binaries (claude, codex) and npx-based tools (gemini).
|
|
47
|
+
*/
|
|
48
|
+
export async function detectAgent(type: AgentType): Promise<AgentDetection> {
|
|
49
|
+
const cmd = AGENT_COMMANDS[type];
|
|
50
|
+
if (!cmd) {
|
|
51
|
+
return { available: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Gemini is invoked via npx — check if the package is available
|
|
55
|
+
if (type === "gemini") {
|
|
56
|
+
try {
|
|
57
|
+
const proc = Bun.spawnSync(["npx", "-y", "@google/gemini-cli", "--version"], {
|
|
58
|
+
timeout: 15_000,
|
|
59
|
+
});
|
|
60
|
+
const out = new TextDecoder().decode(proc.stdout).trim();
|
|
61
|
+
if (proc.exitCode === 0 && out) {
|
|
62
|
+
return { available: true, version: out.split("\n")[0], path: "npx" };
|
|
63
|
+
}
|
|
64
|
+
} catch { /* ok */ }
|
|
65
|
+
return { available: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const which = Bun.spawnSync(["which", cmd]);
|
|
69
|
+
const binPath = new TextDecoder().decode(which.stdout).trim();
|
|
70
|
+
|
|
71
|
+
if (which.exitCode !== 0 || !binPath) {
|
|
72
|
+
return { available: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Try to get version
|
|
76
|
+
let version: string | undefined;
|
|
77
|
+
try {
|
|
78
|
+
const proc = Bun.spawnSync([cmd, "--version"]);
|
|
79
|
+
const out = new TextDecoder().decode(proc.stdout).trim();
|
|
80
|
+
if (proc.exitCode === 0 && out) {
|
|
81
|
+
version = out.split("\n")[0];
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Version detection is best-effort
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { available: true, version, path: binPath };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the MCP server config for multiagents, specific to an agent type.
|
|
92
|
+
* Returns the command + args needed to run the MCP server.
|
|
93
|
+
*/
|
|
94
|
+
export function mcpServerCommand(agentType: AgentType): { command: string; args: string[] } {
|
|
95
|
+
return {
|
|
96
|
+
command: "bun",
|
|
97
|
+
args: [CLI_PATH, "mcp-server", "--agent-type", agentType],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ensure the project directory has MCP configs and session file so spawned
|
|
103
|
+
* agents auto-discover the multiagents MCP server and join the session.
|
|
104
|
+
* Writes config files idempotently — preserves existing entries.
|
|
105
|
+
*/
|
|
106
|
+
export async function ensureMcpConfigs(projectDir: string, sessionId: string): Promise<void> {
|
|
107
|
+
// --- Claude: .mcp.json ---
|
|
108
|
+
const mcpJsonPath = path.join(projectDir, ".mcp.json");
|
|
109
|
+
let mcpConfig: Record<string, unknown> = {};
|
|
110
|
+
try {
|
|
111
|
+
const existing = await Bun.file(mcpJsonPath).text();
|
|
112
|
+
mcpConfig = JSON.parse(existing);
|
|
113
|
+
} catch { /* file doesn't exist yet */ }
|
|
114
|
+
|
|
115
|
+
const mcpServers = (mcpConfig.mcpServers as Record<string, unknown>) ?? {};
|
|
116
|
+
const claudeMcp = mcpServerCommand("claude");
|
|
117
|
+
mcpServers["multiagents"] = { command: claudeMcp.command, args: claudeMcp.args };
|
|
118
|
+
mcpConfig.mcpServers = mcpServers;
|
|
119
|
+
await Bun.write(mcpJsonPath, JSON.stringify(mcpConfig, null, 2));
|
|
120
|
+
|
|
121
|
+
// --- Codex: .codex/config.toml ---
|
|
122
|
+
const codexDir = path.join(projectDir, ".codex");
|
|
123
|
+
if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
const codexTomlPath = path.join(codexDir, "config.toml");
|
|
126
|
+
let codexToml = "";
|
|
127
|
+
try { codexToml = await Bun.file(codexTomlPath).text(); } catch { /* ok */ }
|
|
128
|
+
|
|
129
|
+
// Add multiagents section if not present
|
|
130
|
+
if (!codexToml.includes("[mcp_servers.multiagents]")) {
|
|
131
|
+
const codexMcp = mcpServerCommand("codex");
|
|
132
|
+
const entry = `\n[mcp_servers.multiagents]\ncommand = "bun"\nargs = [${codexMcp.args.map(a => JSON.stringify(a)).join(", ")}]\n`;
|
|
133
|
+
await Bun.write(codexTomlPath, codexToml.trimEnd() + "\n" + entry);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- Gemini: ~/.gemini/settings.json ---
|
|
137
|
+
const geminiSettingsPath = path.join(process.env.HOME ?? "", ".gemini", "settings.json");
|
|
138
|
+
try {
|
|
139
|
+
let geminiConfig: Record<string, unknown> = {};
|
|
140
|
+
try {
|
|
141
|
+
const existing = await Bun.file(geminiSettingsPath).text();
|
|
142
|
+
geminiConfig = JSON.parse(existing);
|
|
143
|
+
} catch { /* ok */ }
|
|
144
|
+
|
|
145
|
+
const geminiMcpServers = (geminiConfig.mcpServers as Record<string, unknown>) ?? {};
|
|
146
|
+
if (!geminiMcpServers["multiagents"]) {
|
|
147
|
+
const geminiMcp = mcpServerCommand("gemini");
|
|
148
|
+
geminiMcpServers["multiagents"] = {
|
|
149
|
+
command: geminiMcp.command,
|
|
150
|
+
args: geminiMcp.args,
|
|
151
|
+
};
|
|
152
|
+
geminiConfig.mcpServers = geminiMcpServers;
|
|
153
|
+
await Bun.write(geminiSettingsPath, JSON.stringify(geminiConfig, null, 2));
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Gemini config is optional — skip if ~/.gemini doesn't exist
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Session file: .multiagents/session.json ---
|
|
160
|
+
// The MCP adapter reads this to auto-join the correct session.
|
|
161
|
+
const sessionDir = path.join(projectDir, ".multiagents");
|
|
162
|
+
if (!fs.existsSync(sessionDir)) fs.mkdirSync(sessionDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
const sessionFilePath = path.join(sessionDir, "session.json");
|
|
165
|
+
const sessionFile = {
|
|
166
|
+
session_id: sessionId,
|
|
167
|
+
created_at: new Date().toISOString(),
|
|
168
|
+
broker_port: DEFAULT_BROKER_PORT,
|
|
169
|
+
};
|
|
170
|
+
await Bun.write(sessionFilePath, JSON.stringify(sessionFile, null, 2));
|
|
171
|
+
|
|
172
|
+
log(LOG_PREFIX, `MCP configs and session file ensured in ${projectDir}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Spawn an agent as a background process with the appropriate CLI flags.
|
|
177
|
+
*
|
|
178
|
+
* Creates a slot in the broker, then spawns the agent process with env vars
|
|
179
|
+
* so the agent's MCP adapter knows which session/slot to join.
|
|
180
|
+
* Ensures MCP configs are present so agents can communicate via the broker.
|
|
181
|
+
*/
|
|
182
|
+
export async function launchAgent(
|
|
183
|
+
sessionId: string,
|
|
184
|
+
projectDir: string,
|
|
185
|
+
config: AgentLaunchConfig,
|
|
186
|
+
brokerClient: BrokerClient,
|
|
187
|
+
): Promise<LaunchResult> {
|
|
188
|
+
// Ensure MCP configs and session file exist before spawning any agent
|
|
189
|
+
await ensureMcpConfigs(projectDir, sessionId);
|
|
190
|
+
|
|
191
|
+
// Create a slot in the broker for this agent
|
|
192
|
+
const slot = await brokerClient.createSlot({
|
|
193
|
+
session_id: sessionId,
|
|
194
|
+
agent_type: config.agent_type,
|
|
195
|
+
display_name: config.name,
|
|
196
|
+
role: config.role,
|
|
197
|
+
role_description: config.role_description,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
log(LOG_PREFIX, `Created slot ${slot.id} for ${config.name} (${config.agent_type})`);
|
|
201
|
+
|
|
202
|
+
// Assign file ownership if specified
|
|
203
|
+
if (config.file_ownership && config.file_ownership.length > 0) {
|
|
204
|
+
await brokerClient.assignOwnership({
|
|
205
|
+
session_id: sessionId,
|
|
206
|
+
slot_id: slot.id,
|
|
207
|
+
path_patterns: config.file_ownership,
|
|
208
|
+
assigned_by: "orchestrator",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Build the task prompt with role context
|
|
213
|
+
const taskPrompt = [
|
|
214
|
+
`You are "${config.name}", role: ${config.role}.`,
|
|
215
|
+
config.role_description,
|
|
216
|
+
"",
|
|
217
|
+
`Your task: ${config.initial_task}`,
|
|
218
|
+
].join("\n");
|
|
219
|
+
|
|
220
|
+
// Build CLI args based on agent type
|
|
221
|
+
const args = buildCliArgs(config.agent_type, taskPrompt);
|
|
222
|
+
const cmd = AGENT_COMMANDS[config.agent_type];
|
|
223
|
+
|
|
224
|
+
if (!cmd) {
|
|
225
|
+
throw new Error(`No CLI command configured for agent type: ${config.agent_type}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build env — unset CLAUDECODE to allow nested Claude sessions
|
|
229
|
+
const spawnEnv: Record<string, string | undefined> = {
|
|
230
|
+
...process.env,
|
|
231
|
+
MULTIAGENTS_SESSION: sessionId,
|
|
232
|
+
MULTIAGENTS_ROLE: config.role,
|
|
233
|
+
MULTIAGENTS_NAME: config.name,
|
|
234
|
+
MULTIAGENTS_SLOT: String(slot.id),
|
|
235
|
+
};
|
|
236
|
+
delete spawnEnv.CLAUDECODE;
|
|
237
|
+
|
|
238
|
+
// Spawn the agent process
|
|
239
|
+
// stdin MUST be "pipe" for MCP stdio transport (bidirectional JSON-RPC)
|
|
240
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
241
|
+
cwd: projectDir,
|
|
242
|
+
stdin: "pipe",
|
|
243
|
+
stdout: "pipe",
|
|
244
|
+
stderr: "pipe",
|
|
245
|
+
env: spawnEnv,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
log(LOG_PREFIX, `Launched ${config.name} (PID ${proc.pid}) in slot ${slot.id}`);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
slotId: slot.id,
|
|
252
|
+
pid: proc.pid,
|
|
253
|
+
process: proc,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build CLI arguments for a specific agent type.
|
|
259
|
+
*
|
|
260
|
+
* Key design: each agent gets the multiagents MCP server injected so it can
|
|
261
|
+
* communicate with teammates through the broker. For Claude this uses
|
|
262
|
+
* --mcp-config (inline JSON); for Codex this uses -c to override the
|
|
263
|
+
* mcp_servers config key (replacing any broken global entries).
|
|
264
|
+
*/
|
|
265
|
+
export function buildCliArgs(agentType: AgentType, task: string): string[] {
|
|
266
|
+
switch (agentType) {
|
|
267
|
+
case "claude": {
|
|
268
|
+
// Build inline MCP config JSON for --mcp-config
|
|
269
|
+
const claudeMcp = mcpServerCommand("claude");
|
|
270
|
+
const mcpConfigJson = JSON.stringify({
|
|
271
|
+
mcpServers: {
|
|
272
|
+
"multiagents": {
|
|
273
|
+
command: claudeMcp.command,
|
|
274
|
+
args: claudeMcp.args,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
return [
|
|
279
|
+
"--print",
|
|
280
|
+
"--verbose",
|
|
281
|
+
"--output-format", "stream-json",
|
|
282
|
+
"--max-turns", "200",
|
|
283
|
+
"--dangerously-skip-permissions",
|
|
284
|
+
"--mcp-config", mcpConfigJson,
|
|
285
|
+
"-p", task,
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
case "codex": {
|
|
289
|
+
// Inject multiagents MCP server via dotted-path config overrides.
|
|
290
|
+
//
|
|
291
|
+
// CRITICAL: Codex loads ALL MCP servers from ~/.codex/config.toml at
|
|
292
|
+
// startup. We must neutralize any global entries that would interfere:
|
|
293
|
+
//
|
|
294
|
+
// 1. Entries with `url` (like figma) are remote MCP — not supported
|
|
295
|
+
// in stdio mode, causes connection errors.
|
|
296
|
+
// 2. Entries with slow `npx` commands (like chrome-devtools) cause
|
|
297
|
+
// 10s+ timeouts during MCP handshake.
|
|
298
|
+
// 3. NEVER use `echo "disabled"` as a dummy — Codex reads stdout as
|
|
299
|
+
// JSON-RPC and "disabled" fails to parse, crashing the agent.
|
|
300
|
+
//
|
|
301
|
+
// The fix: read ~/.codex/config.toml, find all [mcp_servers.*] keys,
|
|
302
|
+
// and override each non-multiagents entry with noop-mcp.ts — a tiny
|
|
303
|
+
// MCP server that completes the JSON-RPC handshake instantly with zero
|
|
304
|
+
// tools, then stays alive. This avoids:
|
|
305
|
+
// - /usr/bin/true: exits immediately → 10s MCP handshake timeout
|
|
306
|
+
// - echo "disabled": stdout parsed as JSON-RPC → deserialization crash
|
|
307
|
+
const noopMcpPath = path.resolve(import.meta.dir, "..", "noop-mcp.ts");
|
|
308
|
+
const codexMcp = mcpServerCommand("codex");
|
|
309
|
+
const argsJson = JSON.stringify(codexMcp.args);
|
|
310
|
+
const overrides: string[] = [
|
|
311
|
+
// Our MCP server
|
|
312
|
+
`mcp_servers.multiagents.command="${codexMcp.command}"`,
|
|
313
|
+
`mcp_servers.multiagents.args=${argsJson}`,
|
|
314
|
+
'model_reasoning_effort="high"',
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
// Neutralize global MCP servers by reading config
|
|
318
|
+
const globalConfigPath = path.join(process.env.HOME ?? "", ".codex", "config.toml");
|
|
319
|
+
try {
|
|
320
|
+
const configText = fs.readFileSync(globalConfigPath, "utf-8");
|
|
321
|
+
// Find all [mcp_servers.XXX] section names
|
|
322
|
+
const mcpPattern = /\[mcp_servers\.(\w[\w-]*)\]/g;
|
|
323
|
+
let match;
|
|
324
|
+
while ((match = mcpPattern.exec(configText)) !== null) {
|
|
325
|
+
const serverName = match[1];
|
|
326
|
+
if (serverName !== "multiagents") {
|
|
327
|
+
// Override with noop-mcp.ts — instant handshake, zero tools
|
|
328
|
+
overrides.push(`mcp_servers.${serverName}.command="bun"`);
|
|
329
|
+
overrides.push(`mcp_servers.${serverName}.args=["${noopMcpPath}"]`);
|
|
330
|
+
// Clear url field if present (remote MCP entries)
|
|
331
|
+
overrides.push(`mcp_servers.${serverName}.url=""`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// No global config — nothing to neutralize
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const args: string[] = [
|
|
339
|
+
"exec",
|
|
340
|
+
"--sandbox", "workspace-write",
|
|
341
|
+
"--full-auto",
|
|
342
|
+
"--json",
|
|
343
|
+
];
|
|
344
|
+
for (const override of overrides) {
|
|
345
|
+
args.push("-c", override);
|
|
346
|
+
}
|
|
347
|
+
args.push(task);
|
|
348
|
+
return args;
|
|
349
|
+
}
|
|
350
|
+
case "gemini": {
|
|
351
|
+
// Gemini CLI is invoked via npx. MCP is configured at user level
|
|
352
|
+
// (~/.gemini/settings.json) by ensureMcpConfigs. Use --approval-mode
|
|
353
|
+
// yolo for fully autonomous operation, --sandbox for safety, and
|
|
354
|
+
// --output-format stream-json for structured output parsing.
|
|
355
|
+
return [
|
|
356
|
+
"-y", "@google/gemini-cli",
|
|
357
|
+
"--sandbox",
|
|
358
|
+
"--approval-mode", "yolo",
|
|
359
|
+
"--output-format", "stream-json",
|
|
360
|
+
"--allowed-mcp-server-names", "multiagents",
|
|
361
|
+
"-p", task,
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
case "custom":
|
|
365
|
+
return [task];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Build a team context string listing all active slots in the session,
|
|
371
|
+
* excluding the given slot. Used to orient a new or restarted agent.
|
|
372
|
+
*/
|
|
373
|
+
export async function buildTeamContext(
|
|
374
|
+
sessionId: string,
|
|
375
|
+
excludeSlotId: number,
|
|
376
|
+
brokerClient: BrokerClient,
|
|
377
|
+
): Promise<string> {
|
|
378
|
+
const slots = await brokerClient.listSlots(sessionId);
|
|
379
|
+
const teammates = slots.filter((s) => s.id !== excludeSlotId && s.status === "connected");
|
|
380
|
+
|
|
381
|
+
if (teammates.length === 0) {
|
|
382
|
+
return "You are the first agent on this team. No other agents are active yet.";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const lines = ["Current team members:"];
|
|
386
|
+
for (const s of teammates) {
|
|
387
|
+
const name = s.display_name ?? `Agent #${s.id}`;
|
|
388
|
+
const role = s.role ?? "unassigned";
|
|
389
|
+
const status = s.paused ? "paused" : "active";
|
|
390
|
+
lines.push(` - ${name} (${s.agent_type}, slot ${s.id}): role="${role}", status=${status}`);
|
|
391
|
+
}
|
|
392
|
+
return lines.join("\n");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Announce a new team member to all existing connected slots.
|
|
397
|
+
* Sends a team_change message so agents can update their mental model.
|
|
398
|
+
*/
|
|
399
|
+
export async function announceNewMember(
|
|
400
|
+
sessionId: string,
|
|
401
|
+
newSlot: Slot,
|
|
402
|
+
config: AgentLaunchConfig,
|
|
403
|
+
brokerClient: BrokerClient,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const slots = await brokerClient.listSlots(sessionId);
|
|
406
|
+
const peers = slots.filter((s) => s.id !== newSlot.id && s.status === "connected" && s.peer_id);
|
|
407
|
+
|
|
408
|
+
const announcement = [
|
|
409
|
+
`[Team Update] New member joined: "${config.name}"`,
|
|
410
|
+
` Role: ${config.role}`,
|
|
411
|
+
` Type: ${config.agent_type}`,
|
|
412
|
+
` Slot: ${newSlot.id}`,
|
|
413
|
+
config.file_ownership?.length
|
|
414
|
+
? ` File ownership: ${config.file_ownership.join(", ")}`
|
|
415
|
+
: "",
|
|
416
|
+
` Task: ${config.initial_task}`,
|
|
417
|
+
]
|
|
418
|
+
.filter(Boolean)
|
|
419
|
+
.join("\n");
|
|
420
|
+
|
|
421
|
+
for (const peer of peers) {
|
|
422
|
+
if (!peer.peer_id) continue;
|
|
423
|
+
await brokerClient.sendMessage({
|
|
424
|
+
from_id: "orchestrator",
|
|
425
|
+
to_id: peer.peer_id,
|
|
426
|
+
text: announcement,
|
|
427
|
+
msg_type: "team_change",
|
|
428
|
+
session_id: sessionId,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
log(LOG_PREFIX, `Announced ${config.name} to ${peers.length} existing agents`);
|
|
433
|
+
}
|