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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -7
- package/hooks/hooks.json +97 -10
- package/package.json +3 -3
- package/scripts/map-hook.mjs +12 -1
- package/scripts/map-sidecar.mjs +106 -0
- package/src/__tests__/bootstrap.test.mjs +34 -5
- package/src/__tests__/helpers.mjs +1 -0
- package/src/__tests__/opentasks-connector.test.mjs +216 -0
- package/src/__tests__/sessionlog-e2e.test.mjs +270 -0
- package/src/__tests__/sessionlog.test.mjs +256 -13
- package/src/__tests__/sidecar-server.test.mjs +2 -2
- package/src/bootstrap.mjs +17 -4
- package/src/config.mjs +6 -2
- package/src/content-provider.mjs +176 -0
- package/src/index.mjs +3 -0
- package/src/map-connection.mjs +6 -2
- package/src/opentasks-connector.mjs +86 -0
- package/src/sessionlog.mjs +163 -12
- package/src/sidecar-server.mjs +16 -7
|
@@ -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 {
|
|
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: {
|
|
102
|
+
tokenUsage: { inputTokens: 1000, outputTokens: 500, cacheCreationTokens: 50, cacheReadTokens: 200, apiCallCount: 3 },
|
|
76
103
|
extraField: "extra",
|
|
77
104
|
};
|
|
78
105
|
|
|
79
|
-
it("sets
|
|
106
|
+
it("sets agent to teamName-sidecar (wire format)", () => {
|
|
80
107
|
const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
|
|
81
|
-
expect(cp.
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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",
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
package/src/map-connection.mjs
CHANGED
|
@@ -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,
|