claude-code-swarm 0.3.11 → 0.3.16

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,9 +1,36 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import path from "path";
3
3
  import fs from "fs";
4
- import { findActiveSession, buildTrajectoryCheckpoint } from "../sessionlog.mjs";
4
+ import { execSync } from "child_process";
5
+ import { findActiveSession, buildTrajectoryCheckpoint, ensureSessionlogEnabled, checkSessionlogStatus, dispatchSessionlogHook } from "../sessionlog.mjs";
5
6
  import { makeTmpDir, writeFile, makeConfig, cleanupTmpDir } from "./helpers.mjs";
6
7
 
8
+ // Mock child_process for ensureSessionlogEnabled tests
9
+ vi.mock("child_process", async (importOriginal) => {
10
+ const actual = await importOriginal();
11
+ return {
12
+ ...actual,
13
+ execSync: vi.fn(actual.execSync),
14
+ };
15
+ });
16
+
17
+ // Mock swarmkit-resolver for resolvePackage
18
+ vi.mock("../swarmkit-resolver.mjs", () => ({
19
+ resolvePackage: vi.fn().mockResolvedValue(null),
20
+ }));
21
+
22
+ // Mock config — preserve resolveTeamName/resolveScope for buildTrajectoryCheckpoint tests,
23
+ // override readConfig for dispatchSessionlogHook mode tests
24
+ vi.mock("../config.mjs", async (importOriginal) => {
25
+ const actual = await importOriginal();
26
+ return {
27
+ ...actual,
28
+ readConfig: vi.fn(() => ({
29
+ sessionlog: { enabled: true, sync: "off", mode: "plugin" },
30
+ })),
31
+ };
32
+ });
33
+
7
34
  describe("sessionlog", () => {
8
35
  let tmpDir;
9
36
  beforeEach(() => { tmpDir = makeTmpDir(); });
@@ -72,13 +99,18 @@ describe("sessionlog", () => {
72
99
  filesTouched: ["a.js", "b.js"],
73
100
  lastCheckpointID: "cp-42",
74
101
  turnCheckpointIDs: ["cp-40", "cp-41", "cp-42"],
75
- tokenUsage: { input: 1000, output: 500 },
102
+ tokenUsage: { inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 50, cacheReadTokens: 200, apiCallCount: 3 },
76
103
  extraField: "extra",
77
104
  };
78
105
 
79
- it("sets agentId to teamName-sidecar", () => {
106
+ it("sets agent to teamName-sidecar (wire format)", () => {
80
107
  const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
81
- expect(cp.agentId).toBe("test-team-sidecar");
108
+ expect(cp.agent).toBe("test-team-sidecar");
109
+ });
110
+
111
+ it("sets session_id from state.sessionID (wire format)", () => {
112
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
113
+ expect(cp.session_id).toBe("sess-123");
82
114
  });
83
115
 
84
116
  it("builds checkpoint id from lastCheckpointID when available", () => {
@@ -92,10 +124,17 @@ describe("sessionlog", () => {
92
124
  expect(cp.id).toBe("sess-123-step10");
93
125
  });
94
126
 
95
- it("builds human-readable label", () => {
127
+ it("builds human-readable label in metadata", () => {
96
128
  const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
97
- expect(cp.label).toContain("Turn turn-5");
98
- expect(cp.label).toContain("step 10");
129
+ expect(cp.metadata.label).toContain("Turn turn-5");
130
+ expect(cp.metadata.label).toContain("step 10");
131
+ });
132
+
133
+ it("defaults files_touched and checkpoints_count at lifecycle level", () => {
134
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
135
+ expect(cp.files_touched).toEqual([]);
136
+ expect(cp.checkpoints_count).toBe(0);
137
+ expect(cp.token_usage).toBeUndefined();
99
138
  });
100
139
 
101
140
  it("includes base metadata at lifecycle level", () => {
@@ -106,15 +145,27 @@ describe("sessionlog", () => {
106
145
  expect(cp.metadata.stepCount).toBeUndefined();
107
146
  });
108
147
 
109
- it("includes metrics at metrics level", () => {
148
+ it("promotes files_touched and token_usage to top level at metrics level", () => {
149
+ const cp = buildTrajectoryCheckpoint(baseState, "metrics", makeConfig());
150
+ expect(cp.files_touched).toEqual(["a.js", "b.js"]);
151
+ expect(cp.checkpoints_count).toBe(3);
152
+ expect(cp.token_usage).toEqual({
153
+ input_tokens: 1000,
154
+ output_tokens: 500,
155
+ cache_creation_tokens: 50,
156
+ cache_read_tokens: 200,
157
+ api_call_count: 3,
158
+ });
159
+ });
160
+
161
+ it("keeps stepCount and checkpoint IDs in metadata at metrics level", () => {
110
162
  const cp = buildTrajectoryCheckpoint(baseState, "metrics", makeConfig());
111
163
  expect(cp.metadata.stepCount).toBe(10);
112
- expect(cp.metadata.filesTouched).toEqual(["a.js", "b.js"]);
113
- expect(cp.metadata.tokenUsage).toEqual({ input: 1000, output: 500 });
114
164
  expect(cp.metadata.lastCheckpointID).toBe("cp-42");
165
+ expect(cp.metadata.turnCheckpointIDs).toEqual(["cp-40", "cp-41", "cp-42"]);
115
166
  });
116
167
 
117
- it("includes all state fields at full level", () => {
168
+ it("includes all state fields in metadata at full level", () => {
118
169
  const cp = buildTrajectoryCheckpoint(baseState, "full", makeConfig());
119
170
  expect(cp.metadata.extraField).toBe("extra");
120
171
  expect(cp.metadata.stepCount).toBe(10);
@@ -131,9 +182,201 @@ describe("sessionlog", () => {
131
182
  expect(cp.metadata.endedAt).toBe("2024-01-01T01:00:00Z");
132
183
  });
133
184
 
134
- it("sets sessionId from state.sessionID", () => {
185
+ it("handles legacy tokenUsage format (input/output instead of inputTokens/outputTokens)", () => {
186
+ const state = { ...baseState, tokenUsage: { input: 800, output: 400 } };
187
+ const cp = buildTrajectoryCheckpoint(state, "metrics", makeConfig());
188
+ expect(cp.token_usage.input_tokens).toBe(800);
189
+ expect(cp.token_usage.output_tokens).toBe(400);
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", () => {
135
200
  const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
136
- expect(cp.sessionId).toBe("sess-123");
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
+ });
231
+ });
232
+
233
+ describe("ensureSessionlogEnabled", () => {
234
+ beforeEach(() => {
235
+ vi.mocked(execSync).mockReset();
236
+ });
237
+
238
+ it("returns true immediately when sessionlog is already active", async () => {
239
+ // checkSessionlogStatus calls execSync twice: `which sessionlog` and `sessionlog status`
240
+ vi.mocked(execSync)
241
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
242
+ .mockImplementationOnce(() => "enabled: true\nstrategy: manual-commit"); // status
243
+ const result = await ensureSessionlogEnabled();
244
+ expect(result).toBe(true);
245
+ });
246
+
247
+ it("returns false when sessionlog is not installed", async () => {
248
+ vi.mocked(execSync).mockImplementationOnce(() => { throw new Error("not found"); }); // which
249
+ const result = await ensureSessionlogEnabled();
250
+ expect(result).toBe(false);
251
+ });
252
+
253
+ it("attempts CLI enable when installed but not enabled and resolvePackage returns null", async () => {
254
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
255
+ vi.mocked(resolvePackage).mockResolvedValue(null);
256
+
257
+ // First two calls: checkSessionlogStatus (which + status)
258
+ // Third call: CLI fallback `sessionlog enable --agent claude-code`
259
+ vi.mocked(execSync)
260
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
261
+ .mockImplementationOnce(() => "enabled: false") // status → not enabled
262
+ .mockImplementationOnce(() => ""); // sessionlog enable succeeds
263
+ const result = await ensureSessionlogEnabled();
264
+ expect(result).toBe(true);
265
+ });
266
+
267
+ it("returns false when both programmatic and CLI enable fail", async () => {
268
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
269
+ vi.mocked(resolvePackage).mockResolvedValue(null);
270
+
271
+ vi.mocked(execSync)
272
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
273
+ .mockImplementationOnce(() => "enabled: false") // status
274
+ .mockImplementationOnce(() => { throw new Error("enable failed"); }); // CLI fails
275
+ const result = await ensureSessionlogEnabled();
276
+ expect(result).toBe(false);
277
+ });
278
+
279
+ it("tries programmatic API before CLI fallback", async () => {
280
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
281
+ const mockEnable = vi.fn().mockResolvedValue({ enabled: true });
282
+ vi.mocked(resolvePackage).mockResolvedValue({ enable: mockEnable });
283
+
284
+ vi.mocked(execSync)
285
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
286
+ .mockImplementationOnce(() => "enabled: false"); // status → not enabled
287
+ const result = await ensureSessionlogEnabled();
288
+ expect(result).toBe(true);
289
+ expect(mockEnable).toHaveBeenCalledWith({ agent: "claude-code", skipAgentHooks: true });
290
+ });
291
+ });
292
+
293
+ describe("dispatchSessionlogHook", () => {
294
+ function mockSessionlog(overrides = {}) {
295
+ return {
296
+ isEnabled: vi.fn().mockResolvedValue(true),
297
+ getAgent: vi.fn().mockReturnValue({ parseHookEvent: vi.fn().mockReturnValue({ type: "SessionStart" }) }),
298
+ hasHookSupport: vi.fn().mockReturnValue(true),
299
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: vi.fn() }),
300
+ createSessionStore: vi.fn().mockReturnValue({}),
301
+ createCheckpointStore: vi.fn().mockReturnValue({}),
302
+ ...overrides,
303
+ };
304
+ }
305
+
306
+ beforeEach(() => {
307
+ vi.mocked(execSync).mockReset();
308
+ });
309
+
310
+ it("skips dispatch when mode is 'standalone'", async () => {
311
+ const { readConfig } = await import("../config.mjs");
312
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "standalone" } });
313
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
314
+ const mod = mockSessionlog();
315
+ vi.mocked(resolvePackage).mockResolvedValue(mod);
316
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
317
+ expect(mod.createLifecycleHandler().dispatch).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("dispatches when mode is 'plugin'", async () => {
321
+ const { readConfig } = await import("../config.mjs");
322
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
323
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
324
+ const mockDispatch = vi.fn();
325
+ const mockEvent = { type: "SessionStart", sessionID: "s1" };
326
+ const mockAgent = { parseHookEvent: vi.fn().mockReturnValue(mockEvent) };
327
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
328
+ getAgent: vi.fn().mockReturnValue(mockAgent),
329
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
330
+ }));
331
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
332
+ expect(mockDispatch).toHaveBeenCalledWith(mockAgent, mockEvent);
333
+ });
334
+
335
+ it("bails silently when sessionlog package is not available", async () => {
336
+ const { readConfig } = await import("../config.mjs");
337
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
338
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
339
+ vi.mocked(resolvePackage).mockResolvedValue(null);
340
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
341
+ });
342
+
343
+ it("bails when isEnabled returns false", async () => {
344
+ const { readConfig } = await import("../config.mjs");
345
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
346
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
347
+ const mockDispatch = vi.fn();
348
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
349
+ isEnabled: vi.fn().mockResolvedValue(false),
350
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
351
+ }));
352
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
353
+ expect(mockDispatch).not.toHaveBeenCalled();
354
+ });
355
+
356
+ it("bails when parseHookEvent returns null", async () => {
357
+ const { readConfig } = await import("../config.mjs");
358
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
359
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
360
+ const mockDispatch = vi.fn();
361
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
362
+ getAgent: vi.fn().mockReturnValue({ parseHookEvent: vi.fn().mockReturnValue(null) }),
363
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
364
+ }));
365
+ await dispatchSessionlogHook("unknown-hook", {});
366
+ expect(mockDispatch).not.toHaveBeenCalled();
367
+ });
368
+
369
+ it("bails when getAgent returns null", async () => {
370
+ const { readConfig } = await import("../config.mjs");
371
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
372
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
373
+ const mockDispatch = vi.fn();
374
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
375
+ getAgent: vi.fn().mockReturnValue(null),
376
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
377
+ }));
378
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
379
+ expect(mockDispatch).not.toHaveBeenCalled();
137
380
  });
138
381
  });
139
382
  });
@@ -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
 
package/src/bootstrap.mjs CHANGED
@@ -20,7 +20,7 @@ import { findSocketPath, isDaemonAlive, ensureDaemon } from "./opentasks-client.
20
20
  import { loadTeam } from "./template.mjs";
21
21
  import { killSidecar, startSidecar, sendToInbox } from "./sidecar-client.mjs";
22
22
  import { sendCommand } from "./map-events.mjs";
23
- import { checkSessionlogStatus, syncSessionlog, annotateSwarmSession } from "./sessionlog.mjs";
23
+ import { checkSessionlogStatus, ensureSessionlogEnabled, syncSessionlog, annotateSwarmSession, hasStandaloneHooks } from "./sessionlog.mjs";
24
24
  import { resolveSwarmkit, configureNodePath } from "./swarmkit-resolver.mjs";
25
25
 
26
26
  /**
@@ -330,7 +330,7 @@ export async function backgroundInit(config, scope, dir, sessionId) {
330
330
  );
331
331
  }
332
332
 
333
- // Sessionlog sync + swarm annotation
333
+ // Sessionlog sync + swarm annotation (enable already happened in fast path)
334
334
  if (config.map.enabled && config.sessionlog.sync !== "off") {
335
335
  tasks.push(syncSessionlog(config, sessionId).catch(() => {}));
336
336
  }
@@ -394,10 +394,23 @@ export async function bootstrap(pluginDirOverride, sessionId) {
394
394
  }
395
395
  }
396
396
 
397
- // 3. Sessionlog status actual check is slow (execSync), defer to background
397
+ // 3. Sessionlog: ensure enabled, detect standalone vs plugin mode.
398
+ // Mode is resolved per-hook in dispatchSessionlogHook() — bootstrap just
399
+ // ensures sessionlog infrastructure exists and reports status.
398
400
  let sessionlogStatus = "not installed";
399
401
  if (config.sessionlog.enabled) {
400
- sessionlogStatus = "checking";
402
+ try {
403
+ const mode = config.sessionlog.mode || "auto";
404
+ const standalone = mode === "standalone" || (mode === "auto" && hasStandaloneHooks());
405
+ if (standalone) {
406
+ sessionlogStatus = "active (standalone)";
407
+ } else {
408
+ const enabled = await ensureSessionlogEnabled();
409
+ sessionlogStatus = enabled ? "active" : "installed but not enabled";
410
+ }
411
+ } catch {
412
+ sessionlogStatus = "checking";
413
+ }
401
414
  }
402
415
 
403
416
  // 4. Quick MAP status — report "starting" for session sidecars (actual startup is background)
package/src/config.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import fs from "fs";
11
+ import path from "path";
11
12
  import { CONFIG_PATH, GLOBAL_CONFIG_PATH } from "./paths.mjs";
12
13
 
13
14
  export const DEFAULTS = {
@@ -53,8 +54,10 @@ function readJsonFile(filePath) {
53
54
  * Never throws — returns defaults on any error.
54
55
  */
55
56
  export function readConfig(configPath = CONFIG_PATH, globalConfigPath = GLOBAL_CONFIG_PATH) {
56
- const global = readJsonFile(globalConfigPath);
57
- const project = readJsonFile(configPath);
57
+ // Resolve relative paths against process.cwd() — fs.readFileSync resolves
58
+ // against the OS working directory which may differ (e.g. worktrees, subdirs).
59
+ const global = readJsonFile(path.resolve(globalConfigPath));
60
+ const project = readJsonFile(path.resolve(configPath));
58
61
 
59
62
  // Project overrides global for each field (not deep merge — per-field fallthrough)
60
63
  const server = envStr("SWARM_MAP_SERVER") ?? project.map?.server ?? global.map?.server ?? undefined;
@@ -84,6 +87,7 @@ export function readConfig(configPath = CONFIG_PATH, globalConfigPath = GLOBAL_C
84
87
  sessionlog: {
85
88
  enabled: envBool("SWARM_SESSIONLOG_ENABLED") ?? Boolean(project.sessionlog?.enabled ?? global.sessionlog?.enabled),
86
89
  sync: envStr("SWARM_SESSIONLOG_SYNC") ?? project.sessionlog?.sync ?? global.sessionlog?.sync ?? DEFAULTS.sessionlogSync,
90
+ mode: envStr("SWARM_SESSIONLOG_MODE") ?? project.sessionlog?.mode ?? global.sessionlog?.mode ?? "auto",
87
91
  },
88
92
  opentasks: {
89
93
  enabled: envBool("SWARM_OPENTASKS_ENABLED") ?? Boolean(project.opentasks?.enabled ?? global.opentasks?.enabled),
@@ -0,0 +1,176 @@
1
+ /**
2
+ * content-provider.mjs — Trajectory content provider for claude-code-swarm
3
+ *
4
+ * Provides session transcript content for on-demand trajectory/content requests
5
+ * from the hub. Reads from sessionlog's session state to find the transcript,
6
+ * then serves the raw Claude Code JSONL.
7
+ *
8
+ * Two content sources:
9
+ * 1. Live session: reads directly from state.transcriptPath (active JSONL file)
10
+ * 2. Committed checkpoint: reads from sessionlog's checkpoint store (full.jsonl)
11
+ *
12
+ * Returns { metadata, transcript, prompts, context } matching the
13
+ * SessionContentProvider type from @multi-agent-protocol/sdk.
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import { SESSIONLOG_DIR } from "./paths.mjs";
19
+ import { resolvePackage } from "./swarmkit-resolver.mjs";
20
+ import { createLogger } from "./log.mjs";
21
+
22
+ const log = createLogger("content-provider");
23
+
24
+ /**
25
+ * Create a content provider function for the sidecar.
26
+ * The provider receives a checkpointId and returns transcript content.
27
+ *
28
+ * For live sessions, checkpointId may be the session ID or a checkpoint ID.
29
+ * We search sessionlog state to find the transcript path.
30
+ *
31
+ * @returns {Function} SessionContentProvider-compatible async function
32
+ */
33
+ export function createContentProvider() {
34
+ return async function provideContent(checkpointId) {
35
+ try {
36
+ // 1. Try to find a live session with this checkpoint or session ID
37
+ const liveContent = await readLiveSessionContent(checkpointId);
38
+ if (liveContent) return liveContent;
39
+
40
+ // 2. Try to read from committed checkpoint store
41
+ const committedContent = await readCommittedContent(checkpointId);
42
+ if (committedContent) return committedContent;
43
+
44
+ log.warn("content not found for checkpoint", { checkpointId });
45
+ return null;
46
+ } catch (err) {
47
+ log.warn("content provider error", { checkpointId, error: err.message });
48
+ return null;
49
+ }
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Read transcript content from a live (non-ended) sessionlog session.
55
+ * Searches all session state files for one that matches the checkpoint ID
56
+ * or has a matching session ID, then reads the transcript from disk.
57
+ */
58
+ async function readLiveSessionContent(checkpointId) {
59
+ if (!fs.existsSync(SESSIONLOG_DIR)) return null;
60
+
61
+ let files;
62
+ try {
63
+ files = fs.readdirSync(SESSIONLOG_DIR).filter(f => f.endsWith(".json"));
64
+ } catch {
65
+ return null;
66
+ }
67
+
68
+ for (const f of files) {
69
+ try {
70
+ const statePath = path.join(SESSIONLOG_DIR, f);
71
+ const state = JSON.parse(fs.readFileSync(statePath, "utf-8"));
72
+
73
+ // Match by session ID, checkpoint ID, or checkpoint in turnCheckpointIDs
74
+ const isMatch =
75
+ state.sessionID === checkpointId ||
76
+ state.lastCheckpointID === checkpointId ||
77
+ (state.turnCheckpointIDs || []).includes(checkpointId);
78
+
79
+ if (!isMatch) continue;
80
+
81
+ // Read the transcript from the path stored in session state
82
+ const transcriptPath = state.transcriptPath;
83
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
84
+ log.warn("transcript path not found", { sessionID: state.sessionID, transcriptPath });
85
+ continue;
86
+ }
87
+
88
+ const transcript = fs.readFileSync(transcriptPath, "utf-8");
89
+
90
+ // Extract prompts from transcript (user messages)
91
+ const prompts = extractPrompts(transcript);
92
+
93
+ return {
94
+ metadata: {
95
+ sessionID: state.sessionID,
96
+ phase: state.phase,
97
+ stepCount: state.stepCount || 0,
98
+ filesTouched: state.filesTouched || [],
99
+ tokenUsage: state.tokenUsage || {},
100
+ startedAt: state.startedAt,
101
+ endedAt: state.endedAt,
102
+ source: "live",
103
+ },
104
+ transcript,
105
+ prompts,
106
+ context: `Session ${state.sessionID} (${state.phase})`,
107
+ };
108
+ } catch {
109
+ // Skip malformed files
110
+ }
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Read transcript content from sessionlog's committed checkpoint store.
118
+ * Uses sessionlog's library API via resolvePackage.
119
+ */
120
+ async function readCommittedContent(checkpointId) {
121
+ try {
122
+ const sessionlogMod = await resolvePackage("sessionlog");
123
+ if (!sessionlogMod?.createCheckpointStore) return null;
124
+
125
+ const store = sessionlogMod.createCheckpointStore();
126
+ if (!store?.readSessionContent) return null;
127
+
128
+ // Try reading committed content (session index 0)
129
+ const content = await store.readSessionContent(checkpointId, 0);
130
+ if (!content) return null;
131
+
132
+ return {
133
+ metadata: {
134
+ ...content.metadata,
135
+ source: "committed",
136
+ },
137
+ transcript: content.transcript,
138
+ prompts: content.prompts,
139
+ context: content.context,
140
+ };
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Extract user prompts from a Claude Code JSONL transcript.
148
+ */
149
+ function extractPrompts(transcript) {
150
+ const prompts = [];
151
+ for (const line of transcript.split("\n")) {
152
+ if (!line.trim()) continue;
153
+ try {
154
+ const entry = JSON.parse(line);
155
+ if (entry.type === "user") {
156
+ const msg = entry.message;
157
+ if (typeof msg === "string") {
158
+ prompts.push(msg);
159
+ } else if (msg?.content) {
160
+ if (typeof msg.content === "string") {
161
+ prompts.push(msg.content);
162
+ } else if (Array.isArray(msg.content)) {
163
+ const text = msg.content
164
+ .filter(b => b.type === "text" && b.text)
165
+ .map(b => b.text)
166
+ .join("\n");
167
+ if (text) prompts.push(text);
168
+ }
169
+ }
170
+ }
171
+ } catch {
172
+ // Skip malformed lines
173
+ }
174
+ }
175
+ return prompts.join("\n---\n");
176
+ }
package/src/index.mjs CHANGED
@@ -80,10 +80,13 @@ export {
80
80
  // Sessionlog
81
81
  export {
82
82
  checkSessionlogStatus,
83
+ ensureSessionlogEnabled,
84
+ hasStandaloneHooks,
83
85
  findActiveSession,
84
86
  buildTrajectoryCheckpoint,
85
87
  syncSessionlog,
86
88
  annotateSwarmSession,
89
+ dispatchSessionlogHook,
87
90
  } from "./sessionlog.mjs";
88
91
 
89
92
  // Template
@@ -24,7 +24,7 @@ const log = createLogger("map");
24
24
  * authRequired challenge with the server's preferred method + this credential.
25
25
  * When absent, uses the standard SDK connect() for open mode servers.
26
26
  */
27
- export async function connectToMAP({ server, scope, systemId, onMessage, credential }) {
27
+ export async function connectToMAP({ server, scope, systemId, onMessage, credential, projectContext }) {
28
28
  try {
29
29
  const mapSdk = await resolvePackage("@multi-agent-protocol/sdk");
30
30
  if (!mapSdk) throw new Error("@multi-agent-protocol/sdk not available");
@@ -38,12 +38,16 @@ export async function connectToMAP({ server, scope, systemId, onMessage, credent
38
38
  role: "sidecar",
39
39
  scopes: [scope],
40
40
  capabilities: {
41
- trajectory: { canReport: true },
41
+ trajectory: { canReport: true, canServeContent: true },
42
42
  tasks: { canCreate: true, canAssign: true, canUpdate: true, canList: true },
43
+ ...(projectContext?.task_graph ? {
44
+ opentasks: { canQuery: true, canLink: true, canAnnotate: true, canTask: true },
45
+ } : {}),
43
46
  },
44
47
  metadata: {
45
48
  systemId,
46
49
  type: "claude-code-swarm-sidecar",
50
+ ...(projectContext || {}),
47
51
  },
48
52
  reconnection: {
49
53
  enabled: true,