claude-code-swarm 0.3.10 → 0.3.12

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.12",
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,29 +1,34 @@
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.12",
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": [],
12
- "env": {
13
- "OPENTASKS_WORKING_DIR": "${workspaceFolder}"
14
- }
10
+ "command": "node",
11
+ "args": [
12
+ "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
13
+ "opentasks"
14
+ ],
15
+ "env": {}
15
16
  },
16
17
  "agent-inbox": {
17
- "command": "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/run-agent-inbox-mcp.sh",
18
- "args": [],
18
+ "command": "node",
19
+ "args": [
20
+ "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
21
+ "agent-inbox"
22
+ ],
19
23
  "env": {}
20
24
  },
21
25
  "minimem": {
22
- "command": "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/run-minimem-mcp.sh",
23
- "args": [],
24
- "env": {
25
- "MINIMEM_WORKING_DIR": "${workspaceFolder}"
26
- }
26
+ "command": "node",
27
+ "args": [
28
+ "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
29
+ "minimem"
30
+ ],
31
+ "env": {}
27
32
  }
28
33
  }
29
34
  }
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/hooks/hooks.json CHANGED
@@ -7,6 +7,21 @@
7
7
  {
8
8
  "type": "command",
9
9
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\""
10
+ },
11
+ {
12
+ "type": "command",
13
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch session-start; fi"
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ "SessionEnd": [
19
+ {
20
+ "matcher": "",
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch session-end; fi"
10
25
  }
11
26
  ]
12
27
  }
@@ -18,6 +33,21 @@
18
33
  {
19
34
  "type": "command",
20
35
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8')).map||{};process.exit(c.enabled||c.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" inject; fi"
36
+ },
37
+ {
38
+ "type": "command",
39
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch user-prompt-submit; fi"
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "PreToolUse": [
45
+ {
46
+ "matcher": "Task",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch pre-task; fi"
21
51
  }
22
52
  ]
23
53
  }
@@ -32,12 +62,25 @@
32
62
  }
33
63
  ]
34
64
  },
65
+ {
66
+ "matcher": "Task",
67
+ "hooks": [
68
+ {
69
+ "type": "command",
70
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-task; fi"
71
+ }
72
+ ]
73
+ },
35
74
  {
36
75
  "matcher": "TaskCreate",
37
76
  "hooks": [
38
77
  {
39
78
  "type": "command",
40
79
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8')).map||{};process.exit(c.enabled||c.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" native-task-created; fi"
80
+ },
81
+ {
82
+ "type": "command",
83
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-task-create; fi"
41
84
  }
42
85
  ]
43
86
  },
@@ -47,6 +90,46 @@
47
90
  {
48
91
  "type": "command",
49
92
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8')).map||{};process.exit(c.enabled||c.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" native-task-updated; fi"
93
+ },
94
+ {
95
+ "type": "command",
96
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-task-update; fi"
97
+ }
98
+ ]
99
+ },
100
+ {
101
+ "matcher": "TodoWrite",
102
+ "hooks": [
103
+ {
104
+ "type": "command",
105
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-todo; fi"
106
+ }
107
+ ]
108
+ },
109
+ {
110
+ "matcher": "EnterPlanMode",
111
+ "hooks": [
112
+ {
113
+ "type": "command",
114
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-plan-enter; fi"
115
+ }
116
+ ]
117
+ },
118
+ {
119
+ "matcher": "ExitPlanMode",
120
+ "hooks": [
121
+ {
122
+ "type": "command",
123
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-plan-exit; fi"
124
+ }
125
+ ]
126
+ },
127
+ {
128
+ "matcher": "Skill",
129
+ "hooks": [
130
+ {
131
+ "type": "command",
132
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-skill; fi"
50
133
  }
51
134
  ]
52
135
  }
@@ -62,6 +145,10 @@
62
145
  {
63
146
  "type": "command",
64
147
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));const m=c.map||{};process.exit((m.enabled||m.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED)&&c.sessionlog?.sync&&c.sessionlog.sync!=='off'?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-sync; fi"
148
+ },
149
+ {
150
+ "type": "command",
151
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch stop; fi"
65
152
  }
66
153
  ]
67
154
  }
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.12",
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"
@@ -19,6 +19,7 @@
19
19
  * teammate-idle — Update teammate state to idle
20
20
  * task-completed — Complete task in opentasks + emit bridge event
21
21
  * opentasks-mcp-used — Bridge opentasks MCP tool use → MAP task sync payload
22
+ * sessionlog-dispatch — Dispatch a sessionlog lifecycle hook via programmatic API
22
23
  *
23
24
  * Usage: node map-hook.mjs <action>
24
25
  * Hook event data is read from stdin (JSON).
@@ -48,7 +49,7 @@ import {
48
49
  handleNativeTaskCreatedEvent,
49
50
  handleNativeTaskUpdatedEvent,
50
51
  } from "../src/map-events.mjs";
51
- import { syncSessionlog } from "../src/sessionlog.mjs";
52
+ import { syncSessionlog, dispatchSessionlogHook } from "../src/sessionlog.mjs";
52
53
  import { findSocketPath, pushSyncEvent } from "../src/opentasks-client.mjs";
53
54
 
54
55
  const action = process.argv[2];
@@ -184,6 +185,15 @@ async function handleNativeTaskUpdated(hookData, sessionId) {
184
185
  await handleNativeTaskUpdatedEvent(config, hookData, sessionId);
185
186
  }
186
187
 
188
+ async function handleSessionlogDispatch(hookData) {
189
+ const sessionlogHookName = process.argv[3];
190
+ if (!sessionlogHookName) {
191
+ log.warn("sessionlog-dispatch: missing hook name argument");
192
+ return;
193
+ }
194
+ await dispatchSessionlogHook(sessionlogHookName, hookData);
195
+ }
196
+
187
197
  // ── Main ──────────────────────────────────────────────────────────────────────
188
198
 
189
199
  async function main() {
@@ -204,6 +214,7 @@ async function main() {
204
214
  case "opentasks-mcp-used": await handleOpentasksMcpUsed(hookData, sessionId); break;
205
215
  case "native-task-created": await handleNativeTaskCreated(hookData, sessionId); break;
206
216
  case "native-task-updated": await handleNativeTaskUpdated(hookData, sessionId); break;
217
+ case "sessionlog-dispatch": await handleSessionlogDispatch(hookData); break;
207
218
  default:
208
219
  log.warn("unknown action", { action });
209
220
  }
@@ -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,8 +16,14 @@ 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"),
25
+ ensureSessionlogEnabled: vi.fn().mockResolvedValue(false),
26
+ hasStandaloneHooks: vi.fn().mockReturnValue(false),
21
27
  syncSessionlog: vi.fn().mockResolvedValue(undefined),
22
28
  annotateSwarmSession: vi.fn().mockResolvedValue(undefined),
23
29
  }));
@@ -81,7 +87,8 @@ vi.mock("../swarmkit-resolver.mjs", () => ({
81
87
  const { bootstrap, backgroundInit } = await import("../bootstrap.mjs");
82
88
  const { readConfig } = await import("../config.mjs");
83
89
  const { killSidecar, startSidecar } = await import("../sidecar-client.mjs");
84
- const { checkSessionlogStatus, syncSessionlog, annotateSwarmSession } = await import("../sessionlog.mjs");
90
+ const { sendCommand } = await import("../map-events.mjs");
91
+ const { checkSessionlogStatus, ensureSessionlogEnabled, hasStandaloneHooks, syncSessionlog, annotateSwarmSession } = await import("../sessionlog.mjs");
85
92
  const { pluginDir, ensureOpentasksDir, ensureSessionDir, listSessionDirs } = await import("../paths.mjs");
86
93
  const { findSocketPath, isDaemonAlive, ensureDaemon } = await import("../opentasks-client.mjs");
87
94
  const { resolveSwarmkit, configureNodePath } = await import("../swarmkit-resolver.mjs");
@@ -156,16 +163,43 @@ describe("bootstrap", () => {
156
163
  });
157
164
 
158
165
  describe("sessionlog", () => {
159
- it("returns 'checking' when sessionlog enabled (actual check is in background)", async () => {
166
+ it("defers to standalone when standalone hooks are present", async () => {
167
+ hasStandaloneHooks.mockReturnValue(true);
160
168
  readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
161
169
  const result = await bootstrap();
162
- expect(result.sessionlogStatus).toBe("checking");
170
+ expect(result.sessionlogStatus).toBe("active (standalone)");
171
+ expect(ensureSessionlogEnabled).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it("enables sessionlog when no standalone hooks", async () => {
175
+ hasStandaloneHooks.mockReturnValue(false);
176
+ ensureSessionlogEnabled.mockResolvedValue(true);
177
+ readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
178
+ const result = await bootstrap();
179
+ expect(result.sessionlogStatus).toBe("active");
180
+ expect(ensureSessionlogEnabled).toHaveBeenCalled();
163
181
  });
164
182
 
165
- it("does not call checkSessionlogStatus synchronously", async () => {
183
+ it("reports status when enable fails", async () => {
184
+ hasStandaloneHooks.mockReturnValue(false);
185
+ ensureSessionlogEnabled.mockResolvedValue(false);
166
186
  readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
187
+ const result = await bootstrap();
188
+ expect(result.sessionlogStatus).toBe("installed but not enabled");
189
+ });
190
+
191
+ it("returns 'checking' when hasStandaloneHooks throws", async () => {
192
+ hasStandaloneHooks.mockImplementation(() => { throw new Error("unexpected"); });
193
+ readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
194
+ const result = await bootstrap();
195
+ expect(result.sessionlogStatus).toBe("checking");
196
+ });
197
+
198
+ it("does not check standalone when disabled", async () => {
199
+ readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: false }));
167
200
  await bootstrap();
168
- expect(checkSessionlogStatus).not.toHaveBeenCalled();
201
+ expect(hasStandaloneHooks).not.toHaveBeenCalled();
202
+ expect(ensureSessionlogEnabled).not.toHaveBeenCalled();
169
203
  });
170
204
  });
171
205
 
@@ -398,6 +432,47 @@ describe("bootstrap", () => {
398
432
  "session-xyz"
399
433
  );
400
434
  });
435
+
436
+ it("registers main agent via spawn command after sidecar starts", async () => {
437
+ startSidecar.mockResolvedValue(true);
438
+ const config = makeConfig({ mapEnabled: true, sidecar: "session" });
439
+ await backgroundInit(config, "swarm:test", pluginDir(), "session-main");
440
+ // Allow fire-and-forget promise to settle
441
+ await new Promise((r) => setTimeout(r, 50));
442
+ expect(sendCommand).toHaveBeenCalledWith(
443
+ expect.anything(),
444
+ expect.objectContaining({
445
+ action: "spawn",
446
+ agent: expect.objectContaining({
447
+ agentId: "session-main",
448
+ name: "test-team-main",
449
+ role: "orchestrator",
450
+ metadata: expect.objectContaining({ isMain: true, sessionId: "session-main" }),
451
+ }),
452
+ }),
453
+ "session-main"
454
+ );
455
+ });
456
+
457
+ it("uses sessionId as agentId for main agent", async () => {
458
+ startSidecar.mockResolvedValue(true);
459
+ const config = makeConfig({ mapEnabled: true, sidecar: "session" });
460
+ await backgroundInit(config, "swarm:test", pluginDir(), "abc-123-def");
461
+ await new Promise((r) => setTimeout(r, 50));
462
+ const spawnCall = sendCommand.mock.calls.find(
463
+ (c) => c[1]?.action === "spawn"
464
+ );
465
+ expect(spawnCall).toBeDefined();
466
+ expect(spawnCall[1].agent.agentId).toBe("abc-123-def");
467
+ });
468
+
469
+ it("does not register main agent when sidecar fails to start", async () => {
470
+ startSidecar.mockResolvedValue(false);
471
+ const config = makeConfig({ mapEnabled: true, sidecar: "session" });
472
+ await backgroundInit(config, "swarm:test", pluginDir(), "session-fail");
473
+ await new Promise((r) => setTimeout(r, 50));
474
+ expect(sendCommand).not.toHaveBeenCalled();
475
+ });
401
476
  });
402
477
 
403
478
  describe("sessionlog sync", () => {