claude-code-swarm 0.3.9 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/mcp-launcher.mjs +176 -0
- package/.claude-plugin/plugin.json +16 -7
- package/CLAUDE.md +10 -0
- package/package.json +1 -5
- package/scripts/map-sidecar.mjs +4 -2
- package/src/__tests__/bootstrap.test.mjs +46 -0
- package/src/__tests__/e2e-main-agent-registration.test.mjs +229 -0
- package/src/__tests__/e2e-reconnection.test.mjs +5 -5
- package/src/__tests__/sidecar-server.test.mjs +4 -4
- package/src/__tests__/swarmkit-resolver.test.mjs +168 -0
- package/src/bootstrap.mjs +14 -0
- package/src/map-connection.mjs +10 -3
- package/src/mesh-connection.mjs +10 -3
- package/src/sessionlog.mjs +4 -1
- package/src/sidecar-server.mjs +63 -25
- package/src/skilltree-client.mjs +7 -31
- package/src/swarmkit-resolver.mjs +48 -0
- package/.claude-plugin/run-agent-inbox-mcp.sh +0 -95
- package/.claude-plugin/run-minimem-mcp.sh +0 -98
- package/.claude-plugin/run-opentasks-mcp.sh +0 -65
- package/scripts/dev-link.mjs +0 -179
|
@@ -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.
|
|
4
|
+
"version": "0.3.11",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "alexngai"
|
|
7
7
|
},
|
|
8
8
|
"mcpServers": {
|
|
9
9
|
"opentasks": {
|
|
10
|
-
"command": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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"
|
|
@@ -56,7 +53,6 @@
|
|
|
56
53
|
"node": ">=18.0.0"
|
|
57
54
|
},
|
|
58
55
|
"devDependencies": {
|
|
59
|
-
"@multi-agent-protocol/sdk": "^0.1.4",
|
|
60
56
|
"agent-inbox": "^0.1.9",
|
|
61
57
|
"minimem": "^0.1.0",
|
|
62
58
|
"opentasks": "^0.0.6",
|
package/scripts/map-sidecar.mjs
CHANGED
|
@@ -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
|
|
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).
|
|
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).
|
|
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",
|