skalpel 3.4.16 → 3.4.18
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/package.json +6 -6
- package/postinstall/lib/prosumer-hooks.js +2 -0
- package/postinstall/lib/prosumer-hooks.test.js +42 -0
- package/prosumer-hooks/bootstrap.mjs +147 -24
- package/prosumer-hooks/bootstrap.test.mjs +93 -0
- package/prosumer-hooks/incremental-ingest.mjs +530 -0
- package/prosumer-hooks/incremental-ingest.test.mjs +320 -0
- package/prosumer-hooks/install.mjs +28 -5
- package/prosumer-hooks/skalpel-hook-session-end.mjs +21 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import { gunzipSync } from "node:zlib";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
drainIncrementalOutbox,
|
|
17
|
+
enqueueCompletedSession,
|
|
18
|
+
pendingItems,
|
|
19
|
+
} from "./incremental-ingest.mjs";
|
|
20
|
+
|
|
21
|
+
function fixture() {
|
|
22
|
+
const home = mkdtempSync(join(tmpdir(), "skalpel-session-end-"));
|
|
23
|
+
const dir = join(home, ".claude", "projects", "repo");
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
const transcriptPath = join(dir, "session-123.jsonl");
|
|
26
|
+
const transcript =
|
|
27
|
+
'{"type":"user","message":{"content":"implement it"}}\n' +
|
|
28
|
+
'{"type":"assistant","message":{"content":"done"}}\n';
|
|
29
|
+
writeFileSync(transcriptPath, transcript);
|
|
30
|
+
return {
|
|
31
|
+
home,
|
|
32
|
+
transcript,
|
|
33
|
+
payload: {
|
|
34
|
+
hook_event_name: "SessionEnd",
|
|
35
|
+
session_id: "session-123",
|
|
36
|
+
transcript_path: transcriptPath,
|
|
37
|
+
reason: "other",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function jsonResponse(body, status = 200) {
|
|
43
|
+
return new Response(JSON.stringify(body), {
|
|
44
|
+
status,
|
|
45
|
+
headers: { "content-type": "application/json" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const auth = async () => ({ uid: "user-123", token: "token-123" });
|
|
50
|
+
const noInsight = () => {};
|
|
51
|
+
|
|
52
|
+
test("SessionEnd snapshots one allowed transcript and deduplicates repeated events", () => {
|
|
53
|
+
const f = fixture();
|
|
54
|
+
try {
|
|
55
|
+
const first = enqueueCompletedSession(f.payload, { home: f.home });
|
|
56
|
+
const second = enqueueCompletedSession(f.payload, { home: f.home });
|
|
57
|
+
assert.equal(first.key, second.key);
|
|
58
|
+
assert.equal(first.session_id, "session-123");
|
|
59
|
+
assert.equal(pendingItems(f.home).length, 1);
|
|
60
|
+
|
|
61
|
+
const outside = join(f.home, "secret.jsonl");
|
|
62
|
+
writeFileSync(outside, "secret");
|
|
63
|
+
assert.equal(
|
|
64
|
+
enqueueCompletedSession({ ...f.payload, transcript_path: outside }, { home: f.home }),
|
|
65
|
+
null,
|
|
66
|
+
"a forged hook payload must not upload arbitrary local files",
|
|
67
|
+
);
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(f.home, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("accepted job sends exactly the completed transcript and records delivery", async () => {
|
|
74
|
+
const f = fixture();
|
|
75
|
+
try {
|
|
76
|
+
enqueueCompletedSession(f.payload, { home: f.home });
|
|
77
|
+
const calls = [];
|
|
78
|
+
const fetchFn = async (url, init) => {
|
|
79
|
+
calls.push({ url, init });
|
|
80
|
+
if (url.endsWith("/ingest")) return jsonResponse({ job_id: "job-1", replayed: false }, 202);
|
|
81
|
+
return jsonResponse({ state: "done", done: 1, total: 1, errors: 0, n_sessions: 1 });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const result = await drainIncrementalOutbox({
|
|
85
|
+
home: f.home,
|
|
86
|
+
api: "https://graph.test",
|
|
87
|
+
identityFn: auth,
|
|
88
|
+
fetchFn,
|
|
89
|
+
insightFn: noInsight,
|
|
90
|
+
});
|
|
91
|
+
assert.equal(result.pending, 0);
|
|
92
|
+
assert.equal(calls.length, 2);
|
|
93
|
+
const posted = JSON.parse(gunzipSync(calls[0].init.body).toString("utf8"));
|
|
94
|
+
assert.deepEqual(Object.keys(posted.raw_transcripts), ["session-123.jsonl"]);
|
|
95
|
+
assert.equal(posted.raw_transcripts["session-123.jsonl"], f.transcript);
|
|
96
|
+
assert.match(calls[0].init.headers["idempotency-key"], /^session-end:[a-f0-9]{64}:0$/);
|
|
97
|
+
|
|
98
|
+
const duplicate = enqueueCompletedSession(f.payload, { home: f.home });
|
|
99
|
+
assert.equal(duplicate.key.length, 64);
|
|
100
|
+
await drainIncrementalOutbox({
|
|
101
|
+
home: f.home,
|
|
102
|
+
identityFn: auth,
|
|
103
|
+
insightFn: noInsight,
|
|
104
|
+
fetchFn: async () => {
|
|
105
|
+
throw new Error("the account-scoped delivered ledger should suppress this replay");
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
assert.equal(pendingItems(f.home).length, 0);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(f.home, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("accepted-response loss retries with the same persisted idempotency key", async () => {
|
|
115
|
+
const f = fixture();
|
|
116
|
+
try {
|
|
117
|
+
enqueueCompletedSession(f.payload, { home: f.home });
|
|
118
|
+
const keys = [];
|
|
119
|
+
await drainIncrementalOutbox({
|
|
120
|
+
home: f.home,
|
|
121
|
+
api: "https://graph.test",
|
|
122
|
+
identityFn: auth,
|
|
123
|
+
insightFn: noInsight,
|
|
124
|
+
fetchFn: async (_url, init) => {
|
|
125
|
+
keys.push(init.headers["idempotency-key"]);
|
|
126
|
+
throw new Error("response lost");
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
assert.equal(pendingItems(f.home).length, 1);
|
|
130
|
+
|
|
131
|
+
await drainIncrementalOutbox({
|
|
132
|
+
home: f.home,
|
|
133
|
+
api: "https://graph.test",
|
|
134
|
+
identityFn: auth,
|
|
135
|
+
insightFn: noInsight,
|
|
136
|
+
fetchFn: async (url, init) => {
|
|
137
|
+
if (url.endsWith("/ingest")) {
|
|
138
|
+
keys.push(init.headers["idempotency-key"]);
|
|
139
|
+
return jsonResponse({ job_id: "job-replayed", replayed: true }, 202);
|
|
140
|
+
}
|
|
141
|
+
return jsonResponse({ state: "done", done: 1, total: 1, n_sessions: 1 });
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
assert.equal(keys.length, 2);
|
|
145
|
+
assert.equal(keys[0], keys[1]);
|
|
146
|
+
assert.equal(pendingItems(f.home).length, 0);
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(f.home, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("active or quota-waiting jobs are polled by job id without reposting", async () => {
|
|
153
|
+
const f = fixture();
|
|
154
|
+
let clock = Date.parse("2026-07-12T05:00:00Z");
|
|
155
|
+
try {
|
|
156
|
+
const queued = enqueueCompletedSession(f.payload, { home: f.home, now: () => clock });
|
|
157
|
+
const snapshot = join(f.home, ".skalpel", "ingest-outbox", `${queued.key}.jsonl`);
|
|
158
|
+
const snapshotMtime = statSync(snapshot).mtimeMs;
|
|
159
|
+
let posts = 0;
|
|
160
|
+
await drainIncrementalOutbox({
|
|
161
|
+
home: f.home,
|
|
162
|
+
api: "https://graph.test",
|
|
163
|
+
identityFn: auth,
|
|
164
|
+
insightFn: noInsight,
|
|
165
|
+
now: () => clock,
|
|
166
|
+
workBudgetMs: 1,
|
|
167
|
+
pollIntervalMs: 1,
|
|
168
|
+
fetchFn: async (url) => {
|
|
169
|
+
if (url.endsWith("/ingest")) {
|
|
170
|
+
posts += 1;
|
|
171
|
+
return jsonResponse({ job_id: "job-waiting" }, 202);
|
|
172
|
+
}
|
|
173
|
+
return jsonResponse({
|
|
174
|
+
state: "waiting_quota",
|
|
175
|
+
phase: "waiting_quota",
|
|
176
|
+
done: 0,
|
|
177
|
+
total: 1,
|
|
178
|
+
errors: 0,
|
|
179
|
+
heartbeat_at: "2026-07-12T05:00:00Z",
|
|
180
|
+
terminal: false,
|
|
181
|
+
requires_replay: false,
|
|
182
|
+
retry_at: new Date(clock + 5 * 60 * 1000).toISOString(),
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
assert.equal(pendingItems(f.home)[0].job_id, "job-waiting");
|
|
187
|
+
assert.equal(pendingItems(f.home)[0].state, "waiting_quota");
|
|
188
|
+
assert.equal(pendingItems(f.home)[0].phase, "waiting_quota");
|
|
189
|
+
assert.equal(pendingItems(f.home)[0].terminal, false);
|
|
190
|
+
assert.equal(pendingItems(f.home)[0].requires_replay, false);
|
|
191
|
+
assert.equal(pendingItems(f.home)[0].next_poll_at, clock + 5 * 60 * 1000);
|
|
192
|
+
assert.equal(readFileSync(snapshot, "utf8"), f.transcript);
|
|
193
|
+
assert.equal(
|
|
194
|
+
statSync(snapshot).mtimeMs,
|
|
195
|
+
snapshotMtime,
|
|
196
|
+
"status heartbeats must update only tiny metadata, never rewrite the transcript snapshot",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
let fetchedBeforeRetry = false;
|
|
200
|
+
await drainIncrementalOutbox({
|
|
201
|
+
home: f.home,
|
|
202
|
+
api: "https://graph.test",
|
|
203
|
+
identityFn: auth,
|
|
204
|
+
insightFn: noInsight,
|
|
205
|
+
now: () => clock,
|
|
206
|
+
fetchFn: async () => {
|
|
207
|
+
fetchedBeforeRetry = true;
|
|
208
|
+
throw new Error("must honor retry_at");
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
assert.equal(fetchedBeforeRetry, false, "nonterminal waiting_quota must pause until retry_at");
|
|
212
|
+
|
|
213
|
+
clock += 6 * 60 * 1000;
|
|
214
|
+
await drainIncrementalOutbox({
|
|
215
|
+
home: f.home,
|
|
216
|
+
api: "https://graph.test",
|
|
217
|
+
identityFn: auth,
|
|
218
|
+
insightFn: noInsight,
|
|
219
|
+
now: () => clock,
|
|
220
|
+
fetchFn: async (url) => {
|
|
221
|
+
assert.ok(url.includes("/ingest/status?job_id=job-waiting"));
|
|
222
|
+
return jsonResponse({ state: "done", done: 1, total: 1, n_sessions: 1 });
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
assert.equal(posts, 1, "the persisted job id must be resumed instead of creating another job");
|
|
226
|
+
assert.equal(pendingItems(f.home).length, 0);
|
|
227
|
+
} finally {
|
|
228
|
+
rmSync(f.home, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("terminal waiting_quota with requires_replay mints one new retry generation", async () => {
|
|
233
|
+
const f = fixture();
|
|
234
|
+
let clock = Date.parse("2026-07-12T05:00:00Z");
|
|
235
|
+
const keys = [];
|
|
236
|
+
try {
|
|
237
|
+
enqueueCompletedSession(f.payload, { home: f.home, now: () => clock });
|
|
238
|
+
await drainIncrementalOutbox({
|
|
239
|
+
home: f.home,
|
|
240
|
+
api: "https://graph.test",
|
|
241
|
+
identityFn: auth,
|
|
242
|
+
insightFn: noInsight,
|
|
243
|
+
now: () => clock,
|
|
244
|
+
fetchFn: async (url, init) => {
|
|
245
|
+
if (url.endsWith("/ingest")) {
|
|
246
|
+
keys.push(init.headers["idempotency-key"]);
|
|
247
|
+
return jsonResponse({ job_id: "job-failed" }, 202);
|
|
248
|
+
}
|
|
249
|
+
return jsonResponse({
|
|
250
|
+
state: "waiting_quota",
|
|
251
|
+
phase: "judge",
|
|
252
|
+
done: 0,
|
|
253
|
+
total: 1,
|
|
254
|
+
errors: 1,
|
|
255
|
+
terminal: true,
|
|
256
|
+
requires_replay: true,
|
|
257
|
+
error: "quota",
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const pending = pendingItems(f.home)[0];
|
|
262
|
+
assert.equal(pending.state, "retry_queued");
|
|
263
|
+
assert.equal(pending.generation, 1);
|
|
264
|
+
assert.equal(pending.job_id, null);
|
|
265
|
+
|
|
266
|
+
clock += 6 * 60 * 1000;
|
|
267
|
+
await drainIncrementalOutbox({
|
|
268
|
+
home: f.home,
|
|
269
|
+
api: "https://graph.test",
|
|
270
|
+
identityFn: auth,
|
|
271
|
+
insightFn: noInsight,
|
|
272
|
+
now: () => clock,
|
|
273
|
+
fetchFn: async (url, init) => {
|
|
274
|
+
if (url.endsWith("/ingest")) {
|
|
275
|
+
keys.push(init.headers["idempotency-key"]);
|
|
276
|
+
return jsonResponse({ job_id: "job-retry" }, 202);
|
|
277
|
+
}
|
|
278
|
+
return jsonResponse({ state: "done", done: 1, total: 1, n_sessions: 1 });
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
assert.equal(keys.length, 2);
|
|
282
|
+
assert.notEqual(keys[0], keys[1]);
|
|
283
|
+
assert.match(keys[0], /:0$/);
|
|
284
|
+
assert.match(keys[1], /:1$/);
|
|
285
|
+
assert.equal(pendingItems(f.home).length, 0);
|
|
286
|
+
} finally {
|
|
287
|
+
rmSync(f.home, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a bootstrap timestamp never falsely acknowledges an unknown per-session result", async () => {
|
|
292
|
+
const f = fixture();
|
|
293
|
+
try {
|
|
294
|
+
const item = enqueueCompletedSession(f.payload, { home: f.home });
|
|
295
|
+
const dir = join(f.home, ".skalpel");
|
|
296
|
+
mkdirSync(dir, { recursive: true });
|
|
297
|
+
writeFileSync(
|
|
298
|
+
join(dir, "bootstrap.json"),
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
state: "done",
|
|
301
|
+
sessions: 1,
|
|
302
|
+
last_ingest_at: new Date(item.transcript_mtime_ms + 1_000).toISOString(),
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
const result = await drainIncrementalOutbox({
|
|
306
|
+
home: f.home,
|
|
307
|
+
identityFn: async () => ({ uid: null, token: null }),
|
|
308
|
+
insightFn: noInsight,
|
|
309
|
+
});
|
|
310
|
+
assert.equal(result.pending, 1);
|
|
311
|
+
assert.equal(result.authRequired, true);
|
|
312
|
+
assert.equal(
|
|
313
|
+
pendingItems(f.home)[0].key,
|
|
314
|
+
item.key,
|
|
315
|
+
"bulk catch-up has no per-session receipt, so the durable item must remain until its job confirms",
|
|
316
|
+
);
|
|
317
|
+
} finally {
|
|
318
|
+
rmSync(f.home, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
@@ -15,7 +15,7 @@ const statuslineOnly = !uninstall && process.argv.includes("--statusline-only");
|
|
|
15
15
|
|
|
16
16
|
// Where this installer's own files live — the globally-installed package, a source checkout, OR the
|
|
17
17
|
// transient `npx` cache. All three must produce ONE working command, so we can't rely on a bin being
|
|
18
|
-
// on PATH (only `npm i -g` puts it there; `npx` and a source run don't). Instead we copy the
|
|
18
|
+
// on PATH (only `npm i -g` puts it there; `npx` and a source run don't). Instead we copy the
|
|
19
19
|
// self-contained hook files into a STABLE home and wire an absolute `node <path>` command that works
|
|
20
20
|
// offline, survives npx cache GC, and needs no global install — so `npx @skalpelai/prosumer-hook` is
|
|
21
21
|
// the single command that leaves genuinely-working hooks.
|
|
@@ -26,6 +26,7 @@ const HOOKS_DIR = join(homedir(), ".skalpel", "hooks");
|
|
|
26
26
|
// gets healed to the absolute command on the next run.
|
|
27
27
|
const HOOK_FILE = join(HOOKS_DIR, "skalpel-hook.mjs");
|
|
28
28
|
const SESSION_FILE = join(HOOKS_DIR, "skalpel-hook-session.mjs");
|
|
29
|
+
const SESSION_END_FILE = join(HOOKS_DIR, "skalpel-hook-session-end.mjs");
|
|
29
30
|
const STATUSLINE_FILE = join(HOOKS_DIR, "skalpel-statusline.mjs");
|
|
30
31
|
|
|
31
32
|
// Copy the hook runtime (the two entrypoints + their shared auth.mjs) into ~/.skalpel/hooks so the
|
|
@@ -41,6 +42,8 @@ function stageHookRuntime() {
|
|
|
41
42
|
copyFileSync(join(PKG_DIR, "insights.mjs"), join(HOOKS_DIR, "insights.mjs")); // hooks import ./insights.mjs
|
|
42
43
|
copyFileSync(join(PKG_DIR, "skalpel-hook.mjs"), HOOK_FILE);
|
|
43
44
|
copyFileSync(join(PKG_DIR, "skalpel-hook-session.mjs"), SESSION_FILE);
|
|
45
|
+
copyFileSync(join(PKG_DIR, "incremental-ingest.mjs"), join(HOOKS_DIR, "incremental-ingest.mjs"));
|
|
46
|
+
copyFileSync(join(PKG_DIR, "skalpel-hook-session-end.mjs"), SESSION_END_FILE);
|
|
44
47
|
copyFileSync(join(PKG_DIR, "skalpel-statusline.mjs"), STATUSLINE_FILE);
|
|
45
48
|
copyFileSync(join(PKG_DIR, "auth.mjs"), join(HOOKS_DIR, "auth.mjs")); // hooks import ./auth.mjs
|
|
46
49
|
copyFileSync(join(PKG_DIR, "metrics.mjs"), join(HOOKS_DIR, "metrics.mjs")); // hooks import ./metrics.mjs
|
|
@@ -65,6 +68,12 @@ if (!uninstall && !process.env.SKALPEL_HOOK_BIN) {
|
|
|
65
68
|
// SKALPEL_HOOK_SESSION_BIN still override (dev, or a deliberate global-bin install).
|
|
66
69
|
const CMD = process.env.SKALPEL_HOOK_BIN || `node ${HOOK_FILE}`;
|
|
67
70
|
const SESSION_CMD = process.env.SKALPEL_HOOK_SESSION_BIN || `node ${SESSION_FILE}`;
|
|
71
|
+
const sessionEndRuntime =
|
|
72
|
+
process.env.SKALPEL_HOOK_BIN && PKG_DIR !== HOOKS_DIR
|
|
73
|
+
? join(PKG_DIR, "skalpel-hook-session-end.mjs")
|
|
74
|
+
: SESSION_END_FILE;
|
|
75
|
+
const SESSION_END_CMD =
|
|
76
|
+
process.env.SKALPEL_HOOK_SESSION_END_BIN || `node ${sessionEndRuntime}`;
|
|
68
77
|
const STATUSLINE_CMD = process.env.SKALPEL_STATUSLINE_BIN || `node ${STATUSLINE_FILE}`;
|
|
69
78
|
const CLAUDE = join(homedir(), ".claude", "settings.json");
|
|
70
79
|
const CODEX_DIR = join(homedir(), ".codex");
|
|
@@ -81,6 +90,13 @@ const HOOKS = [
|
|
|
81
90
|
["UserPromptSubmit", markerOf(CMD), CMD],
|
|
82
91
|
["SessionStart", markerOf(SESSION_CMD), SESSION_CMD],
|
|
83
92
|
];
|
|
93
|
+
// Claude Code supports a once-per-session SessionEnd event and native background command hooks.
|
|
94
|
+
// Codex currently supports neither SessionEnd nor async command hooks, so its honest fallback is
|
|
95
|
+
// the existing next-launch history scan; do not write an inert Codex hook and claim parity.
|
|
96
|
+
const CLAUDE_HOOKS = [
|
|
97
|
+
...HOOKS,
|
|
98
|
+
["SessionEnd", markerOf(SESSION_END_CMD), SESSION_END_CMD],
|
|
99
|
+
];
|
|
84
100
|
|
|
85
101
|
function readJson(p) {
|
|
86
102
|
let raw;
|
|
@@ -113,9 +129,11 @@ function isOurs(c, marker) {
|
|
|
113
129
|
marker,
|
|
114
130
|
"skalpel-hook",
|
|
115
131
|
"skalpel-hook-session",
|
|
132
|
+
"skalpel-hook-session-end",
|
|
116
133
|
"skalpel-statusline",
|
|
117
134
|
"skalpel-prosumer-hook",
|
|
118
135
|
"skalpel-prosumer-hook-session",
|
|
136
|
+
"skalpel-prosumer-hook-session-end",
|
|
119
137
|
"skalpel-prosumer-statusline",
|
|
120
138
|
];
|
|
121
139
|
return markers.some((candidate) => {
|
|
@@ -138,7 +156,7 @@ function claude() {
|
|
|
138
156
|
let removed = 0;
|
|
139
157
|
if (!statuslineOnly) {
|
|
140
158
|
d.hooks ??= {};
|
|
141
|
-
for (const [event, marker, command] of
|
|
159
|
+
for (const [event, marker, command] of CLAUDE_HOOKS) {
|
|
142
160
|
d.hooks[event] ??= [];
|
|
143
161
|
const arr = d.hooks[event];
|
|
144
162
|
const isMine = (e) => {
|
|
@@ -148,9 +166,14 @@ function claude() {
|
|
|
148
166
|
};
|
|
149
167
|
const had = arr.some(isMine);
|
|
150
168
|
const kept = arr.filter((e) => !isMine(e)); // drop ours (both shapes) AND repair
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
169
|
+
// SessionEnd uses Claude Code's native async process: the transcript snapshot happens before
|
|
170
|
+
// networking inside that process, and session termination never waits for ingest/polling.
|
|
171
|
+
// Other hooks retain their tight interactive budget.
|
|
172
|
+
const handler =
|
|
173
|
+
event === "SessionEnd"
|
|
174
|
+
? { type: "command", command, timeout: 55, async: true }
|
|
175
|
+
: { type: "command", command, timeout: 8 };
|
|
176
|
+
const group = { hooks: [handler] };
|
|
154
177
|
d.hooks[event] = uninstall ? kept : [...kept, group];
|
|
155
178
|
if (uninstall) {
|
|
156
179
|
if (had) removed += 1;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code SessionEnd hook: snapshot the one completed transcript into a durable local outbox,
|
|
3
|
+
// then submit/poll it in Claude's native async-hook process. It never writes stdout/stderr and
|
|
4
|
+
// always exits 0, so graph maintenance cannot delay or break session termination.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
import { captureAndDrainSession } from "./incremental-ingest.mjs";
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
let payload;
|
|
11
|
+
try {
|
|
12
|
+
payload = JSON.parse(readFileSync(0, "utf8") || "{}");
|
|
13
|
+
} catch {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
await captureAndDrainSession(payload);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
main()
|
|
20
|
+
.then(() => process.exit(0))
|
|
21
|
+
.catch(() => process.exit(0));
|