agenthub-multiagent-mcp 1.51.0 → 1.53.0

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.
Files changed (78) hide show
  1. package/README.md +192 -192
  2. package/dist/captureHookInstall.d.ts +127 -0
  3. package/dist/captureHookInstall.d.ts.map +1 -0
  4. package/dist/captureHookInstall.js +256 -0
  5. package/dist/captureHookInstall.js.map +1 -0
  6. package/dist/captureHookInstall.test.d.ts +10 -0
  7. package/dist/captureHookInstall.test.d.ts.map +1 -0
  8. package/dist/captureHookInstall.test.js +325 -0
  9. package/dist/captureHookInstall.test.js.map +1 -0
  10. package/dist/channel.js +99 -3
  11. package/dist/channel.js.map +1 -1
  12. package/dist/client.d.ts +80 -1
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +197 -4
  15. package/dist/client.js.map +1 -1
  16. package/dist/client.unit.test.js +50 -0
  17. package/dist/client.unit.test.js.map +1 -1
  18. package/dist/daemon.js +217 -3
  19. package/dist/daemon.js.map +1 -1
  20. package/dist/hooks/periodicReminder.js +9 -9
  21. package/dist/index.js +25 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/session-capture.sh +410 -0
  24. package/dist/sessionCapture.d.ts +163 -0
  25. package/dist/sessionCapture.d.ts.map +1 -0
  26. package/dist/sessionCapture.js +279 -0
  27. package/dist/sessionCapture.js.map +1 -0
  28. package/dist/sessionCapture.test.d.ts +9 -0
  29. package/dist/sessionCapture.test.d.ts.map +1 -0
  30. package/dist/sessionCapture.test.js +366 -0
  31. package/dist/sessionCapture.test.js.map +1 -0
  32. package/dist/sessionCaptureScript.test.d.ts +16 -0
  33. package/dist/sessionCaptureScript.test.d.ts.map +1 -0
  34. package/dist/sessionCaptureScript.test.js +92 -0
  35. package/dist/sessionCaptureScript.test.js.map +1 -0
  36. package/dist/setup.js +56 -56
  37. package/dist/steering.test.d.ts +2 -0
  38. package/dist/steering.test.d.ts.map +1 -0
  39. package/dist/steering.test.js +223 -0
  40. package/dist/steering.test.js.map +1 -0
  41. package/dist/streamingRuntimeLib.d.ts +391 -0
  42. package/dist/streamingRuntimeLib.d.ts.map +1 -0
  43. package/dist/streamingRuntimeLib.js +628 -0
  44. package/dist/streamingRuntimeLib.js.map +1 -0
  45. package/dist/streamingRuntimeLib.test.d.ts +2 -0
  46. package/dist/streamingRuntimeLib.test.d.ts.map +1 -0
  47. package/dist/streamingRuntimeLib.test.js +233 -0
  48. package/dist/streamingRuntimeLib.test.js.map +1 -0
  49. package/dist/tools/index.d.ts.map +1 -1
  50. package/dist/tools/index.js +149 -13
  51. package/dist/tools/index.js.map +1 -1
  52. package/dist/tools/openspec-viz/diagram-generator.js +54 -54
  53. package/dist/tools/openspec-viz/html-generator.js +503 -503
  54. package/dist/tools/sessionControl.d.ts +53 -0
  55. package/dist/tools/sessionControl.d.ts.map +1 -0
  56. package/dist/tools/sessionControl.js +265 -0
  57. package/dist/tools/sessionControl.js.map +1 -0
  58. package/dist/tools/sessionControl.test.d.ts +14 -0
  59. package/dist/tools/sessionControl.test.d.ts.map +1 -0
  60. package/dist/tools/sessionControl.test.js +298 -0
  61. package/dist/tools/sessionControl.test.js.map +1 -0
  62. package/dist/tools/tools.test.js +5 -2
  63. package/dist/tools/tools.test.js.map +1 -1
  64. package/dist/tools/zohoAttachments.test.d.ts +10 -0
  65. package/dist/tools/zohoAttachments.test.d.ts.map +1 -0
  66. package/dist/tools/zohoAttachments.test.js +204 -0
  67. package/dist/tools/zohoAttachments.test.js.map +1 -0
  68. package/dist/worker.js +10 -10
  69. package/native/agenthub-memvid/agenthub-memvid.linux-x64-gnu.node +0 -0
  70. package/native/agenthub-memvid/index.d.ts +58 -58
  71. package/native/agenthub-memvid/index.js +319 -319
  72. package/package.json +77 -77
  73. package/skills/catalog.json +76 -76
  74. package/skills/commands/close-session.md +144 -144
  75. package/skills/manifest.json +50 -50
  76. package/skills/skills/code-brain-hook/SKILL.md +61 -61
  77. package/skills/skills/file-bug/SKILL.md +75 -75
  78. package/skills/skills/multiagent-brainstorm/SKILL.md +142 -142
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Session-capture wiring for daemon shifts (add-multiplayer-sessions §1.5).
3
+ *
4
+ * The capture hook (`session-capture.sh`) has always existed but has never
5
+ * produced anything for the live fleet, because two halves were missing:
6
+ *
7
+ * 1. Nothing created a coding session, so `AGENTHUB_SESSION_ID` was never set
8
+ * and the hook exited at its first guard (`session-capture.sh:14`).
9
+ * 2. Nothing registered the hook for the spawned Claude process — the MCP
10
+ * installers cover only the inbox and activity hooks.
11
+ *
12
+ * This module supplies both, and is the single place the daemon touches for
13
+ * capture concerns. Everything here is **fail-soft**: a capture failure logs and
14
+ * returns a null/empty result so the shift still runs. A shift must never die
15
+ * for an observability reason.
16
+ *
17
+ * Gated on `AGENTHUB_SESSION_STREAMING_ENABLED` (default off, matching the
18
+ * server-side flag added in §1.2). With the flag off, `beginShiftCapture`
19
+ * performs no network call, no filesystem access, and returns `null`, and
20
+ * `applyCaptureEnv` leaves the child env untouched — the shift is byte-for-byte
21
+ * what it is today.
22
+ */
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ /** Default bound on every capture HTTP call. Shift startup must not block on it. */
26
+ export const CAPTURE_TIMEOUT_MS = 5000;
27
+ /**
28
+ * Identifies our PostToolUse entry inside a settings object so repeated merges
29
+ * never duplicate it (same marker discipline as activityHookInstall.ts /
30
+ * inboxHookInstall.ts, which key off the CLI filename in the command string).
31
+ */
32
+ export const CAPTURE_HOOK_MARKER = "session-capture.sh";
33
+ /**
34
+ * Live session streaming kill switch. Default **off** — an unset, empty, or
35
+ * unrecognised value means disabled, so the producer stays silent unless an
36
+ * operator deliberately turns it on.
37
+ */
38
+ export function sessionStreamingEnabled(env = process.env) {
39
+ return /^(1|true|yes|on)$/i.test((env.AGENTHUB_SESSION_STREAMING_ENABLED || "").trim());
40
+ }
41
+ /**
42
+ * Merge the capture PostToolUse hook into a settings object **without**
43
+ * discarding anything already there: other hook events, other PostToolUse
44
+ * entries, and every non-`hooks` key are preserved. Idempotent — a settings
45
+ * object that already carries the capture hook is returned unchanged.
46
+ *
47
+ * Returns a new object; the input is never mutated.
48
+ */
49
+ export function mergeCaptureHook(base, command, timeoutSeconds = 3) {
50
+ const merged = { ...base };
51
+ const hooks = { ...(base.hooks ?? {}) };
52
+ const postToolUse = Array.isArray(hooks.PostToolUse) ? [...hooks.PostToolUse] : [];
53
+ const alreadyPresent = postToolUse.some((entry) => (entry?.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes(CAPTURE_HOOK_MARKER)));
54
+ if (!alreadyPresent) {
55
+ postToolUse.push({
56
+ matcher: "",
57
+ hooks: [{ type: "command", command, timeout: timeoutSeconds }],
58
+ });
59
+ }
60
+ hooks.PostToolUse = postToolUse;
61
+ merged.hooks = hooks;
62
+ return merged;
63
+ }
64
+ /**
65
+ * Locate `session-capture.sh` relative to the compiled module directory.
66
+ * Ordered most-specific first:
67
+ * 1. `dist/session-capture.sh` — the published npm package (build copies it)
68
+ * 2. `mcp-server/session-capture.sh` — running from a source checkout's dist/
69
+ * 3. `<repo>/.claude/hooks/session-capture.sh` — repo checkout fallback
70
+ * Returns `null` when the script cannot be found; the caller then runs the shift
71
+ * without capture rather than inventing a degraded inline copy.
72
+ */
73
+ export function resolveCaptureHookScript(moduleDir, exists = fs.existsSync) {
74
+ const candidates = [
75
+ path.join(moduleDir, "session-capture.sh"),
76
+ path.join(moduleDir, "..", "session-capture.sh"),
77
+ path.join(moduleDir, "..", "..", ".claude", "hooks", "session-capture.sh"),
78
+ ];
79
+ for (const candidate of candidates) {
80
+ if (exists(candidate))
81
+ return candidate.replace(/\\/g, "/");
82
+ }
83
+ return null;
84
+ }
85
+ /** Shell command Claude Code runs for the PostToolUse capture hook. */
86
+ export function captureHookCommand(scriptPath) {
87
+ return `bash "${scriptPath}"`;
88
+ }
89
+ /**
90
+ * Build a memoised factory that materialises the per-daemon-process settings
91
+ * file registering the capture hook, and returns its path ("" when the hook
92
+ * script is unavailable or the write fails).
93
+ *
94
+ * **Why a dedicated file rather than editing `~/.claude/settings.json`:** the
95
+ * daemon already passes a settings file to `run-claude.sh` (arg 4 → `claude
96
+ * --settings`). Writing a fresh file scoped to the spawned process means no
97
+ * user-owned or workspace-owned settings file is ever rewritten, so there is
98
+ * nothing to clobber — a strictly stronger guarantee than merging in place. The
99
+ * file contains only our hook, so hook entries from the user's own settings
100
+ * sources are neither dropped nor double-registered. `mergeCaptureHook` is used
101
+ * (rather than a literal) so the same non-destructive merge is exercised here
102
+ * and reusable by the in-place installer Phase 3 will need for human sessions.
103
+ */
104
+ export function createCaptureSettingsFactory(moduleDir, settingsPath, deps = {}) {
105
+ const exists = deps.exists ?? fs.existsSync;
106
+ const writeFile = deps.writeFile ?? ((p, data) => fs.writeFileSync(p, data));
107
+ const log = deps.log ?? (() => { });
108
+ let cached = null;
109
+ return function ensureCaptureSettingsFile() {
110
+ if (cached !== null)
111
+ return cached;
112
+ const script = resolveCaptureHookScript(moduleDir, exists);
113
+ if (!script) {
114
+ log("session capture: session-capture.sh not found next to the daemon build — " +
115
+ "shift runs without the capture hook");
116
+ cached = "";
117
+ return cached;
118
+ }
119
+ try {
120
+ const settings = mergeCaptureHook({}, captureHookCommand(script));
121
+ writeFile(settingsPath, JSON.stringify(settings, null, 2));
122
+ cached = settingsPath.replace(/\\/g, "/");
123
+ }
124
+ catch (e) {
125
+ log(`session capture: could not write settings file — ${errText(e)}`);
126
+ cached = "";
127
+ }
128
+ return cached;
129
+ };
130
+ }
131
+ /**
132
+ * Create the coding session that a shift will be captured under.
133
+ *
134
+ * Uses `POST /api/sessions` (`handleCreateSession`) — the plain record-creating
135
+ * endpoint. Deliberately **not** `POST /api/sessions/start`: that one also calls
136
+ * `Orchestrator().DispatchSession`, i.e. it asks a daemon to start a shift. The
137
+ * daemon calling it would dispatch a second shift for work it is already doing.
138
+ *
139
+ * Never throws and never blocks beyond `timeoutMs`. Returns the session id, or
140
+ * `null` when the session could not be created — the caller then runs the shift
141
+ * uncaptured.
142
+ */
143
+ export async function openCaptureSession(opts) {
144
+ const log = opts.log ?? (() => { });
145
+ const body = { agent_id: opts.agentId };
146
+ if (opts.title)
147
+ body.title = opts.title.slice(0, 200);
148
+ if (opts.workingDirectory)
149
+ body.working_directory = opts.workingDirectory;
150
+ if (opts.projectId)
151
+ body.project_id = opts.projectId;
152
+ const parsed = await captureRequest(opts, "POST", "/api/sessions", body, "create");
153
+ if (parsed === null)
154
+ return null;
155
+ const id = parsed?.id;
156
+ if (typeof id !== "string" || id === "") {
157
+ log("session capture: create returned no session id — shift runs without capture");
158
+ return null;
159
+ }
160
+ return id;
161
+ }
162
+ /**
163
+ * Close a captured session. Uses `POST /api/sessions/{id}/terminate`, the only
164
+ * endpoint that moves a session to a terminal state and stamps a reason.
165
+ * Never throws — a shift that already finished must not fail on cleanup.
166
+ */
167
+ export async function closeCaptureSession(sessionId, reason, opts) {
168
+ const parsed = await captureRequest(opts, "POST", `/api/sessions/${encodeURIComponent(sessionId)}/terminate`, { termination_reason: reason }, "terminate");
169
+ return parsed !== null;
170
+ }
171
+ /**
172
+ * Remove the hook's per-session scratch markers.
173
+ *
174
+ * `session-capture.sh` keys both of its markers by `AGENTHUB_SESSION_ID`
175
+ * (`/tmp/agenthub-transcript-offset-<id>`, `/tmp/agenthub-claude-sid-<id>`), so
176
+ * concurrent shifts on one host never collide — each shift gets its own session
177
+ * id. They are, however, never cleaned up by the script itself, so the daemon
178
+ * removes them when the session closes. Best-effort: absent files are fine.
179
+ */
180
+ export function cleanupCaptureMarkers(sessionId, tmpDir = "/tmp", removeFile = fs.unlinkSync) {
181
+ for (const name of [`agenthub-transcript-offset-${sessionId}`, `agenthub-claude-sid-${sessionId}`]) {
182
+ try {
183
+ removeFile(path.join(tmpDir, name));
184
+ }
185
+ catch {
186
+ /* never existed, or already gone */
187
+ }
188
+ }
189
+ }
190
+ /**
191
+ * Open a session and register the capture hook for one shift.
192
+ *
193
+ * Returns `null` — meaning "run the shift uncaptured" — when streaming is
194
+ * disabled or the session could not be created. With streaming disabled this
195
+ * returns before any I/O of any kind.
196
+ */
197
+ export async function beginShiftCapture(opts) {
198
+ if (!sessionStreamingEnabled(opts.env ?? process.env))
199
+ return null;
200
+ const sessionId = await openCaptureSession(opts);
201
+ if (!sessionId)
202
+ return null;
203
+ return { sessionId, settingsFile: opts.ensureSettingsFile() };
204
+ }
205
+ /** Close a shift's session and clean up its hook markers. Never throws. */
206
+ export async function endShiftCapture(capture, outcome, opts) {
207
+ if (!capture)
208
+ return;
209
+ await closeCaptureSession(capture.sessionId, outcome, opts);
210
+ cleanupCaptureMarkers(capture.sessionId, opts.tmpDir ?? "/tmp");
211
+ }
212
+ /**
213
+ * Stamp the capture variables onto a shift's child environment.
214
+ *
215
+ * `AGENTHUB_SESSION_ID` is what un-gates `session-capture.sh`; the URL and key
216
+ * are re-asserted because the hook reads them from its own environment and
217
+ * would otherwise silently fall back to the compiled-in default host.
218
+ *
219
+ * With `capture === null` the env object is returned untouched — this is the
220
+ * off-path guarantee, expressed as a single early return.
221
+ */
222
+ export function applyCaptureEnv(env, capture, apiUrl, apiKey) {
223
+ if (!capture)
224
+ return env;
225
+ env.AGENTHUB_SESSION_ID = capture.sessionId;
226
+ env.AGENTHUB_URL = apiUrl;
227
+ env.AGENTHUB_API_KEY = apiKey;
228
+ return env;
229
+ }
230
+ // ---------------------------------------------------------------------------
231
+ // internals
232
+ // ---------------------------------------------------------------------------
233
+ function errText(e) {
234
+ return e instanceof Error ? e.message : String(e);
235
+ }
236
+ function trimTrailingSlash(url) {
237
+ return url.replace(/\/+$/, "");
238
+ }
239
+ /**
240
+ * One bounded, never-throwing JSON request. Returns the parsed body on success
241
+ * (`{}` when the body is not JSON) or `null` on any failure, so every caller
242
+ * has exactly one degradation path.
243
+ */
244
+ async function captureRequest(opts, method, route, body, label) {
245
+ const log = opts.log ?? (() => { });
246
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
247
+ if (!doFetch) {
248
+ log(`session capture: ${label} skipped — no fetch implementation available`);
249
+ return null;
250
+ }
251
+ const controller = new AbortController();
252
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? CAPTURE_TIMEOUT_MS);
253
+ try {
254
+ const res = await doFetch(`${trimTrailingSlash(opts.apiUrl)}${route}`, {
255
+ method,
256
+ headers: { "Content-Type": "application/json", "X-API-Key": opts.apiKey },
257
+ body: JSON.stringify(body),
258
+ signal: controller.signal,
259
+ });
260
+ if (!res.ok) {
261
+ log(`session capture: ${label} failed (HTTP ${res.status}) — continuing without capture`);
262
+ return null;
263
+ }
264
+ try {
265
+ return (await res.json()) ?? {};
266
+ }
267
+ catch {
268
+ return {};
269
+ }
270
+ }
271
+ catch (e) {
272
+ log(`session capture: ${label} failed (${errText(e)}) — continuing without capture`);
273
+ return null;
274
+ }
275
+ finally {
276
+ clearTimeout(timer);
277
+ }
278
+ }
279
+ //# sourceMappingURL=sessionCapture.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionCapture.js","sourceRoot":"","sources":["../src/sessionCapture.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * add-multiplayer-sessions §1.5 — daemon session-capture wiring.
3
+ *
4
+ * The load-bearing properties are (a) the flag-off path is completely inert,
5
+ * (b) every capture failure degrades to "run the shift uncaptured" instead of
6
+ * throwing, and (c) hook registration never destroys existing hook config.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=sessionCapture.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionCapture.test.d.ts","sourceRoot":"","sources":["../src/sessionCapture.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,366 @@
1
+ /**
2
+ * add-multiplayer-sessions §1.5 — daemon session-capture wiring.
3
+ *
4
+ * The load-bearing properties are (a) the flag-off path is completely inert,
5
+ * (b) every capture failure degrades to "run the shift uncaptured" instead of
6
+ * throwing, and (c) hook registration never destroys existing hook config.
7
+ */
8
+ import { describe, it, expect, vi } from "vitest";
9
+ import { buildShiftEnv } from "./daemonPolicy.js";
10
+ import { sessionStreamingEnabled, mergeCaptureHook, resolveCaptureHookScript, captureHookCommand, createCaptureSettingsFactory, openCaptureSession, closeCaptureSession, cleanupCaptureMarkers, beginShiftCapture, endShiftCapture, applyCaptureEnv, CAPTURE_HOOK_MARKER, } from "./sessionCapture.js";
11
+ function okFetch(payload, status = 201) {
12
+ return vi.fn(async () => ({ ok: true, status, json: async () => payload }));
13
+ }
14
+ function failFetch(status = 500) {
15
+ return vi.fn(async () => ({ ok: false, status, json: async () => ({}) }));
16
+ }
17
+ const API = { apiUrl: "https://hub.example.com", apiKey: "k-1" };
18
+ // ---------------------------------------------------------------------------
19
+ // Kill switch
20
+ // ---------------------------------------------------------------------------
21
+ describe("sessionStreamingEnabled", () => {
22
+ it("defaults off when the variable is unset", () => {
23
+ expect(sessionStreamingEnabled({})).toBe(false);
24
+ });
25
+ it("stays off for empty, false, and unrecognised values", () => {
26
+ for (const v of ["", " ", "false", "0", "off", "no", "maybe"]) {
27
+ expect(sessionStreamingEnabled({ AGENTHUB_SESSION_STREAMING_ENABLED: v })).toBe(false);
28
+ }
29
+ });
30
+ it("turns on only for explicit truthy values", () => {
31
+ for (const v of ["1", "true", "TRUE", "yes", "on", " true "]) {
32
+ expect(sessionStreamingEnabled({ AGENTHUB_SESSION_STREAMING_ENABLED: v })).toBe(true);
33
+ }
34
+ });
35
+ });
36
+ // ---------------------------------------------------------------------------
37
+ // Hook registration — merge, never overwrite
38
+ // ---------------------------------------------------------------------------
39
+ describe("mergeCaptureHook", () => {
40
+ it("adds the capture hook to an empty settings object", () => {
41
+ const merged = mergeCaptureHook({}, captureHookCommand("/opt/h/session-capture.sh"));
42
+ const hooks = merged.hooks;
43
+ expect(hooks.PostToolUse).toHaveLength(1);
44
+ expect(hooks.PostToolUse[0].hooks[0].command).toContain(CAPTURE_HOOK_MARKER);
45
+ });
46
+ it("preserves existing PostToolUse entries instead of overwriting them", () => {
47
+ const base = {
48
+ hooks: {
49
+ PostToolUse: [
50
+ { matcher: "", hooks: [{ type: "command", command: 'node "/x/activityHookCli.js"' }] },
51
+ ],
52
+ },
53
+ };
54
+ const merged = mergeCaptureHook(base, captureHookCommand("/opt/h/session-capture.sh"));
55
+ const entries = merged.hooks
56
+ .PostToolUse;
57
+ expect(entries).toHaveLength(2);
58
+ expect(entries[0].hooks[0].command).toContain("activityHookCli");
59
+ expect(entries[1].hooks[0].command).toContain(CAPTURE_HOOK_MARKER);
60
+ });
61
+ it("preserves other hook events and unrelated settings keys", () => {
62
+ const base = {
63
+ permissions: { allow: ["Bash"] },
64
+ hooks: {
65
+ SessionStart: [
66
+ { matcher: "startup", hooks: [{ type: "command", command: 'node "/x/inboxHookCli.js"' }] },
67
+ ],
68
+ },
69
+ };
70
+ const merged = mergeCaptureHook(base, captureHookCommand("/opt/h/session-capture.sh"));
71
+ const hooks = merged.hooks;
72
+ expect(merged.permissions).toEqual({ allow: ["Bash"] });
73
+ expect(hooks.SessionStart).toHaveLength(1);
74
+ expect(hooks.PostToolUse).toHaveLength(1);
75
+ });
76
+ it("is idempotent — re-merging never duplicates the capture hook", () => {
77
+ const cmd = captureHookCommand("/opt/h/session-capture.sh");
78
+ const once = mergeCaptureHook({}, cmd);
79
+ const twice = mergeCaptureHook(once, cmd);
80
+ expect(twice.hooks.PostToolUse).toHaveLength(1);
81
+ });
82
+ it("does not mutate the input settings object", () => {
83
+ const base = { hooks: { PostToolUse: [] } };
84
+ mergeCaptureHook(base, captureHookCommand("/x/session-capture.sh"));
85
+ expect(base.hooks.PostToolUse).toHaveLength(0);
86
+ });
87
+ });
88
+ describe("resolveCaptureHookScript", () => {
89
+ it("prefers the packaged dist/ copy", () => {
90
+ const found = resolveCaptureHookScript("/opt/app/dist", () => true);
91
+ expect(found).toBe("/opt/app/dist/session-capture.sh");
92
+ });
93
+ it("falls back to the source-checkout copy beside dist/", () => {
94
+ const found = resolveCaptureHookScript("/repo/mcp-server/dist", (p) => p.replace(/\\/g, "/").endsWith("/repo/mcp-server/session-capture.sh"));
95
+ expect(found).toBe("/repo/mcp-server/session-capture.sh");
96
+ });
97
+ it("returns null when the script is nowhere to be found", () => {
98
+ expect(resolveCaptureHookScript("/opt/app/dist", () => false)).toBeNull();
99
+ });
100
+ });
101
+ describe("createCaptureSettingsFactory", () => {
102
+ it("writes a settings file containing only the capture hook", () => {
103
+ const writes = [];
104
+ const factory = createCaptureSettingsFactory("/opt/app/dist", "/tmp/s.json", {
105
+ exists: () => true,
106
+ writeFile: (p, d) => writes.push([p, d]),
107
+ });
108
+ expect(factory()).toBe("/tmp/s.json");
109
+ expect(writes).toHaveLength(1);
110
+ const parsed = JSON.parse(writes[0][1]);
111
+ expect(Object.keys(parsed)).toEqual(["hooks"]);
112
+ expect(parsed.hooks.PostToolUse[0].hooks[0].command).toContain(CAPTURE_HOOK_MARKER);
113
+ });
114
+ it("memoises — repeated calls write the file once", () => {
115
+ const writeFile = vi.fn();
116
+ const factory = createCaptureSettingsFactory("/opt/app/dist", "/tmp/s.json", {
117
+ exists: () => true,
118
+ writeFile,
119
+ });
120
+ factory();
121
+ factory();
122
+ factory();
123
+ expect(writeFile).toHaveBeenCalledTimes(1);
124
+ });
125
+ it("returns '' (no hook) when the capture script is missing, and logs", () => {
126
+ const log = vi.fn();
127
+ const factory = createCaptureSettingsFactory("/opt/app/dist", "/tmp/s.json", {
128
+ exists: () => false,
129
+ writeFile: () => {
130
+ throw new Error("should not be called");
131
+ },
132
+ log,
133
+ });
134
+ expect(factory()).toBe("");
135
+ expect(log).toHaveBeenCalled();
136
+ });
137
+ it("returns '' rather than throwing when the settings write fails", () => {
138
+ const factory = createCaptureSettingsFactory("/opt/app/dist", "/tmp/s.json", {
139
+ exists: () => true,
140
+ writeFile: () => {
141
+ throw new Error("EACCES");
142
+ },
143
+ });
144
+ expect(factory()).toBe("");
145
+ });
146
+ });
147
+ // ---------------------------------------------------------------------------
148
+ // Session lifecycle
149
+ // ---------------------------------------------------------------------------
150
+ describe("openCaptureSession", () => {
151
+ it("POSTs to /api/sessions (never /sessions/start, which dispatches a shift)", async () => {
152
+ const fetchImpl = okFetch({ id: "sess-1" });
153
+ const id = await openCaptureSession({
154
+ ...API,
155
+ agentId: "eng-ic-test",
156
+ title: "task shift",
157
+ workingDirectory: "/opt/ws",
158
+ fetchImpl,
159
+ });
160
+ expect(id).toBe("sess-1");
161
+ const [url, init] = fetchImpl.mock.calls[0];
162
+ expect(url).toBe("https://hub.example.com/api/sessions");
163
+ expect(init.method).toBe("POST");
164
+ expect(init.headers["X-API-Key"]).toBe("k-1");
165
+ expect(JSON.parse(init.body)).toEqual({
166
+ agent_id: "eng-ic-test",
167
+ title: "task shift",
168
+ working_directory: "/opt/ws",
169
+ });
170
+ });
171
+ it("returns null on a non-2xx response without throwing", async () => {
172
+ const id = await openCaptureSession({ ...API, agentId: "a", fetchImpl: failFetch(500) });
173
+ expect(id).toBeNull();
174
+ });
175
+ it("returns null when the transport throws (network down, abort)", async () => {
176
+ const fetchImpl = async () => {
177
+ throw new Error("ECONNREFUSED");
178
+ };
179
+ const id = await openCaptureSession({ ...API, agentId: "a", fetchImpl });
180
+ expect(id).toBeNull();
181
+ });
182
+ it("returns null when the response carries no session id", async () => {
183
+ const id = await openCaptureSession({ ...API, agentId: "a", fetchImpl: okFetch({}) });
184
+ expect(id).toBeNull();
185
+ });
186
+ it("aborts rather than hanging when the server is slow", async () => {
187
+ const fetchImpl = (_url, init) => new Promise((_resolve, reject) => {
188
+ const signal = init.signal;
189
+ signal.addEventListener("abort", () => reject(new Error("aborted")));
190
+ });
191
+ const id = await openCaptureSession({ ...API, agentId: "a", fetchImpl, timeoutMs: 10 });
192
+ expect(id).toBeNull();
193
+ });
194
+ });
195
+ describe("closeCaptureSession", () => {
196
+ it("POSTs to /{id}/terminate with the outcome as the reason", async () => {
197
+ const fetchImpl = okFetch({ status: "terminated" }, 200);
198
+ const ok = await closeCaptureSession("sess-1", "completed", { ...API, fetchImpl });
199
+ expect(ok).toBe(true);
200
+ const [url, init] = fetchImpl.mock.calls[0];
201
+ expect(url).toBe("https://hub.example.com/api/sessions/sess-1/terminate");
202
+ expect(JSON.parse(init.body)).toEqual({ termination_reason: "completed" });
203
+ });
204
+ it("reports failure instead of throwing when the server rejects", async () => {
205
+ const ok = await closeCaptureSession("sess-1", "failed", { ...API, fetchImpl: failFetch(404) });
206
+ expect(ok).toBe(false);
207
+ });
208
+ });
209
+ describe("cleanupCaptureMarkers", () => {
210
+ it("removes both per-session markers, keyed by session id", () => {
211
+ const removed = [];
212
+ cleanupCaptureMarkers("sess-1", "/tmp", (p) => removed.push(p.replace(/\\/g, "/")));
213
+ expect(removed).toEqual([
214
+ "/tmp/agenthub-transcript-offset-sess-1",
215
+ "/tmp/agenthub-claude-sid-sess-1",
216
+ ]);
217
+ });
218
+ it("swallows ENOENT for markers that were never written", () => {
219
+ expect(() => cleanupCaptureMarkers("sess-1", "/tmp", () => {
220
+ throw new Error("ENOENT");
221
+ })).not.toThrow();
222
+ });
223
+ it("gives concurrent shifts disjoint marker paths", () => {
224
+ const a = [];
225
+ const b = [];
226
+ cleanupCaptureMarkers("sess-a", "/tmp", (p) => a.push(p));
227
+ cleanupCaptureMarkers("sess-b", "/tmp", (p) => b.push(p));
228
+ expect(a.some((p) => b.includes(p))).toBe(false);
229
+ });
230
+ });
231
+ // ---------------------------------------------------------------------------
232
+ // Shift wiring
233
+ // ---------------------------------------------------------------------------
234
+ describe("beginShiftCapture", () => {
235
+ it("is completely inert with the flag off — no fetch, no settings file", async () => {
236
+ const fetchImpl = vi.fn();
237
+ const ensureSettingsFile = vi.fn(() => "/tmp/s.json");
238
+ const capture = await beginShiftCapture({
239
+ ...API,
240
+ agentId: "a",
241
+ env: {},
242
+ ensureSettingsFile,
243
+ fetchImpl: fetchImpl,
244
+ });
245
+ expect(capture).toBeNull();
246
+ expect(fetchImpl).not.toHaveBeenCalled();
247
+ expect(ensureSettingsFile).not.toHaveBeenCalled();
248
+ });
249
+ it("stays inert when the flag is explicitly false", async () => {
250
+ const fetchImpl = vi.fn();
251
+ const capture = await beginShiftCapture({
252
+ ...API,
253
+ agentId: "a",
254
+ env: { AGENTHUB_SESSION_STREAMING_ENABLED: "false" },
255
+ ensureSettingsFile: () => "/tmp/s.json",
256
+ fetchImpl: fetchImpl,
257
+ });
258
+ expect(capture).toBeNull();
259
+ expect(fetchImpl).not.toHaveBeenCalled();
260
+ });
261
+ it("with the flag on, returns the session id and the settings file", async () => {
262
+ const capture = await beginShiftCapture({
263
+ ...API,
264
+ agentId: "a",
265
+ env: { AGENTHUB_SESSION_STREAMING_ENABLED: "true" },
266
+ ensureSettingsFile: () => "/tmp/s.json",
267
+ fetchImpl: okFetch({ id: "sess-9" }),
268
+ });
269
+ expect(capture).toEqual({ sessionId: "sess-9", settingsFile: "/tmp/s.json" });
270
+ });
271
+ it("degrades to no capture (not a throw) when session creation fails", async () => {
272
+ const ensureSettingsFile = vi.fn(() => "/tmp/s.json");
273
+ const capture = await beginShiftCapture({
274
+ ...API,
275
+ agentId: "a",
276
+ env: { AGENTHUB_SESSION_STREAMING_ENABLED: "true" },
277
+ ensureSettingsFile,
278
+ fetchImpl: failFetch(503),
279
+ });
280
+ expect(capture).toBeNull();
281
+ expect(ensureSettingsFile).not.toHaveBeenCalled();
282
+ });
283
+ it("still opens the session when the hook script is unavailable", async () => {
284
+ const capture = await beginShiftCapture({
285
+ ...API,
286
+ agentId: "a",
287
+ env: { AGENTHUB_SESSION_STREAMING_ENABLED: "true" },
288
+ ensureSettingsFile: () => "",
289
+ fetchImpl: okFetch({ id: "sess-9" }),
290
+ });
291
+ expect(capture).toEqual({ sessionId: "sess-9", settingsFile: "" });
292
+ });
293
+ });
294
+ describe("endShiftCapture", () => {
295
+ it("is a no-op when the shift was never captured", async () => {
296
+ const fetchImpl = vi.fn();
297
+ await endShiftCapture(null, "completed", { ...API, fetchImpl: fetchImpl });
298
+ expect(fetchImpl).not.toHaveBeenCalled();
299
+ });
300
+ it("terminates the session on the success path", async () => {
301
+ const fetchImpl = okFetch({}, 200);
302
+ await endShiftCapture({ sessionId: "s", settingsFile: "" }, "completed", { ...API, fetchImpl });
303
+ const [, init] = fetchImpl.mock.calls[0];
304
+ expect(JSON.parse(init.body).termination_reason).toBe("completed");
305
+ });
306
+ it("terminates the session on the failure path too", async () => {
307
+ const fetchImpl = okFetch({}, 200);
308
+ await endShiftCapture({ sessionId: "s", settingsFile: "" }, "failed", { ...API, fetchImpl });
309
+ const [, init] = fetchImpl.mock.calls[0];
310
+ expect(JSON.parse(init.body).termination_reason).toBe("failed");
311
+ });
312
+ it("does not throw when termination itself fails", async () => {
313
+ await expect(endShiftCapture({ sessionId: "s", settingsFile: "" }, "spawn_error", {
314
+ ...API,
315
+ fetchImpl: failFetch(500),
316
+ })).resolves.toBeUndefined();
317
+ });
318
+ });
319
+ describe("applyCaptureEnv", () => {
320
+ it("leaves the child env untouched when there is no capture", () => {
321
+ const env = { PATH: "/usr/bin", AGENTHUB_AGENT_ID: "a" };
322
+ const before = { ...env };
323
+ applyCaptureEnv(env, null, API.apiUrl, API.apiKey);
324
+ expect(env).toEqual(before);
325
+ expect(env.AGENTHUB_SESSION_ID).toBeUndefined();
326
+ });
327
+ it("sets AGENTHUB_SESSION_ID plus the URL/key the hook reads", () => {
328
+ const capture = { sessionId: "sess-7", settingsFile: "/tmp/s.json" };
329
+ const env = { PATH: "/usr/bin" };
330
+ applyCaptureEnv(env, capture, API.apiUrl, API.apiKey);
331
+ expect(env.AGENTHUB_SESSION_ID).toBe("sess-7");
332
+ expect(env.AGENTHUB_URL).toBe(API.apiUrl);
333
+ expect(env.AGENTHUB_API_KEY).toBe(API.apiKey);
334
+ expect(env.PATH).toBe("/usr/bin");
335
+ });
336
+ });
337
+ // ---------------------------------------------------------------------------
338
+ // The composition daemon.ts actually performs:
339
+ // applyCaptureEnv(buildShiftEnv(process.env, AGENT_ID), capture, URL, KEY)
340
+ // ---------------------------------------------------------------------------
341
+ describe("daemon shift env composition", () => {
342
+ const processEnv = {
343
+ PATH: "/usr/bin",
344
+ CLAUDECODE: "1",
345
+ CLAUDE_CODE_OAUTH_TOKEN: "oauth-abc",
346
+ AGENTHUB_API_KEY: "k-1",
347
+ };
348
+ it("flag off: the child env is exactly the historical shift env", () => {
349
+ const historical = buildShiftEnv(processEnv, "eng-ic-test");
350
+ const actual = applyCaptureEnv(buildShiftEnv(processEnv, "eng-ic-test"), null, API.apiUrl, API.apiKey);
351
+ expect(actual).toEqual(historical);
352
+ expect(actual.AGENTHUB_SESSION_ID).toBeUndefined();
353
+ });
354
+ it("flag on: AGENTHUB_SESSION_ID reaches the child, and buildShiftEnv's own rules survive", () => {
355
+ const capture = { sessionId: "sess-42", settingsFile: "/tmp/s.json" };
356
+ const env = applyCaptureEnv(buildShiftEnv(processEnv, "eng-ic-test"), capture, API.apiUrl, API.apiKey);
357
+ expect(env.AGENTHUB_SESSION_ID).toBe("sess-42");
358
+ // buildShiftEnv invariants must not be disturbed by capture.
359
+ expect(env.AGENTHUB_AGENT_ID).toBe("eng-ic-test");
360
+ expect(env.AGENTHUB_AUTO_INBOX).toBe("false");
361
+ expect(env.AGENTHUB_DISABLE_WEBSOCKET).toBe("true");
362
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oauth-abc");
363
+ expect(env.CLAUDECODE).toBeUndefined();
364
+ });
365
+ });
366
+ //# sourceMappingURL=sessionCapture.test.js.map