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.
@@ -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 () => {
@@ -32,6 +32,7 @@ export function makeConfig(overrides = {}) {
32
32
  sessionlog: {
33
33
  enabled: overrides.sessionlogEnabled ?? false,
34
34
  sync: overrides.sessionlogSync ?? "off",
35
+ mode: overrides.sessionlogMode ?? "auto",
35
36
  },
36
37
  opentasks: {
37
38
  enabled: overrides.opentasksEnabled ?? false,
@@ -0,0 +1,270 @@
1
+ /**
2
+ * E2E test: sessionlog lifecycle dispatch through cc-swarm plugin flow.
3
+ *
4
+ * Creates a real temp git repo with real config files, enables sessionlog,
5
+ * then dispatches lifecycle events via dispatchSessionlogHook() and verifies
6
+ * session state files on disk.
7
+ *
8
+ * Mocked:
9
+ * - resolvePackage("sessionlog") — returns the real sessionlog module from
10
+ * references/ (in production this resolves from global node_modules)
11
+ * - paths.mjs GLOBAL_CONFIG_PATH — points to tmp dir to avoid reading
12
+ * the user's real ~/.claude-swarm/config.json
13
+ * - process.cwd() — points to tmp git repo
14
+ *
15
+ * Real:
16
+ * - Git repo (git init + commit in tmp dir)
17
+ * - sessionlog enable() — creates .sessionlog/, .git/sessionlog-sessions/, git hooks
18
+ * - .swarm/claude-swarm/config.json — written to tmp dir, read by real readConfig()
19
+ * - readConfig() — real config resolution (reads from tmp dir)
20
+ * - hasStandaloneHooks() — real file read of .claude/settings.json
21
+ * - dispatchSessionlogHook() — real dispatch through sessionlog lifecycle handler
22
+ * - Session state files — real files at .git/sessionlog-sessions/<id>.json
23
+ */
24
+
25
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
26
+ import fs from "fs";
27
+ import path from "path";
28
+ import os from "os";
29
+ import { execSync } from "child_process";
30
+
31
+ // Resolve the real sessionlog module from the monorepo references dir
32
+ const SESSIONLOG_PATH = path.resolve(
33
+ import.meta.dirname, "..", "..", "..", "sessionlog"
34
+ );
35
+
36
+ let _tmpDir;
37
+ let _sessionlogMod;
38
+
39
+ // Mock resolvePackage — the only mock needed for the core dispatch.
40
+ // In production, sessionlog is resolved from global node_modules via swarmkit.
41
+ // Here we return the real module from references/.
42
+ vi.mock("../swarmkit-resolver.mjs", () => ({
43
+ resolvePackage: vi.fn(async (name) => {
44
+ if (name === "sessionlog") return _sessionlogMod;
45
+ return null;
46
+ }),
47
+ }));
48
+
49
+ // Mock GLOBAL_CONFIG_PATH to a tmp path so we don't read the user's real
50
+ // ~/.claude-swarm/config.json. CONFIG_PATH stays relative (".swarm/claude-swarm/config.json")
51
+ // and is resolved by readConfig() via path.resolve(process.cwd(), configPath).
52
+ vi.mock("../paths.mjs", async (importOriginal) => {
53
+ const actual = await importOriginal();
54
+ const { mkdtempSync } = await import("fs");
55
+ const { join } = await import("path");
56
+ const { tmpdir } = await import("os");
57
+ const globalTmp = mkdtempSync(join(tmpdir(), "swarm-global-"));
58
+ return {
59
+ ...actual,
60
+ GLOBAL_CONFIG_PATH: join(globalTmp, "config.json"),
61
+ };
62
+ });
63
+
64
+ // Import the function under test AFTER mocks are set up
65
+ const { dispatchSessionlogHook, hasStandaloneHooks } = await import("../sessionlog.mjs");
66
+
67
+ // ── Helpers ──────────────────────────────────────────────────────────────────
68
+
69
+ function initGitRepo(dir) {
70
+ execSync("git init", { cwd: dir, stdio: "pipe" });
71
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
72
+ execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
73
+ fs.writeFileSync(path.join(dir, "README.md"), "# Test");
74
+ execSync("git add . && git commit -m initial", { cwd: dir, stdio: "pipe" });
75
+ }
76
+
77
+ function writeConfig(dir, config) {
78
+ const configDir = path.join(dir, ".swarm", "claude-swarm");
79
+ fs.mkdirSync(configDir, { recursive: true });
80
+ fs.writeFileSync(
81
+ path.join(configDir, "config.json"),
82
+ JSON.stringify(config, null, 2)
83
+ );
84
+ }
85
+
86
+ function writeClaudeSettings(dir, settings) {
87
+ const claudeDir = path.join(dir, ".claude");
88
+ fs.mkdirSync(claudeDir, { recursive: true });
89
+ fs.writeFileSync(
90
+ path.join(claudeDir, "settings.json"),
91
+ JSON.stringify(settings, null, 2)
92
+ );
93
+ }
94
+
95
+ function readSessionState(dir, sessionId) {
96
+ const stateFile = path.join(dir, ".git", "sessionlog-sessions", `${sessionId}.json`);
97
+ if (!fs.existsSync(stateFile)) return null;
98
+ return JSON.parse(fs.readFileSync(stateFile, "utf-8"));
99
+ }
100
+
101
+ // ── Tests ────────────────────────────────────────────────────────────────────
102
+
103
+ describe("sessionlog e2e: plugin dispatch lifecycle", () => {
104
+ beforeEach(async () => {
105
+ _tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sessionlog-e2e-"));
106
+ initGitRepo(_tmpDir);
107
+
108
+ // Load the real sessionlog module
109
+ _sessionlogMod = await import(SESSIONLOG_PATH + "/src/index.ts");
110
+
111
+ // Enable sessionlog in the temp repo (dirs + git hooks, no agent hooks)
112
+ const result = await _sessionlogMod.enable({
113
+ cwd: _tmpDir,
114
+ agent: "claude-code",
115
+ skipAgentHooks: true,
116
+ });
117
+ if (!result.enabled) {
118
+ throw new Error(`sessionlog enable failed: ${result.errors.join(", ")}`);
119
+ }
120
+
121
+ // Point process.cwd() to the tmp dir — this makes:
122
+ // - readConfig() read .swarm/claude-swarm/config.json from tmp dir
123
+ // - hasStandaloneHooks() read .claude/settings.json from tmp dir
124
+ // - sessionlog stores resolve .git/sessionlog-sessions/ from tmp dir
125
+ vi.spyOn(process, "cwd").mockReturnValue(_tmpDir);
126
+ });
127
+
128
+ afterEach(() => {
129
+ vi.restoreAllMocks();
130
+ try {
131
+ fs.rmSync(_tmpDir, { recursive: true, force: true });
132
+ } catch {
133
+ // ignore
134
+ }
135
+ });
136
+
137
+ it("hasStandaloneHooks returns false when skipAgentHooks was used", () => {
138
+ expect(hasStandaloneHooks()).toBe(false);
139
+ });
140
+
141
+ it("dispatches session-start with real config (mode: plugin)", async () => {
142
+ writeConfig(_tmpDir, {
143
+ sessionlog: { enabled: true, sync: "off", mode: "plugin" },
144
+ });
145
+
146
+ await dispatchSessionlogHook("session-start", {
147
+ session_id: "e2e-plugin-session",
148
+ transcript_path: path.join(_tmpDir, "transcript.jsonl"),
149
+ });
150
+
151
+ const state = readSessionState(_tmpDir, "e2e-plugin-session");
152
+ expect(state).not.toBeNull();
153
+ expect(state.sessionID).toBe("e2e-plugin-session");
154
+ expect(state.phase).toBe("idle");
155
+ });
156
+
157
+ it("full lifecycle: start → prompt → stop → end", async () => {
158
+ writeConfig(_tmpDir, {
159
+ sessionlog: { enabled: true, sync: "off", mode: "plugin" },
160
+ });
161
+
162
+ const sessionId = "e2e-full-lifecycle";
163
+ const transcriptPath = path.join(_tmpDir, "transcript.jsonl");
164
+ fs.writeFileSync(transcriptPath, "");
165
+
166
+ // SessionStart
167
+ await dispatchSessionlogHook("session-start", {
168
+ session_id: sessionId,
169
+ transcript_path: transcriptPath,
170
+ });
171
+ expect(readSessionState(_tmpDir, sessionId).phase).toBe("idle");
172
+
173
+ // UserPromptSubmit (TurnStart)
174
+ await dispatchSessionlogHook("user-prompt-submit", {
175
+ session_id: sessionId,
176
+ transcript_path: transcriptPath,
177
+ prompt: "implement feature X",
178
+ });
179
+ let state = readSessionState(_tmpDir, sessionId);
180
+ expect(state.phase).toBe("active");
181
+ expect(state.firstPrompt).toBe("implement feature X");
182
+
183
+ // Stop (TurnEnd)
184
+ await dispatchSessionlogHook("stop", {
185
+ session_id: sessionId,
186
+ transcript_path: transcriptPath,
187
+ });
188
+ expect(readSessionState(_tmpDir, sessionId).phase).toBe("idle");
189
+
190
+ // SessionEnd
191
+ await dispatchSessionlogHook("session-end", {
192
+ session_id: sessionId,
193
+ transcript_path: transcriptPath,
194
+ });
195
+ state = readSessionState(_tmpDir, sessionId);
196
+ expect(state.phase).toBe("ended");
197
+ expect(state.endedAt).toBeDefined();
198
+ });
199
+
200
+ it("standalone mode skips dispatch (real config)", async () => {
201
+ writeConfig(_tmpDir, {
202
+ sessionlog: { enabled: true, sync: "off", mode: "standalone" },
203
+ });
204
+
205
+ await dispatchSessionlogHook("session-start", {
206
+ session_id: "should-not-exist",
207
+ transcript_path: "/tmp/transcript.jsonl",
208
+ });
209
+
210
+ expect(readSessionState(_tmpDir, "should-not-exist")).toBeNull();
211
+ });
212
+
213
+ it("auto mode defers when standalone hooks exist in .claude/settings.json", async () => {
214
+ writeConfig(_tmpDir, {
215
+ sessionlog: { enabled: true, sync: "off", mode: "auto" },
216
+ });
217
+
218
+ // Write standalone sessionlog hooks to .claude/settings.json
219
+ writeClaudeSettings(_tmpDir, {
220
+ hooks: {
221
+ SessionStart: [
222
+ {
223
+ matcher: "",
224
+ hooks: [{ type: "command", command: "sessionlog hooks claude-code session-start" }],
225
+ },
226
+ ],
227
+ },
228
+ });
229
+
230
+ await dispatchSessionlogHook("session-start", {
231
+ session_id: "should-not-exist-auto",
232
+ transcript_path: "/tmp/transcript.jsonl",
233
+ });
234
+
235
+ expect(readSessionState(_tmpDir, "should-not-exist-auto")).toBeNull();
236
+ });
237
+
238
+ it("auto mode dispatches when no standalone hooks exist", async () => {
239
+ writeConfig(_tmpDir, {
240
+ sessionlog: { enabled: true, sync: "off", mode: "auto" },
241
+ });
242
+
243
+ // No .claude/settings.json with sessionlog hooks — auto should dispatch
244
+ await dispatchSessionlogHook("session-start", {
245
+ session_id: "e2e-auto-no-standalone",
246
+ transcript_path: path.join(_tmpDir, "transcript.jsonl"),
247
+ });
248
+
249
+ const state = readSessionState(_tmpDir, "e2e-auto-no-standalone");
250
+ expect(state).not.toBeNull();
251
+ expect(state.phase).toBe("idle");
252
+ });
253
+
254
+ it("mode defaults to auto when not specified in config", async () => {
255
+ // Config with no mode field — should default to "auto"
256
+ writeConfig(_tmpDir, {
257
+ sessionlog: { enabled: true, sync: "off" },
258
+ });
259
+
260
+ await dispatchSessionlogHook("session-start", {
261
+ session_id: "e2e-default-mode",
262
+ transcript_path: path.join(_tmpDir, "transcript.jsonl"),
263
+ });
264
+
265
+ // No standalone hooks → auto dispatches
266
+ const state = readSessionState(_tmpDir, "e2e-default-mode");
267
+ expect(state).not.toBeNull();
268
+ expect(state.phase).toBe("idle");
269
+ });
270
+ });