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.
- package/README.md +192 -192
- package/dist/captureHookInstall.d.ts +127 -0
- package/dist/captureHookInstall.d.ts.map +1 -0
- package/dist/captureHookInstall.js +256 -0
- package/dist/captureHookInstall.js.map +1 -0
- package/dist/captureHookInstall.test.d.ts +10 -0
- package/dist/captureHookInstall.test.d.ts.map +1 -0
- package/dist/captureHookInstall.test.js +325 -0
- package/dist/captureHookInstall.test.js.map +1 -0
- package/dist/channel.js +99 -3
- package/dist/channel.js.map +1 -1
- package/dist/client.d.ts +80 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +197 -4
- package/dist/client.js.map +1 -1
- package/dist/client.unit.test.js +50 -0
- package/dist/client.unit.test.js.map +1 -1
- package/dist/daemon.js +217 -3
- package/dist/daemon.js.map +1 -1
- package/dist/hooks/periodicReminder.js +9 -9
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/session-capture.sh +410 -0
- package/dist/sessionCapture.d.ts +163 -0
- package/dist/sessionCapture.d.ts.map +1 -0
- package/dist/sessionCapture.js +279 -0
- package/dist/sessionCapture.js.map +1 -0
- package/dist/sessionCapture.test.d.ts +9 -0
- package/dist/sessionCapture.test.d.ts.map +1 -0
- package/dist/sessionCapture.test.js +366 -0
- package/dist/sessionCapture.test.js.map +1 -0
- package/dist/sessionCaptureScript.test.d.ts +16 -0
- package/dist/sessionCaptureScript.test.d.ts.map +1 -0
- package/dist/sessionCaptureScript.test.js +92 -0
- package/dist/sessionCaptureScript.test.js.map +1 -0
- package/dist/setup.js +56 -56
- package/dist/steering.test.d.ts +2 -0
- package/dist/steering.test.d.ts.map +1 -0
- package/dist/steering.test.js +223 -0
- package/dist/steering.test.js.map +1 -0
- package/dist/streamingRuntimeLib.d.ts +391 -0
- package/dist/streamingRuntimeLib.d.ts.map +1 -0
- package/dist/streamingRuntimeLib.js +628 -0
- package/dist/streamingRuntimeLib.js.map +1 -0
- package/dist/streamingRuntimeLib.test.d.ts +2 -0
- package/dist/streamingRuntimeLib.test.d.ts.map +1 -0
- package/dist/streamingRuntimeLib.test.js +233 -0
- package/dist/streamingRuntimeLib.test.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +149 -13
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/openspec-viz/diagram-generator.js +54 -54
- package/dist/tools/openspec-viz/html-generator.js +503 -503
- package/dist/tools/sessionControl.d.ts +53 -0
- package/dist/tools/sessionControl.d.ts.map +1 -0
- package/dist/tools/sessionControl.js +265 -0
- package/dist/tools/sessionControl.js.map +1 -0
- package/dist/tools/sessionControl.test.d.ts +14 -0
- package/dist/tools/sessionControl.test.d.ts.map +1 -0
- package/dist/tools/sessionControl.test.js +298 -0
- package/dist/tools/sessionControl.test.js.map +1 -0
- package/dist/tools/tools.test.js +5 -2
- package/dist/tools/tools.test.js.map +1 -1
- package/dist/tools/zohoAttachments.test.d.ts +10 -0
- package/dist/tools/zohoAttachments.test.d.ts.map +1 -0
- package/dist/tools/zohoAttachments.test.js +204 -0
- package/dist/tools/zohoAttachments.test.js.map +1 -0
- package/dist/worker.js +10 -10
- package/native/agenthub-memvid/agenthub-memvid.linux-x64-gnu.node +0 -0
- package/native/agenthub-memvid/index.d.ts +58 -58
- package/native/agenthub-memvid/index.js +319 -319
- package/package.json +77 -77
- package/skills/catalog.json +76 -76
- package/skills/commands/close-session.md +144 -144
- package/skills/manifest.json +50 -50
- package/skills/skills/code-brain-hook/SKILL.md +61 -61
- package/skills/skills/file-bug/SKILL.md +75 -75
- 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
|