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,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
+ }