claude-code-swarm 0.3.10 → 0.3.11

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Launch Claude Code with swarmkit capabilities, including team orchestration, MAP observability, and session tracking.",
5
5
  "owner": {
6
6
  "name": "alexngai"
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unified MCP server launcher for claude-code-swarm plugin.
4
+ *
5
+ * Usage: node mcp-launcher.mjs <server>
6
+ * server: opentasks | minimem | agent-inbox
7
+ *
8
+ * Reads .swarm/claude-swarm/config.json once, checks enablement,
9
+ * resolves CLI args, and exec's the real server or falls back to noop.
10
+ */
11
+
12
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
13
+ import { execFileSync, execSync } from 'node:child_process';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createInterface } from 'node:readline';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const server = process.argv[2];
20
+
21
+ if (!server) {
22
+ process.stderr.write('Usage: mcp-launcher.mjs <opentasks|minimem|agent-inbox>\n');
23
+ process.exit(1);
24
+ }
25
+
26
+ // --- Config ---
27
+
28
+ function readConfig() {
29
+ const configPath = '.swarm/claude-swarm/config.json';
30
+ try {
31
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function envBool(key) {
38
+ const v = (process.env[key] || '').toLowerCase();
39
+ return ['true', '1', 'yes'].includes(v);
40
+ }
41
+
42
+ function env(key) {
43
+ return process.env[key] || '';
44
+ }
45
+
46
+ const config = readConfig();
47
+
48
+ // --- Noop ---
49
+
50
+ function runNoop() {
51
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
52
+ rl.on('line', (line) => {
53
+ line = line.trim();
54
+ if (!line) return;
55
+ let msg;
56
+ try { msg = JSON.parse(line); } catch { return; }
57
+ const respond = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\n');
58
+ if (msg.method === 'initialize') {
59
+ respond({ protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'noop-mcp', version: '1.0.0' } });
60
+ } else if (msg.method === 'tools/list') {
61
+ respond({ tools: [] });
62
+ } else if (msg.method === 'resources/list') {
63
+ respond({ resources: [] });
64
+ } else if (msg.method === 'prompts/list') {
65
+ respond({ prompts: [] });
66
+ } else if (msg.id !== undefined && !msg.method?.startsWith('notifications/')) {
67
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found' } }) + '\n');
68
+ }
69
+ });
70
+ rl.on('close', () => process.exit(0));
71
+ }
72
+
73
+ // --- CLI resolution ---
74
+
75
+ function which(cmd) {
76
+ try {
77
+ return execFileSync('which', [cmd], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function findSocket(paths) {
84
+ for (const p of paths) {
85
+ try {
86
+ if (existsSync(p) && statSync(p).isSocket?.()) return p;
87
+ } catch {}
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // --- Server definitions ---
93
+
94
+ const servers = {
95
+ opentasks: {
96
+ enabled: () => envBool('SWARM_OPENTASKS_ENABLED') || config.opentasks?.enabled === true,
97
+ launch: () => {
98
+ const scope = env('SWARM_OPENTASKS_SCOPE') || config.opentasks?.scope || 'tasks';
99
+ const socket = findSocket([
100
+ '.swarm/opentasks/daemon.sock',
101
+ '.opentasks/daemon.sock',
102
+ '.git/opentasks/daemon.sock',
103
+ ]);
104
+ const args = ['mcp', '--scope', scope];
105
+ if (socket) args.push('--socket', socket);
106
+ return { cmd: 'opentasks', args };
107
+ },
108
+ },
109
+
110
+ minimem: {
111
+ enabled: () => envBool('SWARM_MINIMEM_ENABLED') || config.minimem?.enabled === true,
112
+ launch: () => {
113
+ const provider = env('SWARM_MINIMEM_PROVIDER') || config.minimem?.provider || 'auto';
114
+ let dir = env('SWARM_MINIMEM_DIR') || config.minimem?.dir || '';
115
+ if (!dir) {
116
+ dir = existsSync('.swarm/minimem') ? '.swarm/minimem' : '.';
117
+ }
118
+ const useGlobal = envBool('SWARM_MINIMEM_GLOBAL') || config.minimem?.global === true;
119
+ const args = ['mcp', '--dir', dir, '--provider', provider];
120
+ if (useGlobal) args.push('--global');
121
+ return { cmd: 'minimem', args };
122
+ },
123
+ },
124
+
125
+ 'agent-inbox': {
126
+ enabled: () => envBool('SWARM_INBOX_ENABLED') || config.inbox?.enabled === true,
127
+ launch: () => {
128
+ const scope = env('SWARM_INBOX_SCOPE') || config.inbox?.scope || config.map?.scope || 'default';
129
+ process.env.INBOX_SCOPE = scope;
130
+
131
+ // Discover sidecar inbox socket for proxy mode
132
+ let inboxSock = findSocket(['.swarm/claude-swarm/tmp/map/inbox.sock']);
133
+ if (!inboxSock) {
134
+ const sessDir = '.swarm/claude-swarm/tmp/map/sessions';
135
+ try {
136
+ if (existsSync(sessDir)) {
137
+ for (const d of readdirSync(sessDir)) {
138
+ const sock = join(sessDir, d, 'inbox.sock');
139
+ if (existsSync(sock)) { inboxSock = sock; break; }
140
+ }
141
+ }
142
+ } catch {}
143
+ }
144
+ if (inboxSock) {
145
+ process.env.INBOX_SOCKET_PATH = inboxSock;
146
+ }
147
+
148
+ return { cmd: 'agent-inbox', args: ['mcp'] };
149
+ },
150
+ },
151
+ };
152
+
153
+ // --- Main ---
154
+
155
+ const def = servers[server];
156
+ if (!def) {
157
+ process.stderr.write(`Unknown server: ${server}\n`);
158
+ process.exit(1);
159
+ }
160
+
161
+ if (!def.enabled()) {
162
+ runNoop();
163
+ } else {
164
+ const { cmd, args } = def.launch();
165
+ if (which(cmd)) {
166
+ // Replace this process with the real server
167
+ const { execFileSync: _ , ...rest } = await import('node:child_process');
168
+ const child = (await import('node:child_process')).spawn(cmd, args, {
169
+ stdio: 'inherit',
170
+ });
171
+ child.on('exit', (code) => process.exit(code ?? 0));
172
+ } else {
173
+ process.stderr.write(`[${server}-mcp] ${cmd} CLI not found\n`);
174
+ runNoop();
175
+ }
176
+ }
@@ -1,26 +1,35 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
3
  "description": "Spin up Claude Code agent teams from openteams YAML topologies with optional MAP (Multi-Agent Protocol) observability and coordination. Provides hooks for session lifecycle, agent spawn/complete tracking, and a /swarm skill to launch team configurations.",
4
- "version": "0.3.10",
4
+ "version": "0.3.11",
5
5
  "author": {
6
6
  "name": "alexngai"
7
7
  },
8
8
  "mcpServers": {
9
9
  "opentasks": {
10
- "command": "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/run-opentasks-mcp.sh",
11
- "args": [],
10
+ "command": "node",
11
+ "args": [
12
+ "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
13
+ "opentasks"
14
+ ],
12
15
  "env": {
13
16
  "OPENTASKS_WORKING_DIR": "${workspaceFolder}"
14
17
  }
15
18
  },
16
19
  "agent-inbox": {
17
- "command": "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/run-agent-inbox-mcp.sh",
18
- "args": [],
20
+ "command": "node",
21
+ "args": [
22
+ "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
23
+ "agent-inbox"
24
+ ],
19
25
  "env": {}
20
26
  },
21
27
  "minimem": {
22
- "command": "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/run-minimem-mcp.sh",
23
- "args": [],
28
+ "command": "node",
29
+ "args": [
30
+ "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
31
+ "minimem"
32
+ ],
24
33
  "env": {
25
34
  "MINIMEM_WORKING_DIR": "${workspaceFolder}"
26
35
  }
package/CLAUDE.md CHANGED
@@ -461,6 +461,16 @@ Runtime:
461
461
  - **Claude Code agent teams** — enabled via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` in settings.json
462
462
 
463
463
  ## Development notes
464
+
465
+ ### Debugging with logs
466
+ When logging is enabled, per-session log files (JSON Lines format) are written to:
467
+ - **Default location**: `~/.claude-swarm/tmp/logs/<timestamp>_<sessionId>.log`
468
+ - **Custom location**: set `log.dir` in config or `SWARM_LOG_DIR` env var
469
+ - **Explicit file**: set `log.file` in config or `SWARM_LOG_FILE` env var
470
+
471
+ To enable verbose logging: `SWARM_LOG_LEVEL=debug claude` (or `info` for lifecycle events only). The default level is `warn`. Each session may produce multiple log files (e.g., one from bootstrap, one from the sidecar) sharing the same session ID in the filename.
472
+
473
+ ### General notes
464
474
  - Templates are provided by the openteams package (installed via swarmkit), not bundled with the plugin
465
475
  - `.swarm/` directory is managed by swarmkit for ecosystem packages (openteams, sessionlog) via `initProjectPackage()`
466
476
  - Plugin-specific state lives under `.swarm/claude-swarm/` (config, `.gitignore` ignoring `tmp/`). Runtime artifacts go in `.swarm/claude-swarm/tmp/` (per-template caches, MAP files)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Claude Code plugin for launching agent teams from openteams topologies with MAP observability",
5
5
  "type": "module",
6
6
  "exports": {
@@ -45,9 +45,6 @@
45
45
  "test:e2e:tier4": "vitest run --config e2e/vitest.config.e2e.mjs -t tier4",
46
46
  "test:e2e:tier5": "vitest run --config e2e/vitest.config.e2e.mjs -t tier5",
47
47
  "test:e2e:tier7": "vitest run --config e2e/vitest.config.e2e.mjs -t tier7",
48
- "dev:link": "node scripts/dev-link.mjs link",
49
- "dev:unlink": "node scripts/dev-link.mjs unlink",
50
- "dev:status": "node scripts/dev-link.mjs status",
51
48
  "version:patch": "npm version patch --no-git-tag-version && node scripts/sync-version.mjs",
52
49
  "version:minor": "npm version minor --no-git-tag-version && node scripts/sync-version.mjs",
53
50
  "version:major": "npm version major --no-git-tag-version && node scripts/sync-version.mjs"
@@ -26,7 +26,7 @@ import { createMeshPeer, createMeshInbox } from "../src/mesh-connection.mjs";
26
26
  import { createSocketServer, createCommandHandler } from "../src/sidecar-server.mjs";
27
27
  import { readConfig } from "../src/config.mjs";
28
28
  import { createLogger, init as initLog } from "../src/log.mjs";
29
- import { configureNodePath } from "../src/swarmkit-resolver.mjs";
29
+ import { configureNodePath, resolvePackage } from "../src/swarmkit-resolver.mjs";
30
30
 
31
31
  const log = createLogger("sidecar");
32
32
 
@@ -329,7 +329,9 @@ async function startLegacyAgentInbox(mapConnection) {
329
329
  if (!INBOX_CONFIG) return null;
330
330
 
331
331
  try {
332
- const { createAgentInbox } = await import("agent-inbox");
332
+ const agentInboxMod = await resolvePackage("agent-inbox");
333
+ if (!agentInboxMod) throw new Error("agent-inbox not available");
334
+ const { createAgentInbox } = agentInboxMod;
333
335
 
334
336
  const peers = INBOX_CONFIG.federation?.peers || [];
335
337
  const federationConfig = peers.length > 0
@@ -16,6 +16,10 @@ vi.mock("../sidecar-client.mjs", () => ({
16
16
  sendToInbox: vi.fn().mockResolvedValue(undefined),
17
17
  }));
18
18
 
19
+ vi.mock("../map-events.mjs", () => ({
20
+ sendCommand: vi.fn().mockResolvedValue(undefined),
21
+ }));
22
+
19
23
  vi.mock("../sessionlog.mjs", () => ({
20
24
  checkSessionlogStatus: vi.fn(() => "not installed"),
21
25
  syncSessionlog: vi.fn().mockResolvedValue(undefined),
@@ -81,6 +85,7 @@ vi.mock("../swarmkit-resolver.mjs", () => ({
81
85
  const { bootstrap, backgroundInit } = await import("../bootstrap.mjs");
82
86
  const { readConfig } = await import("../config.mjs");
83
87
  const { killSidecar, startSidecar } = await import("../sidecar-client.mjs");
88
+ const { sendCommand } = await import("../map-events.mjs");
84
89
  const { checkSessionlogStatus, syncSessionlog, annotateSwarmSession } = await import("../sessionlog.mjs");
85
90
  const { pluginDir, ensureOpentasksDir, ensureSessionDir, listSessionDirs } = await import("../paths.mjs");
86
91
  const { findSocketPath, isDaemonAlive, ensureDaemon } = await import("../opentasks-client.mjs");
@@ -398,6 +403,47 @@ describe("bootstrap", () => {
398
403
  "session-xyz"
399
404
  );
400
405
  });
406
+
407
+ it("registers main agent via spawn command after sidecar starts", async () => {
408
+ startSidecar.mockResolvedValue(true);
409
+ const config = makeConfig({ mapEnabled: true, sidecar: "session" });
410
+ await backgroundInit(config, "swarm:test", pluginDir(), "session-main");
411
+ // Allow fire-and-forget promise to settle
412
+ await new Promise((r) => setTimeout(r, 50));
413
+ expect(sendCommand).toHaveBeenCalledWith(
414
+ expect.anything(),
415
+ expect.objectContaining({
416
+ action: "spawn",
417
+ agent: expect.objectContaining({
418
+ agentId: "session-main",
419
+ name: "test-team-main",
420
+ role: "orchestrator",
421
+ metadata: expect.objectContaining({ isMain: true, sessionId: "session-main" }),
422
+ }),
423
+ }),
424
+ "session-main"
425
+ );
426
+ });
427
+
428
+ it("uses sessionId as agentId for main agent", async () => {
429
+ startSidecar.mockResolvedValue(true);
430
+ const config = makeConfig({ mapEnabled: true, sidecar: "session" });
431
+ await backgroundInit(config, "swarm:test", pluginDir(), "abc-123-def");
432
+ await new Promise((r) => setTimeout(r, 50));
433
+ const spawnCall = sendCommand.mock.calls.find(
434
+ (c) => c[1]?.action === "spawn"
435
+ );
436
+ expect(spawnCall).toBeDefined();
437
+ expect(spawnCall[1].agent.agentId).toBe("abc-123-def");
438
+ });
439
+
440
+ it("does not register main agent when sidecar fails to start", async () => {
441
+ startSidecar.mockResolvedValue(false);
442
+ const config = makeConfig({ mapEnabled: true, sidecar: "session" });
443
+ await backgroundInit(config, "swarm:test", pluginDir(), "session-fail");
444
+ await new Promise((r) => setTimeout(r, 50));
445
+ expect(sendCommand).not.toHaveBeenCalled();
446
+ });
401
447
  });
402
448
 
403
449
  describe("sessionlog sync", () => {
@@ -0,0 +1,229 @@
1
+ /**
2
+ * E2E test: Main agent registration on bootstrap
3
+ *
4
+ * Verifies that when the sidecar starts successfully, the bootstrap
5
+ * sends a spawn command to register the main Claude Code session agent.
6
+ *
7
+ * Uses a real TestServer + AgentConnection (no mocks on MAP layer).
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
11
+ import net from "net";
12
+ import path from "path";
13
+ import { createStreamPair, AgentConnection } from "@multi-agent-protocol/sdk";
14
+ import { TestServer } from "@multi-agent-protocol/sdk/testing";
15
+ import { createSocketServer, createCommandHandler } from "../sidecar-server.mjs";
16
+ import { makeTmpDir, cleanupTmpDir } from "./helpers.mjs";
17
+
18
+ // ── Helpers ──────────────────────────────────────────────────────────────────
19
+
20
+ function sendSocketCommand(socketPath, command) {
21
+ return new Promise((resolve, reject) => {
22
+ const client = net.createConnection(socketPath);
23
+ let data = "";
24
+ client.on("connect", () => {
25
+ client.write(JSON.stringify(command) + "\n");
26
+ });
27
+ client.on("data", (chunk) => {
28
+ data += chunk.toString();
29
+ try {
30
+ const parsed = JSON.parse(data.trim().split("\n").pop());
31
+ client.destroy();
32
+ resolve(parsed);
33
+ } catch {
34
+ // wait for more data
35
+ }
36
+ });
37
+ client.on("error", reject);
38
+ setTimeout(() => {
39
+ client.destroy();
40
+ try {
41
+ resolve(JSON.parse(data.trim().split("\n").pop()));
42
+ } catch {
43
+ reject(new Error("Timeout waiting for response"));
44
+ }
45
+ }, 3000);
46
+ });
47
+ }
48
+
49
+ async function createLiveMapConnection(agentName = "sidecar-agent") {
50
+ const server = new TestServer({ name: "test-map-server" });
51
+ const [clientStream, serverStream] = createStreamPair();
52
+ server.acceptConnection(serverStream);
53
+
54
+ const conn = new AgentConnection(clientStream, {
55
+ name: agentName,
56
+ role: "sidecar",
57
+ });
58
+
59
+ await conn.connect();
60
+
61
+ return { server, conn };
62
+ }
63
+
64
+ // ── Tests ────────────────────────────────────────────────────────────────────
65
+
66
+ describe("Main agent registration via spawn", () => {
67
+ let tmpDir;
68
+ let socketPath;
69
+ let socketServer;
70
+ let mapServer;
71
+ let conn;
72
+ let registeredAgents;
73
+
74
+ const SCOPE = "swarm:test-team";
75
+ const SESSION_ID = "test-session-abc-123";
76
+
77
+ beforeEach(async () => {
78
+ tmpDir = makeTmpDir("e2e-main-agent-");
79
+ socketPath = path.join(tmpDir, "sidecar.sock");
80
+
81
+ const live = await createLiveMapConnection("test-team-sidecar");
82
+ mapServer = live.server;
83
+ conn = live.conn;
84
+
85
+ registeredAgents = new Map();
86
+ const handler = createCommandHandler(conn, SCOPE, registeredAgents);
87
+ socketServer = createSocketServer(socketPath, handler);
88
+ await new Promise((resolve) => socketServer.on("listening", resolve));
89
+ });
90
+
91
+ afterEach(async () => {
92
+ if (socketServer) {
93
+ await new Promise((resolve) => socketServer.close(resolve));
94
+ socketServer = null;
95
+ }
96
+ if (conn && conn.isConnected) {
97
+ try { await conn.disconnect(); } catch { /* ignore */ }
98
+ }
99
+ cleanupTmpDir(tmpDir);
100
+ });
101
+
102
+ it("registers main agent with sessionId as agentId", async () => {
103
+ // This is the exact command bootstrap sends after sidecar starts
104
+ const resp = await sendSocketCommand(socketPath, {
105
+ action: "spawn",
106
+ agent: {
107
+ agentId: SESSION_ID,
108
+ name: "test-team-main",
109
+ role: "orchestrator",
110
+ scopes: [SCOPE],
111
+ metadata: { isMain: true, sessionId: SESSION_ID },
112
+ },
113
+ });
114
+
115
+ expect(resp.ok).toBe(true);
116
+ expect(resp.agent?.agent?.id).toBe(SESSION_ID);
117
+
118
+ // Verify in TestServer
119
+ const mainAgent = mapServer.agents.get(SESSION_ID);
120
+ expect(mainAgent).toBeDefined();
121
+ expect(mainAgent.name).toBe("test-team-main");
122
+ expect(mainAgent.role).toBe("orchestrator");
123
+
124
+ // Verify in local tracking
125
+ expect(registeredAgents.has(SESSION_ID)).toBe(true);
126
+ expect(registeredAgents.get(SESSION_ID).role).toBe("orchestrator");
127
+ expect(registeredAgents.get(SESSION_ID).metadata.isMain).toBe(true);
128
+ });
129
+
130
+ it("main agent coexists with spawned subagents", async () => {
131
+ // Register main agent
132
+ await sendSocketCommand(socketPath, {
133
+ action: "spawn",
134
+ agent: {
135
+ agentId: SESSION_ID,
136
+ name: "test-team-main",
137
+ role: "orchestrator",
138
+ scopes: [SCOPE],
139
+ metadata: { isMain: true, sessionId: SESSION_ID },
140
+ },
141
+ });
142
+
143
+ // Spawn a subagent (like SubagentStart hook would)
144
+ const subResp = await sendSocketCommand(socketPath, {
145
+ action: "spawn",
146
+ agent: {
147
+ agentId: "subagent-xyz-456",
148
+ name: "Explore",
149
+ role: "subagent",
150
+ scopes: [SCOPE],
151
+ metadata: { agentType: "Explore", sessionId: "subagent-xyz-456" },
152
+ },
153
+ });
154
+
155
+ expect(subResp.ok).toBe(true);
156
+
157
+ // Both should exist in TestServer
158
+ expect(mapServer.agents.has(SESSION_ID)).toBe(true);
159
+ expect(mapServer.agents.has("subagent-xyz-456")).toBe(true);
160
+
161
+ // Both in local tracking
162
+ expect(registeredAgents.size).toBe(2);
163
+ expect(registeredAgents.get(SESSION_ID).role).toBe("orchestrator");
164
+ expect(registeredAgents.get("subagent-xyz-456").role).toBe("subagent");
165
+ });
166
+
167
+ it("main agent survives subagent lifecycle", async () => {
168
+ // Register main
169
+ await sendSocketCommand(socketPath, {
170
+ action: "spawn",
171
+ agent: {
172
+ agentId: SESSION_ID,
173
+ name: "test-team-main",
174
+ role: "orchestrator",
175
+ scopes: [SCOPE],
176
+ metadata: { isMain: true, sessionId: SESSION_ID },
177
+ },
178
+ });
179
+
180
+ // Spawn and kill a subagent
181
+ await sendSocketCommand(socketPath, {
182
+ action: "spawn",
183
+ agent: {
184
+ agentId: "ephemeral-sub",
185
+ name: "Explore",
186
+ role: "subagent",
187
+ scopes: [SCOPE],
188
+ metadata: {},
189
+ },
190
+ });
191
+
192
+ await sendSocketCommand(socketPath, {
193
+ action: "done",
194
+ agentId: "ephemeral-sub",
195
+ reason: "completed",
196
+ });
197
+
198
+ // Main agent should still be registered
199
+ expect(registeredAgents.has(SESSION_ID)).toBe(true);
200
+ expect(registeredAgents.has("ephemeral-sub")).toBe(false);
201
+ expect(mapServer.agents.has(SESSION_ID)).toBe(true);
202
+ });
203
+
204
+ it("main agent appears in TestServer event history as agent_registered", async () => {
205
+ await sendSocketCommand(socketPath, {
206
+ action: "spawn",
207
+ agent: {
208
+ agentId: SESSION_ID,
209
+ name: "test-team-main",
210
+ role: "orchestrator",
211
+ scopes: [SCOPE],
212
+ metadata: { isMain: true, sessionId: SESSION_ID },
213
+ },
214
+ });
215
+
216
+ const registeredEvents = mapServer.eventHistory.filter(
217
+ (e) => e.event.type === "agent_registered"
218
+ );
219
+
220
+ // At least sidecar + main agent
221
+ expect(registeredEvents.length).toBeGreaterThanOrEqual(2);
222
+
223
+ const mainEvent = registeredEvents.find(
224
+ (e) => e.event.data?.name === "test-team-main" ||
225
+ e.event.data?.agentId === SESSION_ID
226
+ );
227
+ expect(mainEvent).toBeDefined();
228
+ });
229
+ });
@@ -102,7 +102,7 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
102
102
  socketPath = path.join(tmpDir, "sidecar.sock");
103
103
  mockConn = createMockMapConnection();
104
104
  registeredAgents = new Map();
105
- handler = createCommandHandler(mockConn, SCOPE, registeredAgents);
105
+ handler = createCommandHandler(mockConn, SCOPE, registeredAgents, { connWaitTimeoutMs: 500 });
106
106
  server = createSocketServer(socketPath, handler);
107
107
  await new Promise((resolve) => server.on("listening", resolve));
108
108
  });
@@ -191,7 +191,7 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
191
191
  expect(mockConn.send).not.toHaveBeenCalled();
192
192
  });
193
193
 
194
- it("spawn responds ok:false with error when connection is null", async () => {
194
+ it("spawn responds ok:false with error when connection is null (after wait timeout)", async () => {
195
195
  handler.setConnection(null);
196
196
 
197
197
  const resp = await sendSocketCommand(socketPath, {
@@ -206,10 +206,10 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
206
206
  });
207
207
 
208
208
  expect(resp.ok).toBe(false);
209
- expect(resp.error).toBe("no connection");
209
+ expect(resp.error).toContain("timed out waiting");
210
210
  });
211
211
 
212
- it("trajectory-checkpoint responds ok:false when connection is null", async () => {
212
+ it("trajectory-checkpoint responds ok:false when connection is null (after wait timeout)", async () => {
213
213
  handler.setConnection(null);
214
214
 
215
215
  const resp = await sendSocketCommand(socketPath, {
@@ -218,7 +218,7 @@ describe("E2E: sidecar reconnection after MAP connection loss", () => {
218
218
  });
219
219
 
220
220
  expect(resp.ok).toBe(false);
221
- expect(resp.error).toBe("no connection");
221
+ expect(resp.error).toContain("timed out waiting");
222
222
  });
223
223
 
224
224
  it("ping still works with null connection", async () => {
@@ -168,7 +168,7 @@ describe("sidecar-server", () => {
168
168
  });
169
169
 
170
170
  it("responds {ok: false} when no connection", async () => {
171
- const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents);
171
+ const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents, { connWaitTimeoutMs: 0 });
172
172
  await nullHandler({
173
173
  action: "spawn",
174
174
  agent: { agentId: "a", name: "a", role: "r", scopes: [], metadata: {} },
@@ -240,7 +240,7 @@ describe("sidecar-server", () => {
240
240
  });
241
241
 
242
242
  it("responds {ok: false} when no connection", async () => {
243
- const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents);
243
+ const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents, { connWaitTimeoutMs: 0 });
244
244
  await nullHandler({ action: "trajectory-checkpoint", checkpoint: {} }, mockClient);
245
245
  const written = JSON.parse(mockClient.write.mock.calls[0][0]);
246
246
  expect(written.ok).toBe(false);
@@ -264,7 +264,7 @@ describe("sidecar-server", () => {
264
264
  });
265
265
 
266
266
  it("responds {ok: true} even without connection", async () => {
267
- const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents);
267
+ const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents, { connWaitTimeoutMs: 0 });
268
268
  await nullHandler({
269
269
  action: "bridge-task-created",
270
270
  task: { id: "t-1" },
@@ -360,7 +360,7 @@ describe("sidecar-server", () => {
360
360
  });
361
361
 
362
362
  it("responds {ok: true} without connection", async () => {
363
- const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents);
363
+ const nullHandler = createCommandHandler(null, "swarm:test", registeredAgents, { connWaitTimeoutMs: 0 });
364
364
  await nullHandler({
365
365
  action: "bridge-task-assigned",
366
366
  taskId: "t-1",