claude-code-swarm 0.3.11 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Launch Claude Code with swarmkit capabilities, including team orchestration, MAP observability, and session tracking.",
5
5
  "owner": {
6
6
  "name": "alexngai"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
3
  "description": "Spin up Claude Code agent teams from openteams YAML topologies with optional MAP (Multi-Agent Protocol) observability and coordination. Provides hooks for session lifecycle, agent spawn/complete tracking, and a /swarm skill to launch team configurations.",
4
- "version": "0.3.11",
4
+ "version": "0.3.12",
5
5
  "author": {
6
6
  "name": "alexngai"
7
7
  },
@@ -12,9 +12,7 @@
12
12
  "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
13
13
  "opentasks"
14
14
  ],
15
- "env": {
16
- "OPENTASKS_WORKING_DIR": "${workspaceFolder}"
17
- }
15
+ "env": {}
18
16
  },
19
17
  "agent-inbox": {
20
18
  "command": "node",
@@ -30,9 +28,7 @@
30
28
  "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/mcp-launcher.mjs",
31
29
  "minimem"
32
30
  ],
33
- "env": {
34
- "MINIMEM_WORKING_DIR": "${workspaceFolder}"
35
- }
31
+ "env": {}
36
32
  }
37
33
  }
38
34
  }
package/hooks/hooks.json CHANGED
@@ -7,6 +7,21 @@
7
7
  {
8
8
  "type": "command",
9
9
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.mjs\""
10
+ },
11
+ {
12
+ "type": "command",
13
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch session-start; fi"
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ "SessionEnd": [
19
+ {
20
+ "matcher": "",
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch session-end; fi"
10
25
  }
11
26
  ]
12
27
  }
@@ -18,6 +33,21 @@
18
33
  {
19
34
  "type": "command",
20
35
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8')).map||{};process.exit(c.enabled||c.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" inject; fi"
36
+ },
37
+ {
38
+ "type": "command",
39
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch user-prompt-submit; fi"
40
+ }
41
+ ]
42
+ }
43
+ ],
44
+ "PreToolUse": [
45
+ {
46
+ "matcher": "Task",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch pre-task; fi"
21
51
  }
22
52
  ]
23
53
  }
@@ -32,12 +62,25 @@
32
62
  }
33
63
  ]
34
64
  },
65
+ {
66
+ "matcher": "Task",
67
+ "hooks": [
68
+ {
69
+ "type": "command",
70
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-task; fi"
71
+ }
72
+ ]
73
+ },
35
74
  {
36
75
  "matcher": "TaskCreate",
37
76
  "hooks": [
38
77
  {
39
78
  "type": "command",
40
79
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8')).map||{};process.exit(c.enabled||c.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" native-task-created; fi"
80
+ },
81
+ {
82
+ "type": "command",
83
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-task-create; fi"
41
84
  }
42
85
  ]
43
86
  },
@@ -47,6 +90,46 @@
47
90
  {
48
91
  "type": "command",
49
92
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8')).map||{};process.exit(c.enabled||c.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" native-task-updated; fi"
93
+ },
94
+ {
95
+ "type": "command",
96
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-task-update; fi"
97
+ }
98
+ ]
99
+ },
100
+ {
101
+ "matcher": "TodoWrite",
102
+ "hooks": [
103
+ {
104
+ "type": "command",
105
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-todo; fi"
106
+ }
107
+ ]
108
+ },
109
+ {
110
+ "matcher": "EnterPlanMode",
111
+ "hooks": [
112
+ {
113
+ "type": "command",
114
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-plan-enter; fi"
115
+ }
116
+ ]
117
+ },
118
+ {
119
+ "matcher": "ExitPlanMode",
120
+ "hooks": [
121
+ {
122
+ "type": "command",
123
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-plan-exit; fi"
124
+ }
125
+ ]
126
+ },
127
+ {
128
+ "matcher": "Skill",
129
+ "hooks": [
130
+ {
131
+ "type": "command",
132
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch post-skill; fi"
50
133
  }
51
134
  ]
52
135
  }
@@ -62,6 +145,10 @@
62
145
  {
63
146
  "type": "command",
64
147
  "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));const m=c.map||{};process.exit((m.enabled||m.server||process.env.SWARM_MAP_SERVER||process.env.SWARM_MAP_ENABLED)&&c.sessionlog?.sync&&c.sessionlog.sync!=='off'?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-sync; fi"
148
+ },
149
+ {
150
+ "type": "command",
151
+ "command": "if [ -f .swarm/claude-swarm/config.json ] && node -e \"const c=JSON.parse(require('fs').readFileSync('.swarm/claude-swarm/config.json','utf-8'));process.exit(c.sessionlog?.enabled?0:1)\" 2>/dev/null; then node \"${CLAUDE_PLUGIN_ROOT}/scripts/map-hook.mjs\" sessionlog-dispatch stop; fi"
65
152
  }
66
153
  ]
67
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Claude Code plugin for launching agent teams from openteams topologies with MAP observability",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,6 +19,7 @@
19
19
  * teammate-idle — Update teammate state to idle
20
20
  * task-completed — Complete task in opentasks + emit bridge event
21
21
  * opentasks-mcp-used — Bridge opentasks MCP tool use → MAP task sync payload
22
+ * sessionlog-dispatch — Dispatch a sessionlog lifecycle hook via programmatic API
22
23
  *
23
24
  * Usage: node map-hook.mjs <action>
24
25
  * Hook event data is read from stdin (JSON).
@@ -48,7 +49,7 @@ import {
48
49
  handleNativeTaskCreatedEvent,
49
50
  handleNativeTaskUpdatedEvent,
50
51
  } from "../src/map-events.mjs";
51
- import { syncSessionlog } from "../src/sessionlog.mjs";
52
+ import { syncSessionlog, dispatchSessionlogHook } from "../src/sessionlog.mjs";
52
53
  import { findSocketPath, pushSyncEvent } from "../src/opentasks-client.mjs";
53
54
 
54
55
  const action = process.argv[2];
@@ -184,6 +185,15 @@ async function handleNativeTaskUpdated(hookData, sessionId) {
184
185
  await handleNativeTaskUpdatedEvent(config, hookData, sessionId);
185
186
  }
186
187
 
188
+ async function handleSessionlogDispatch(hookData) {
189
+ const sessionlogHookName = process.argv[3];
190
+ if (!sessionlogHookName) {
191
+ log.warn("sessionlog-dispatch: missing hook name argument");
192
+ return;
193
+ }
194
+ await dispatchSessionlogHook(sessionlogHookName, hookData);
195
+ }
196
+
187
197
  // ── Main ──────────────────────────────────────────────────────────────────────
188
198
 
189
199
  async function main() {
@@ -204,6 +214,7 @@ async function main() {
204
214
  case "opentasks-mcp-used": await handleOpentasksMcpUsed(hookData, sessionId); break;
205
215
  case "native-task-created": await handleNativeTaskCreated(hookData, sessionId); break;
206
216
  case "native-task-updated": await handleNativeTaskUpdated(hookData, sessionId); break;
217
+ case "sessionlog-dispatch": await handleSessionlogDispatch(hookData); break;
207
218
  default:
208
219
  log.warn("unknown action", { action });
209
220
  }
@@ -22,6 +22,8 @@ vi.mock("../map-events.mjs", () => ({
22
22
 
23
23
  vi.mock("../sessionlog.mjs", () => ({
24
24
  checkSessionlogStatus: vi.fn(() => "not installed"),
25
+ ensureSessionlogEnabled: vi.fn().mockResolvedValue(false),
26
+ hasStandaloneHooks: vi.fn().mockReturnValue(false),
25
27
  syncSessionlog: vi.fn().mockResolvedValue(undefined),
26
28
  annotateSwarmSession: vi.fn().mockResolvedValue(undefined),
27
29
  }));
@@ -86,7 +88,7 @@ const { bootstrap, backgroundInit } = await import("../bootstrap.mjs");
86
88
  const { readConfig } = await import("../config.mjs");
87
89
  const { killSidecar, startSidecar } = await import("../sidecar-client.mjs");
88
90
  const { sendCommand } = await import("../map-events.mjs");
89
- const { checkSessionlogStatus, syncSessionlog, annotateSwarmSession } = await import("../sessionlog.mjs");
91
+ const { checkSessionlogStatus, ensureSessionlogEnabled, hasStandaloneHooks, syncSessionlog, annotateSwarmSession } = await import("../sessionlog.mjs");
90
92
  const { pluginDir, ensureOpentasksDir, ensureSessionDir, listSessionDirs } = await import("../paths.mjs");
91
93
  const { findSocketPath, isDaemonAlive, ensureDaemon } = await import("../opentasks-client.mjs");
92
94
  const { resolveSwarmkit, configureNodePath } = await import("../swarmkit-resolver.mjs");
@@ -161,16 +163,43 @@ describe("bootstrap", () => {
161
163
  });
162
164
 
163
165
  describe("sessionlog", () => {
164
- it("returns 'checking' when sessionlog enabled (actual check is in background)", async () => {
166
+ it("defers to standalone when standalone hooks are present", async () => {
167
+ hasStandaloneHooks.mockReturnValue(true);
165
168
  readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
166
169
  const result = await bootstrap();
167
- expect(result.sessionlogStatus).toBe("checking");
170
+ expect(result.sessionlogStatus).toBe("active (standalone)");
171
+ expect(ensureSessionlogEnabled).not.toHaveBeenCalled();
168
172
  });
169
173
 
170
- it("does not call checkSessionlogStatus synchronously", async () => {
174
+ it("enables sessionlog when no standalone hooks", async () => {
175
+ hasStandaloneHooks.mockReturnValue(false);
176
+ ensureSessionlogEnabled.mockResolvedValue(true);
171
177
  readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
178
+ const result = await bootstrap();
179
+ expect(result.sessionlogStatus).toBe("active");
180
+ expect(ensureSessionlogEnabled).toHaveBeenCalled();
181
+ });
182
+
183
+ it("reports status when enable fails", async () => {
184
+ hasStandaloneHooks.mockReturnValue(false);
185
+ ensureSessionlogEnabled.mockResolvedValue(false);
186
+ readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
187
+ const result = await bootstrap();
188
+ expect(result.sessionlogStatus).toBe("installed but not enabled");
189
+ });
190
+
191
+ it("returns 'checking' when hasStandaloneHooks throws", async () => {
192
+ hasStandaloneHooks.mockImplementation(() => { throw new Error("unexpected"); });
193
+ readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: true }));
194
+ const result = await bootstrap();
195
+ expect(result.sessionlogStatus).toBe("checking");
196
+ });
197
+
198
+ it("does not check standalone when disabled", async () => {
199
+ readConfig.mockReturnValue(makeConfig({ sessionlogEnabled: false }));
172
200
  await bootstrap();
173
- expect(checkSessionlogStatus).not.toHaveBeenCalled();
201
+ expect(hasStandaloneHooks).not.toHaveBeenCalled();
202
+ expect(ensureSessionlogEnabled).not.toHaveBeenCalled();
174
203
  });
175
204
  });
176
205
 
@@ -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
+ });
@@ -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", () => {
128
+ const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
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", () => {
96
134
  const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
97
- expect(cp.label).toContain("Turn turn-5");
98
- expect(cp.label).toContain("step 10");
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,161 @@ 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", () => {
135
- const cp = buildTrajectoryCheckpoint(baseState, "lifecycle", makeConfig());
136
- expect(cp.sessionId).toBe("sess-123");
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
+
193
+ describe("ensureSessionlogEnabled", () => {
194
+ beforeEach(() => {
195
+ vi.mocked(execSync).mockReset();
196
+ });
197
+
198
+ it("returns true immediately when sessionlog is already active", async () => {
199
+ // checkSessionlogStatus calls execSync twice: `which sessionlog` and `sessionlog status`
200
+ vi.mocked(execSync)
201
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
202
+ .mockImplementationOnce(() => "enabled: true\nstrategy: manual-commit"); // status
203
+ const result = await ensureSessionlogEnabled();
204
+ expect(result).toBe(true);
205
+ });
206
+
207
+ it("returns false when sessionlog is not installed", async () => {
208
+ vi.mocked(execSync).mockImplementationOnce(() => { throw new Error("not found"); }); // which
209
+ const result = await ensureSessionlogEnabled();
210
+ expect(result).toBe(false);
211
+ });
212
+
213
+ it("attempts CLI enable when installed but not enabled and resolvePackage returns null", async () => {
214
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
215
+ vi.mocked(resolvePackage).mockResolvedValue(null);
216
+
217
+ // First two calls: checkSessionlogStatus (which + status)
218
+ // Third call: CLI fallback `sessionlog enable --agent claude-code`
219
+ vi.mocked(execSync)
220
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
221
+ .mockImplementationOnce(() => "enabled: false") // status → not enabled
222
+ .mockImplementationOnce(() => ""); // sessionlog enable succeeds
223
+ const result = await ensureSessionlogEnabled();
224
+ expect(result).toBe(true);
225
+ });
226
+
227
+ it("returns false when both programmatic and CLI enable fail", async () => {
228
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
229
+ vi.mocked(resolvePackage).mockResolvedValue(null);
230
+
231
+ vi.mocked(execSync)
232
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
233
+ .mockImplementationOnce(() => "enabled: false") // status
234
+ .mockImplementationOnce(() => { throw new Error("enable failed"); }); // CLI fails
235
+ const result = await ensureSessionlogEnabled();
236
+ expect(result).toBe(false);
237
+ });
238
+
239
+ it("tries programmatic API before CLI fallback", async () => {
240
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
241
+ const mockEnable = vi.fn().mockResolvedValue({ enabled: true });
242
+ vi.mocked(resolvePackage).mockResolvedValue({ enable: mockEnable });
243
+
244
+ vi.mocked(execSync)
245
+ .mockImplementationOnce(() => "/usr/local/bin/sessionlog") // which
246
+ .mockImplementationOnce(() => "enabled: false"); // status → not enabled
247
+ const result = await ensureSessionlogEnabled();
248
+ expect(result).toBe(true);
249
+ expect(mockEnable).toHaveBeenCalledWith({ agent: "claude-code", skipAgentHooks: true });
250
+ });
251
+ });
252
+
253
+ describe("dispatchSessionlogHook", () => {
254
+ function mockSessionlog(overrides = {}) {
255
+ return {
256
+ isEnabled: vi.fn().mockResolvedValue(true),
257
+ getAgent: vi.fn().mockReturnValue({ parseHookEvent: vi.fn().mockReturnValue({ type: "SessionStart" }) }),
258
+ hasHookSupport: vi.fn().mockReturnValue(true),
259
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: vi.fn() }),
260
+ createSessionStore: vi.fn().mockReturnValue({}),
261
+ createCheckpointStore: vi.fn().mockReturnValue({}),
262
+ ...overrides,
263
+ };
264
+ }
265
+
266
+ beforeEach(() => {
267
+ vi.mocked(execSync).mockReset();
268
+ });
269
+
270
+ it("skips dispatch when mode is 'standalone'", async () => {
271
+ const { readConfig } = await import("../config.mjs");
272
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "standalone" } });
273
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
274
+ const mod = mockSessionlog();
275
+ vi.mocked(resolvePackage).mockResolvedValue(mod);
276
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
277
+ expect(mod.createLifecycleHandler().dispatch).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it("dispatches when mode is 'plugin'", async () => {
281
+ const { readConfig } = await import("../config.mjs");
282
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
283
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
284
+ const mockDispatch = vi.fn();
285
+ const mockEvent = { type: "SessionStart", sessionID: "s1" };
286
+ const mockAgent = { parseHookEvent: vi.fn().mockReturnValue(mockEvent) };
287
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
288
+ getAgent: vi.fn().mockReturnValue(mockAgent),
289
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
290
+ }));
291
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
292
+ expect(mockDispatch).toHaveBeenCalledWith(mockAgent, mockEvent);
293
+ });
294
+
295
+ it("bails silently when sessionlog package is not available", async () => {
296
+ const { readConfig } = await import("../config.mjs");
297
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
298
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
299
+ vi.mocked(resolvePackage).mockResolvedValue(null);
300
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
301
+ });
302
+
303
+ it("bails when isEnabled returns false", async () => {
304
+ const { readConfig } = await import("../config.mjs");
305
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
306
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
307
+ const mockDispatch = vi.fn();
308
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
309
+ isEnabled: vi.fn().mockResolvedValue(false),
310
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
311
+ }));
312
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
313
+ expect(mockDispatch).not.toHaveBeenCalled();
314
+ });
315
+
316
+ it("bails when parseHookEvent returns null", async () => {
317
+ const { readConfig } = await import("../config.mjs");
318
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
319
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
320
+ const mockDispatch = vi.fn();
321
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
322
+ getAgent: vi.fn().mockReturnValue({ parseHookEvent: vi.fn().mockReturnValue(null) }),
323
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
324
+ }));
325
+ await dispatchSessionlogHook("unknown-hook", {});
326
+ expect(mockDispatch).not.toHaveBeenCalled();
327
+ });
328
+
329
+ it("bails when getAgent returns null", async () => {
330
+ const { readConfig } = await import("../config.mjs");
331
+ vi.mocked(readConfig).mockReturnValue({ sessionlog: { enabled: true, sync: "off", mode: "plugin" } });
332
+ const { resolvePackage } = await import("../swarmkit-resolver.mjs");
333
+ const mockDispatch = vi.fn();
334
+ vi.mocked(resolvePackage).mockResolvedValue(mockSessionlog({
335
+ getAgent: vi.fn().mockReturnValue(null),
336
+ createLifecycleHandler: vi.fn().mockReturnValue({ dispatch: mockDispatch }),
337
+ }));
338
+ await dispatchSessionlogHook("session-start", { session_id: "s1" });
339
+ expect(mockDispatch).not.toHaveBeenCalled();
137
340
  });
138
341
  });
139
342
  });
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),
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
@@ -10,6 +10,7 @@ import fs from "fs";
10
10
  import path from "path";
11
11
  import { execSync } from "child_process";
12
12
  import { SESSIONLOG_DIR, SESSIONLOG_STATE_PATH, sessionPaths } from "./paths.mjs";
13
+ import { readConfig } from "./config.mjs";
13
14
  import { resolveTeamName, resolveScope } from "./config.mjs";
14
15
  import { sendToSidecar, ensureSidecar } from "./sidecar-client.mjs";
15
16
  import { fireAndForgetTrajectory } from "./map-connection.mjs";
@@ -40,6 +41,60 @@ export function checkSessionlogStatus() {
40
41
  }
41
42
  }
42
43
 
44
+ /**
45
+ * Check if sessionlog's standalone hooks are installed in .claude/settings.json.
46
+ * Reads the file directly — no dependency on resolvePackage("sessionlog").
47
+ * Looks for any SessionStart hook command containing "sessionlog " as a sentinel
48
+ * (if session-start is there, all 12 hooks were installed together).
49
+ */
50
+ export function hasStandaloneHooks() {
51
+ try {
52
+ const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
53
+ const content = fs.readFileSync(settingsPath, "utf-8");
54
+ const settings = JSON.parse(content);
55
+ const hooks = settings.hooks?.SessionStart ?? [];
56
+ return hooks.some(m => m.hooks?.some(h => h.command?.includes("sessionlog ")));
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Auto-enable sessionlog if it is installed but not yet enabled.
64
+ * Tries the programmatic API first (dynamic import), then falls back to CLI.
65
+ * Best-effort — returns true if enabled, false otherwise. Never throws.
66
+ */
67
+ export async function ensureSessionlogEnabled() {
68
+ const status = checkSessionlogStatus();
69
+ if (status === "active") return true;
70
+ if (status === "not installed") return false;
71
+
72
+ // Status is "installed but not enabled" — try to enable it
73
+
74
+ // 1. Try programmatic API via dynamic import
75
+ // skipAgentHooks: true — agent hooks are managed by cc-swarm's hooks.json
76
+ try {
77
+ const sessionlogMod = await resolvePackage("sessionlog");
78
+ if (sessionlogMod?.enable) {
79
+ const result = await sessionlogMod.enable({ agent: "claude-code", skipAgentHooks: true });
80
+ if (result.enabled) return true;
81
+ }
82
+ } catch {
83
+ // Fall through to CLI
84
+ }
85
+
86
+ // 2. Fallback to CLI
87
+ try {
88
+ execSync("sessionlog enable --agent claude-code --skip-agent-hooks", {
89
+ stdio: "ignore",
90
+ timeout: 15_000,
91
+ });
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
43
98
  /**
44
99
  * Find the active (non-ended) sessionlog session file.
45
100
  * Returns parsed SessionState or null.
@@ -79,31 +134,54 @@ export function findActiveSession(sessionlogDir = SESSIONLOG_DIR) {
79
134
 
80
135
  /**
81
136
  * Build a MAP TrajectoryCheckpoint from sessionlog state.
82
- * Metadata contents are filtered by sync level.
137
+ *
138
+ * Conforms to sessionlog's SessionSyncCheckpoint wire format (snake_case,
139
+ * top-level fields) so OpenHive's sync listener can extract fields correctly.
140
+ * Extra sessionlog-specific fields go in `metadata` for passthrough.
83
141
  */
84
142
  export function buildTrajectoryCheckpoint(state, syncLevel, config) {
85
143
  const teamName = resolveTeamName(config);
86
- const agentId = `${teamName}-sidecar`;
87
144
 
88
145
  const id =
89
146
  state.lastCheckpointID ||
90
147
  `${state.sessionID}-step${state.stepCount || 0}`;
91
148
 
92
- const label = `Turn ${state.turnID || "?"} (step ${state.stepCount || 0}, ${state.phase || "unknown"})`;
149
+ // Wire format fields (top-level, snake_case) always present
150
+ const checkpoint = {
151
+ id,
152
+ session_id: state.sessionID,
153
+ agent: `${teamName}-sidecar`,
154
+ files_touched: [],
155
+ checkpoints_count: 0,
156
+ };
93
157
 
158
+ // Metadata — sessionlog-specific fields for passthrough
94
159
  const metadata = {
95
160
  phase: state.phase,
96
161
  turnId: state.turnID,
97
162
  startedAt: state.startedAt,
163
+ label: `Turn ${state.turnID || "?"} (step ${state.stepCount || 0}, ${state.phase || "unknown"})`,
98
164
  };
99
165
  if (state.endedAt) metadata.endedAt = state.endedAt;
100
166
 
101
167
  if (syncLevel === "metrics" || syncLevel === "full") {
168
+ // Promote to top-level wire format fields
169
+ checkpoint.files_touched = state.filesTouched || [];
170
+ checkpoint.checkpoints_count = (state.turnCheckpointIDs || []).length;
171
+ if (state.tokenUsage) {
172
+ checkpoint.token_usage = {
173
+ input_tokens: state.tokenUsage.inputTokens ?? state.tokenUsage.input ?? 0,
174
+ output_tokens: state.tokenUsage.outputTokens ?? state.tokenUsage.output ?? 0,
175
+ cache_creation_tokens: state.tokenUsage.cacheCreationTokens ?? 0,
176
+ cache_read_tokens: state.tokenUsage.cacheReadTokens ?? 0,
177
+ api_call_count: state.tokenUsage.apiCallCount ?? 0,
178
+ };
179
+ }
180
+
181
+ // Keep in metadata for sessionlog consumers
102
182
  metadata.stepCount = state.stepCount;
103
- metadata.filesTouched = state.filesTouched;
104
183
  metadata.lastCheckpointID = state.lastCheckpointID;
105
184
  metadata.turnCheckpointIDs = state.turnCheckpointIDs;
106
- if (state.tokenUsage) metadata.tokenUsage = state.tokenUsage;
107
185
  }
108
186
 
109
187
  if (syncLevel === "full") {
@@ -114,13 +192,7 @@ export function buildTrajectoryCheckpoint(state, syncLevel, config) {
114
192
  }
115
193
  }
116
194
 
117
- return {
118
- id,
119
- agentId,
120
- sessionId: state.sessionID,
121
- label,
122
- metadata,
123
- };
195
+ return { ...checkpoint, metadata };
124
196
  }
125
197
 
126
198
  /**
@@ -203,3 +275,63 @@ export async function annotateSwarmSession(config, sessionId) {
203
275
  // Non-critical — session may not exist yet or annotate failed
204
276
  }
205
277
  }
278
+
279
+ /**
280
+ * Dispatch a sessionlog hook event programmatically.
281
+ * Replaces the CLI pattern: `sessionlog hooks claude-code <hookName>`
282
+ * Uses resolvePackage("sessionlog") to call the lifecycle handler directly.
283
+ * Best-effort — never throws.
284
+ *
285
+ * @param {string} hookName - Sessionlog hook name (e.g. "session-start", "stop")
286
+ * @param {object} hookData - Raw hook event data from Claude Code stdin
287
+ */
288
+ export async function dispatchSessionlogHook(hookName, hookData) {
289
+ // Decide whether plugin dispatch should handle this hook.
290
+ // config.sessionlog.mode: "plugin" (always dispatch), "standalone" (never dispatch), "auto" (check)
291
+ const config = readConfig();
292
+ const mode = config.sessionlog?.mode || "auto";
293
+ if (mode === "standalone") return;
294
+ if (mode === "auto" && hasStandaloneHooks()) return;
295
+
296
+ let sessionlogMod;
297
+ try {
298
+ sessionlogMod = await resolvePackage("sessionlog");
299
+ } catch {
300
+ return;
301
+ }
302
+ if (!sessionlogMod) return;
303
+
304
+ const {
305
+ isEnabled,
306
+ getAgent,
307
+ hasHookSupport,
308
+ createLifecycleHandler,
309
+ createSessionStore,
310
+ createCheckpointStore,
311
+ } = sessionlogMod;
312
+
313
+ // Pass cwd explicitly — sessionlog's defaults use git rev-parse which
314
+ // resolves against the OS working directory, not process.cwd().
315
+ const cwd = process.cwd();
316
+
317
+ // Bail if sessionlog is not enabled in this repo
318
+ try {
319
+ if (typeof isEnabled === "function" && !(await isEnabled(cwd))) return;
320
+ } catch {
321
+ return;
322
+ }
323
+
324
+ const agent = getAgent("claude-code");
325
+ if (!agent || (typeof hasHookSupport === "function" && !hasHookSupport(agent))) return;
326
+
327
+ const event = agent.parseHookEvent(hookName, JSON.stringify(hookData));
328
+ if (!event) return;
329
+
330
+ const handler = createLifecycleHandler({
331
+ sessionStore: createSessionStore(cwd),
332
+ checkpointStore: createCheckpointStore(cwd),
333
+ cwd,
334
+ });
335
+
336
+ await handler.dispatch(agent, event);
337
+ }