claude-code-swarm 0.3.12 → 0.3.17

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.
@@ -24,6 +24,8 @@ import { SOCKET_PATH, PID_PATH, INBOX_SOCKET_PATH, sessionPaths, pluginDir } fro
24
24
  import { connectToMAP } from "../src/map-connection.mjs";
25
25
  import { createMeshPeer, createMeshInbox } from "../src/mesh-connection.mjs";
26
26
  import { createSocketServer, createCommandHandler } from "../src/sidecar-server.mjs";
27
+ import { createContentProvider } from "../src/content-provider.mjs";
28
+ import { startMemoryWatcher } from "../src/memory-watcher.mjs";
27
29
  import { readConfig } from "../src/config.mjs";
28
30
  import { createLogger, init as initLog } from "../src/log.mjs";
29
31
  import { configureNodePath, resolvePackage } from "../src/swarmkit-resolver.mjs";
@@ -50,6 +52,38 @@ const RECONNECT_INTERVAL_MS = parseInt(getArg("reconnect-interval", ""), 10) ||
50
52
  // Auth credential for server-driven auth negotiation (opaque — type determined by server)
51
53
  const AUTH_CREDENTIAL = getArg("credential", "");
52
54
 
55
+ // Project context for swarm identification (sent as agent metadata)
56
+ import { execSync } from "child_process";
57
+ function getProjectContext() {
58
+ const context = {};
59
+ try { context.project = path.basename(process.cwd()); } catch {}
60
+ try {
61
+ context.branch = execSync("git rev-parse --abbrev-ref HEAD", {
62
+ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"],
63
+ }).trim();
64
+ } catch {}
65
+ try {
66
+ const config = readConfig();
67
+ if (config.template) context.template = config.template;
68
+
69
+ // Include task_graph metadata when opentasks is enabled
70
+ if (config.opentasks?.enabled) {
71
+ try {
72
+ const opentasksDir = path.resolve(".opentasks");
73
+ const configPath = path.join(opentasksDir, "config.json");
74
+ const taskGraph = { path: opentasksDir };
75
+ if (fs.existsSync(configPath)) {
76
+ const otConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
77
+ if (otConfig.location?.hash) taskGraph.location_hash = otConfig.location.hash;
78
+ }
79
+ context.task_graph = taskGraph;
80
+ } catch { /* opentasks config not available */ }
81
+ }
82
+ } catch {}
83
+ return context;
84
+ }
85
+ const PROJECT_CONTEXT = getProjectContext();
86
+
53
87
  // Configure NODE_PATH so dynamic imports of globally-installed packages
54
88
  // (@multi-agent-protocol/sdk, agent-inbox, agentic-mesh) resolve correctly.
55
89
  // Must happen before any dynamic import() calls.
@@ -186,6 +220,7 @@ function startSlowReconnectLoop() {
186
220
  scope: MAP_SCOPE,
187
221
  systemId: SYSTEM_ID,
188
222
  credential: AUTH_CREDENTIAL || undefined,
223
+ projectContext: PROJECT_CONTEXT,
189
224
  onMessage: () => resetInactivityTimer(),
190
225
  });
191
226
 
@@ -300,15 +335,77 @@ async function tryMeshTransport() {
300
335
  return true;
301
336
  }
302
337
 
338
+ /**
339
+ * Register opentasks notification handlers on a MAP connection.
340
+ * Delegates to the extracted opentasks-connector module for testability.
341
+ */
342
+ async function registerOpenTasksHandler(conn) {
343
+ const { registerOpenTasksHandler: _register } = await import("../src/opentasks-connector.mjs");
344
+ return _register(conn, {
345
+ scope: MAP_SCOPE,
346
+ onActivity: resetInactivityTimer,
347
+ });
348
+ }
349
+
303
350
  /**
304
351
  * Start with direct MAP SDK WebSocket transport (fallback).
305
352
  */
353
+ /**
354
+ * Register the trajectory/content.request notification handler on a connection.
355
+ * When the hub sends a content request, the sidecar reads the transcript
356
+ * from sessionlog and responds with a trajectory/content.response notification.
357
+ */
358
+ function registerContentHandler(conn) {
359
+ if (!conn || typeof conn.onNotification !== "function") return;
360
+
361
+ const contentProvider = createContentProvider();
362
+
363
+ conn.onNotification("trajectory/content.request", async (params) => {
364
+ const requestId = params?.request_id;
365
+ const checkpointId = params?.checkpoint_id;
366
+ if (!requestId) return;
367
+
368
+ log.info("content request received", { requestId, checkpointId });
369
+ resetInactivityTimer();
370
+
371
+ try {
372
+ const content = checkpointId ? await contentProvider(checkpointId) : null;
373
+
374
+ if (content) {
375
+ conn.sendNotification("trajectory/content.response", {
376
+ request_id: requestId,
377
+ transcript: content.transcript,
378
+ metadata: content.metadata,
379
+ prompts: content.prompts,
380
+ context: content.context,
381
+ });
382
+ log.info("content response sent", { requestId, size: content.transcript.length });
383
+ } else {
384
+ conn.sendNotification("trajectory/content.response", {
385
+ request_id: requestId,
386
+ error: "Content not found",
387
+ });
388
+ log.warn("content not found", { requestId, checkpointId });
389
+ }
390
+ } catch (err) {
391
+ log.error("content provider error", { requestId, error: err.message });
392
+ try {
393
+ conn.sendNotification("trajectory/content.response", {
394
+ request_id: requestId,
395
+ error: err.message,
396
+ });
397
+ } catch { /* ignore */ }
398
+ }
399
+ });
400
+ }
401
+
306
402
  async function startWebSocketTransport() {
307
403
  connection = await connectToMAP({
308
404
  server: MAP_SERVER,
309
405
  scope: MAP_SCOPE,
310
406
  systemId: SYSTEM_ID,
311
407
  credential: AUTH_CREDENTIAL || undefined,
408
+ projectContext: PROJECT_CONTEXT,
312
409
  onMessage: () => {
313
410
  resetInactivityTimer();
314
411
  },
@@ -316,6 +413,16 @@ async function startWebSocketTransport() {
316
413
 
317
414
  transportMode = "websocket";
318
415
 
416
+ // Register trajectory content handler for on-demand transcript serving
417
+ if (connection) {
418
+ registerContentHandler(connection);
419
+ }
420
+
421
+ // Register opentasks connector for remote graph queries (only when opentasks is enabled)
422
+ if (connection && PROJECT_CONTEXT.task_graph) {
423
+ await registerOpenTasksHandler(connection);
424
+ }
425
+
319
426
  // Start agent-inbox with MAP connection (legacy mode)
320
427
  if (INBOX_CONFIG && connection) {
321
428
  inboxInstance = await startLegacyAgentInbox(connection);
@@ -411,6 +518,31 @@ async function main() {
411
518
  return commandHandler(command, client);
412
519
  });
413
520
 
521
+ // Start memory file watcher if minimem is enabled
522
+ const sidecarConfig = readConfig();
523
+ if (sidecarConfig.minimem?.enabled) {
524
+ const minimemDir = sidecarConfig.minimem?.dir || ".swarm/minimem";
525
+ const memWatcher = startMemoryWatcher(minimemDir, (_event) => {
526
+ // Send bridge-memory-sync through the command handler
527
+ // This reuses the same callExtension path as the PostToolUse hook
528
+ const fakeClient = {
529
+ write: () => {},
530
+ writable: true,
531
+ };
532
+ commandHandler({
533
+ action: "bridge-memory-sync",
534
+ agentId: SESSION_ID || "minimem",
535
+ timestamp: new Date().toISOString(),
536
+ }, fakeClient);
537
+ });
538
+
539
+ // Clean up watcher on exit
540
+ if (memWatcher) {
541
+ process.on("exit", () => memWatcher.close());
542
+ process.on("SIGTERM", () => memWatcher.close());
543
+ }
544
+ }
545
+
414
546
  // Start inactivity timer
415
547
  resetInactivityTimer();
416
548
 
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Unit tests for minimem → MAP sync bridge
3
+ *
4
+ * Tests the bridge command builder (map-events.mjs) that converts
5
+ * minimem MCP tool usage into MAP sync commands.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+ import { buildMinimemBridgeCommand } from "../map-events.mjs";
10
+
11
+ describe("buildMinimemBridgeCommand", () => {
12
+ describe("write operations (should emit)", () => {
13
+ it("emits for memory_append tool", () => {
14
+ const cmd = buildMinimemBridgeCommand({
15
+ tool_name: "minimem__memory_append",
16
+ tool_input: { text: "Decided to use Redis" },
17
+ tool_output: '{"content":[{"text":"Appended to memory/2026-03-27.md"}]}',
18
+ });
19
+
20
+ expect(cmd).not.toBeNull();
21
+ expect(cmd.action).toBe("bridge-memory-sync");
22
+ expect(cmd.timestamp).toBeDefined();
23
+ });
24
+
25
+ it("emits for memory_upsert tool", () => {
26
+ const cmd = buildMinimemBridgeCommand({
27
+ tool_name: "minimem__memory_upsert",
28
+ tool_input: { path: "memory/decision.md", content: "# Decision" },
29
+ });
30
+
31
+ expect(cmd).not.toBeNull();
32
+ expect(cmd.action).toBe("bridge-memory-sync");
33
+ });
34
+
35
+ it("emits for tool names containing 'append'", () => {
36
+ const cmd = buildMinimemBridgeCommand({
37
+ tool_name: "minimem__appendToday",
38
+ tool_input: { text: "some note" },
39
+ });
40
+
41
+ expect(cmd).not.toBeNull();
42
+ expect(cmd.action).toBe("bridge-memory-sync");
43
+ });
44
+
45
+ it("emits for tool names containing 'upsert'", () => {
46
+ const cmd = buildMinimemBridgeCommand({
47
+ tool_name: "minimem__upsert_file",
48
+ tool_input: { path: "memory/test.md" },
49
+ });
50
+
51
+ expect(cmd).not.toBeNull();
52
+ });
53
+
54
+ it("includes session_id as agentId", () => {
55
+ const cmd = buildMinimemBridgeCommand({
56
+ tool_name: "minimem__memory_append",
57
+ tool_input: { text: "test" },
58
+ session_id: "sess-abc-123",
59
+ });
60
+
61
+ expect(cmd.agentId).toBe("sess-abc-123");
62
+ });
63
+
64
+ it("defaults agentId to 'minimem' when no session_id", () => {
65
+ const cmd = buildMinimemBridgeCommand({
66
+ tool_name: "minimem__memory_append",
67
+ tool_input: { text: "test" },
68
+ });
69
+
70
+ expect(cmd.agentId).toBe("minimem");
71
+ });
72
+ });
73
+
74
+ describe("read operations (should NOT emit)", () => {
75
+ it("does not emit for memory_search", () => {
76
+ const cmd = buildMinimemBridgeCommand({
77
+ tool_name: "minimem__memory_search",
78
+ tool_input: { query: "redis caching" },
79
+ });
80
+
81
+ expect(cmd).toBeNull();
82
+ });
83
+
84
+ it("does not emit for memory_get_details", () => {
85
+ const cmd = buildMinimemBridgeCommand({
86
+ tool_name: "minimem__memory_get_details",
87
+ tool_input: { results: [] },
88
+ });
89
+
90
+ expect(cmd).toBeNull();
91
+ });
92
+
93
+ it("does not emit for knowledge_search", () => {
94
+ const cmd = buildMinimemBridgeCommand({
95
+ tool_name: "minimem__knowledge_search",
96
+ tool_input: { query: "database" },
97
+ });
98
+
99
+ expect(cmd).toBeNull();
100
+ });
101
+
102
+ it("does not emit for knowledge_graph", () => {
103
+ const cmd = buildMinimemBridgeCommand({
104
+ tool_name: "minimem__knowledge_graph",
105
+ tool_input: { nodeId: "k-test" },
106
+ });
107
+
108
+ expect(cmd).toBeNull();
109
+ });
110
+
111
+ it("does not emit for knowledge_path", () => {
112
+ const cmd = buildMinimemBridgeCommand({
113
+ tool_name: "minimem__knowledge_path",
114
+ tool_input: { fromId: "k-a", toId: "k-b" },
115
+ });
116
+
117
+ expect(cmd).toBeNull();
118
+ });
119
+ });
120
+
121
+ describe("edge cases", () => {
122
+ it("handles missing tool_name", () => {
123
+ const cmd = buildMinimemBridgeCommand({});
124
+ expect(cmd).toBeNull();
125
+ });
126
+
127
+ it("handles empty tool_name", () => {
128
+ const cmd = buildMinimemBridgeCommand({ tool_name: "" });
129
+ expect(cmd).toBeNull();
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Unit tests for memory file watcher
3
+ */
4
+
5
+ import { describe, it, expect, afterEach, vi } from "vitest";
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
7
+ import { join } from "path";
8
+ import { tmpdir } from "os";
9
+ import { startMemoryWatcher } from "../memory-watcher.mjs";
10
+
11
+ describe("startMemoryWatcher", () => {
12
+ let tmpDir;
13
+ let watcher;
14
+
15
+ afterEach(() => {
16
+ if (watcher) {
17
+ watcher.close();
18
+ watcher = null;
19
+ }
20
+ if (tmpDir) {
21
+ try { rmSync(tmpDir, { recursive: true }); } catch {}
22
+ tmpDir = null;
23
+ }
24
+ });
25
+
26
+ it("returns null for non-existent directory", () => {
27
+ const result = startMemoryWatcher("/nonexistent/path", () => {});
28
+ expect(result).toBeNull();
29
+ });
30
+
31
+ it("returns null for empty/undefined dir", () => {
32
+ expect(startMemoryWatcher("", () => {})).toBeNull();
33
+ expect(startMemoryWatcher(undefined, () => {})).toBeNull();
34
+ });
35
+
36
+ it("returns a watcher handle with close method", () => {
37
+ tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
38
+ mkdirSync(join(tmpDir, "memory"), { recursive: true });
39
+
40
+ watcher = startMemoryWatcher(tmpDir, () => {});
41
+ expect(watcher).not.toBeNull();
42
+ expect(typeof watcher.close).toBe("function");
43
+ });
44
+
45
+ it("detects new .md file and calls onSync", async () => {
46
+ tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
47
+ mkdirSync(join(tmpDir, "memory"), { recursive: true });
48
+
49
+ const onSync = vi.fn();
50
+ watcher = startMemoryWatcher(tmpDir, onSync);
51
+
52
+ // Wait for watcher to be ready
53
+ await new Promise((r) => setTimeout(r, 500));
54
+
55
+ // Write a new .md file
56
+ writeFileSync(join(tmpDir, "memory", "test-note.md"), "# Test Note\nContent here.");
57
+
58
+ // Wait for debounce (2s) + buffer
59
+ await new Promise((r) => setTimeout(r, 3500));
60
+
61
+ expect(onSync).toHaveBeenCalled();
62
+ const call = onSync.mock.calls[0][0];
63
+ expect(call.type).toBe("add");
64
+ expect(call.path).toContain("test-note.md");
65
+ }, 10_000);
66
+
67
+ it("ignores non-.md files", async () => {
68
+ tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
69
+
70
+ const onSync = vi.fn();
71
+ watcher = startMemoryWatcher(tmpDir, onSync);
72
+
73
+ await new Promise((r) => setTimeout(r, 500));
74
+
75
+ // Write non-.md files
76
+ writeFileSync(join(tmpDir, "index.db"), "binary data");
77
+ writeFileSync(join(tmpDir, "config.json"), "{}");
78
+
79
+ await new Promise((r) => setTimeout(r, 3500));
80
+
81
+ expect(onSync).not.toHaveBeenCalled();
82
+ }, 10_000);
83
+
84
+ it("debounces rapid changes", async () => {
85
+ tmpDir = mkdtempSync(join(tmpdir(), "mem-watch-"));
86
+ mkdirSync(join(tmpDir, "memory"), { recursive: true });
87
+
88
+ const onSync = vi.fn();
89
+ watcher = startMemoryWatcher(tmpDir, onSync);
90
+
91
+ await new Promise((r) => setTimeout(r, 500));
92
+
93
+ // Write multiple files rapidly
94
+ writeFileSync(join(tmpDir, "memory", "note1.md"), "# Note 1");
95
+ writeFileSync(join(tmpDir, "memory", "note2.md"), "# Note 2");
96
+ writeFileSync(join(tmpDir, "memory", "note3.md"), "# Note 3");
97
+
98
+ // Wait for debounce
99
+ await new Promise((r) => setTimeout(r, 3500));
100
+
101
+ // Should only fire once (debounced)
102
+ expect(onSync).toHaveBeenCalledTimes(1);
103
+ }, 10_000);
104
+ });
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { registerOpenTasksHandler } from "../opentasks-connector.mjs";
3
+
4
+ // ── Mock factories ──────────────────────────────────────────────────────────
5
+
6
+ const MOCK_METHODS = {
7
+ QUERY_REQUEST: "opentasks/query.request",
8
+ LINK_REQUEST: "opentasks/link.request",
9
+ ANNOTATE_REQUEST: "opentasks/annotate.request",
10
+ TASK_REQUEST: "opentasks/task.request",
11
+ };
12
+
13
+ function createMockConnection() {
14
+ const handlers = new Map();
15
+ return {
16
+ onNotification: vi.fn((method, handler) => {
17
+ handlers.set(method, handler);
18
+ }),
19
+ sendNotification: vi.fn(),
20
+ // Test helper: fire a notification as if the hub sent it
21
+ _fireNotification(method, params) {
22
+ const handler = handlers.get(method);
23
+ if (handler) return handler(params);
24
+ },
25
+ _handlers: handlers,
26
+ };
27
+ }
28
+
29
+ function createMockOpentasks() {
30
+ const connector = {
31
+ handleNotification: vi.fn(),
32
+ };
33
+ return {
34
+ createClient: vi.fn(() => ({ /* mock client */ })),
35
+ createMAPConnector: vi.fn(() => connector),
36
+ MAP_CONNECTOR_METHODS: { ...MOCK_METHODS },
37
+ _connector: connector,
38
+ };
39
+ }
40
+
41
+ function createMockOpentasksClient(socketPath = "/tmp/opentasks/daemon.sock") {
42
+ return {
43
+ findSocketPath: vi.fn(() => socketPath),
44
+ };
45
+ }
46
+
47
+ // ── Tests ────────────────────────────────────────────────────────────────────
48
+
49
+ describe("registerOpenTasksHandler", () => {
50
+ let mockConn;
51
+ let mockOpentasks;
52
+ let mockOtClient;
53
+
54
+ beforeEach(() => {
55
+ mockConn = createMockConnection();
56
+ mockOpentasks = createMockOpentasks();
57
+ mockOtClient = createMockOpentasksClient();
58
+ });
59
+
60
+ const callRegister = (connOverride, optsOverride = {}) =>
61
+ registerOpenTasksHandler(connOverride ?? mockConn, {
62
+ scope: "swarm:test",
63
+ importOpentasks: async () => mockOpentasks,
64
+ importOpentasksClient: async () => mockOtClient,
65
+ ...optsOverride,
66
+ });
67
+
68
+ it("creates a client with the socket path from findSocketPath", async () => {
69
+ await callRegister();
70
+
71
+ expect(mockOtClient.findSocketPath).toHaveBeenCalled();
72
+ expect(mockOpentasks.createClient).toHaveBeenCalledWith({
73
+ socketPath: "/tmp/opentasks/daemon.sock",
74
+ autoConnect: true,
75
+ });
76
+ });
77
+
78
+ it("creates a MAP connector with the client and a send function", async () => {
79
+ await callRegister();
80
+
81
+ expect(mockOpentasks.createMAPConnector).toHaveBeenCalledTimes(1);
82
+ const callArgs = mockOpentasks.createMAPConnector.mock.calls[0][0];
83
+
84
+ // Should pass the client returned by createClient
85
+ expect(callArgs.client).toBeDefined();
86
+ // Should pass a send function
87
+ expect(typeof callArgs.send).toBe("function");
88
+ // Should pass agentId derived from scope
89
+ expect(callArgs.agentId).toBe("swarm:test-sidecar");
90
+ });
91
+
92
+ it("registers onNotification for all 4 request methods", async () => {
93
+ await callRegister();
94
+
95
+ expect(mockConn.onNotification).toHaveBeenCalledTimes(4);
96
+
97
+ const registeredMethods = mockConn.onNotification.mock.calls.map((c) => c[0]);
98
+ expect(registeredMethods).toContain(MOCK_METHODS.QUERY_REQUEST);
99
+ expect(registeredMethods).toContain(MOCK_METHODS.LINK_REQUEST);
100
+ expect(registeredMethods).toContain(MOCK_METHODS.ANNOTATE_REQUEST);
101
+ expect(registeredMethods).toContain(MOCK_METHODS.TASK_REQUEST);
102
+ });
103
+
104
+ it("forwards notifications to connector.handleNotification", async () => {
105
+ await callRegister();
106
+
107
+ const params = { request_id: "req-1", query: "status:open" };
108
+ await mockConn._fireNotification(MOCK_METHODS.QUERY_REQUEST, params);
109
+
110
+ expect(mockOpentasks._connector.handleNotification).toHaveBeenCalledWith(
111
+ MOCK_METHODS.QUERY_REQUEST,
112
+ params,
113
+ );
114
+ });
115
+
116
+ it("passes empty object when notification params are missing", async () => {
117
+ await callRegister();
118
+
119
+ await mockConn._fireNotification(MOCK_METHODS.TASK_REQUEST, undefined);
120
+
121
+ expect(mockOpentasks._connector.handleNotification).toHaveBeenCalledWith(
122
+ MOCK_METHODS.TASK_REQUEST,
123
+ {},
124
+ );
125
+ });
126
+
127
+ it("calls the send function on the connector via sendNotification", async () => {
128
+ await callRegister();
129
+
130
+ // Get the send function that was passed to createMAPConnector
131
+ const sendFn = mockOpentasks.createMAPConnector.mock.calls[0][0].send;
132
+ sendFn("opentasks/query.response", { data: "result" });
133
+
134
+ expect(mockConn.sendNotification).toHaveBeenCalledWith(
135
+ "opentasks/query.response",
136
+ { data: "result" },
137
+ );
138
+ });
139
+
140
+ it("does not throw when sendNotification fails in the send callback", async () => {
141
+ mockConn.sendNotification.mockImplementation(() => {
142
+ throw new Error("connection closed");
143
+ });
144
+
145
+ await callRegister();
146
+
147
+ const sendFn = mockOpentasks.createMAPConnector.mock.calls[0][0].send;
148
+ // Should not throw
149
+ expect(() => sendFn("method", {})).not.toThrow();
150
+ });
151
+
152
+ it("calls onActivity callback when a notification fires", async () => {
153
+ const onActivity = vi.fn();
154
+ await callRegister(undefined, { onActivity });
155
+
156
+ await mockConn._fireNotification(MOCK_METHODS.LINK_REQUEST, { request_id: "r1" });
157
+
158
+ expect(onActivity).toHaveBeenCalledTimes(1);
159
+ });
160
+
161
+ it("does nothing when conn is null", async () => {
162
+ // Should not throw
163
+ await registerOpenTasksHandler(null, {
164
+ scope: "swarm:test",
165
+ importOpentasks: async () => mockOpentasks,
166
+ importOpentasksClient: async () => mockOtClient,
167
+ });
168
+
169
+ expect(mockOpentasks.createClient).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it("does nothing when conn lacks onNotification", async () => {
173
+ await registerOpenTasksHandler({ sendNotification: vi.fn() }, {
174
+ scope: "swarm:test",
175
+ importOpentasks: async () => mockOpentasks,
176
+ importOpentasksClient: async () => mockOtClient,
177
+ });
178
+
179
+ expect(mockOpentasks.createClient).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("does nothing when opentasks module is missing createMAPConnector", async () => {
183
+ const brokenModule = { createClient: vi.fn() }; // no createMAPConnector
184
+
185
+ await registerOpenTasksHandler(mockConn, {
186
+ scope: "swarm:test",
187
+ importOpentasks: async () => brokenModule,
188
+ importOpentasksClient: async () => mockOtClient,
189
+ });
190
+
191
+ expect(mockConn.onNotification).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it("does nothing when opentasks import throws", async () => {
195
+ await registerOpenTasksHandler(mockConn, {
196
+ scope: "swarm:test",
197
+ importOpentasks: async () => { throw new Error("module not found"); },
198
+ importOpentasksClient: async () => mockOtClient,
199
+ });
200
+
201
+ expect(mockConn.onNotification).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("uses custom socket path from findSocketPath", async () => {
205
+ const customClient = createMockOpentasksClient("/custom/path/daemon.sock");
206
+
207
+ await callRegister(undefined, {
208
+ importOpentasksClient: async () => customClient,
209
+ });
210
+
211
+ expect(mockOpentasks.createClient).toHaveBeenCalledWith({
212
+ socketPath: "/custom/path/daemon.sock",
213
+ autoConnect: true,
214
+ });
215
+ });
216
+ });
@@ -188,6 +188,46 @@ describe("sessionlog", () => {
188
188
  expect(cp.token_usage.input_tokens).toBe(800);
189
189
  expect(cp.token_usage.output_tokens).toBe(400);
190
190
  });
191
+
192
+ it("includes project name from cwd in metadata", () => {
193
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
194
+ expect(cp.metadata.project).toBeDefined();
195
+ expect(typeof cp.metadata.project).toBe("string");
196
+ expect(cp.metadata.project.length).toBeGreaterThan(0);
197
+ });
198
+
199
+ it("includes git branch as top-level wire format field", () => {
200
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
201
+ // branch may be null in CI/non-git environments, but should be defined
202
+ expect("branch" in cp).toBe(true);
203
+ });
204
+
205
+ it("includes firstPrompt from session state when available", () => {
206
+ const state = { ...baseState, firstPrompt: "fix the bug in server.ts" };
207
+ const cp = buildTrajectoryCheckpoint(state, "lifecycle", makeConfig());
208
+ expect(cp.metadata.firstPrompt).toBe("fix the bug in server.ts");
209
+ });
210
+
211
+ it("truncates long firstPrompt to 200 chars", () => {
212
+ const state = { ...baseState, firstPrompt: "x".repeat(300) };
213
+ const cp = buildTrajectoryCheckpoint(state, "lifecycle", makeConfig());
214
+ expect(cp.metadata.firstPrompt.length).toBe(200);
215
+ });
216
+
217
+ it("omits firstPrompt when not in session state", () => {
218
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
219
+ expect(cp.metadata.firstPrompt).toBeUndefined();
220
+ });
221
+
222
+ it("includes template from config when configured", () => {
223
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig({ template: "gsd" }));
224
+ expect(cp.metadata.template).toBe("gsd");
225
+ });
226
+
227
+ it("omits template when not configured", () => {
228
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig({ template: "" }));
229
+ expect(cp.metadata.template).toBeUndefined();
230
+ });
191
231
  });
192
232
 
193
233
  describe("ensureSessionlogEnabled", () => {
@@ -229,13 +229,13 @@ describe("sidecar-server", () => {
229
229
 
230
230
  it("falls back to broadcast with trajectory.checkpoint payload when callExtension throws", async () => {
231
231
  mockConnection.callExtension.mockRejectedValueOnce(new Error("not supported"));
232
- const cp = { id: "cp1", agentId: "a", sessionId: "s", label: "l", metadata: { phase: "active" } };
232
+ const cp = { id: "cp1", agent: "a", session_id: "s", files_touched: [], token_usage: null, metadata: { phase: "active" } };
233
233
  await handler({ action: "trajectory-checkpoint", checkpoint: cp }, mockClient);
234
234
  expect(mockConnection.send).toHaveBeenCalled();
235
235
  const [, payload] = mockConnection.send.mock.calls[0];
236
236
  expect(payload.type).toBe("trajectory.checkpoint");
237
237
  expect(payload.checkpoint.id).toBe("cp1");
238
- expect(payload.checkpoint.agentId).toBe("a");
238
+ expect(payload.checkpoint.agent).toBe("a");
239
239
  expect(payload.checkpoint.metadata).toEqual({ phase: "active" });
240
240
  });
241
241