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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -7
- package/hooks/hooks.json +87 -0
- package/package.json +1 -1
- package/scripts/map-hook.mjs +12 -1
- package/src/__tests__/bootstrap.test.mjs +34 -5
- package/src/__tests__/helpers.mjs +1 -0
- package/src/__tests__/sessionlog-e2e.test.mjs +270 -0
- package/src/__tests__/sessionlog.test.mjs +217 -14
- package/src/bootstrap.mjs +17 -4
- package/src/config.mjs +6 -2
- package/src/index.mjs +3 -0
- package/src/sessionlog.mjs +144 -12
|
@@ -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.
|
|
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
package/scripts/map-hook.mjs
CHANGED
|
@@ -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("
|
|
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("
|
|
170
|
+
expect(result.sessionlogStatus).toBe("active (standalone)");
|
|
171
|
+
expect(ensureSessionlogEnabled).not.toHaveBeenCalled();
|
|
168
172
|
});
|
|
169
173
|
|
|
170
|
-
it("
|
|
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(
|
|
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 {
|
|
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", () => {
|
|
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.
|
|
98
|
-
expect(cp.
|
|
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,161 @@ describe("sessionlog", () => {
|
|
|
131
182
|
expect(cp.metadata.endedAt).toBe("2024-01-01T01:00:00Z");
|
|
132
183
|
});
|
|
133
184
|
|
|
134
|
-
it("
|
|
135
|
-
const
|
|
136
|
-
|
|
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
|
|
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),
|
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/sessionlog.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|