@remnic/plugin-claude-code 9.63.5 → 9.63.7
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.
|
@@ -0,0 +1,1267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared Remnic host hook runner (issues #1440, #1518, #2483).
|
|
5
|
+
*
|
|
6
|
+
* One parameterized cross-platform Node.js implementation of the host hook
|
|
7
|
+
* contract (session-start | user-prompt-recall | post-tool-observe |
|
|
8
|
+
* session-end [+ pre-compact where the host emits it]). Each host package
|
|
9
|
+
* ships a byte-identical copy of this file next to a thin wrapper that calls
|
|
10
|
+
* `run(config)` with the per-host knobs: client id, token connector
|
|
11
|
+
* priority, connector install hint, log file/tag names, and the Codex-only
|
|
12
|
+
* events (pre-compact drain+flush, session-end materialization).
|
|
13
|
+
*
|
|
14
|
+
* CANONICAL SOURCE: scripts/hook-runner/remnic-hook-core.cjs. The copies in
|
|
15
|
+
* packages/plugin-claude-code/hooks/bin/ and packages/plugin-codex/hooks/bin/
|
|
16
|
+
* are generated — edit the canonical file, then run
|
|
17
|
+
* `npm run sync:hook-runner` (CI fails on drift via
|
|
18
|
+
* `npm run check:hook-runner-sync`).
|
|
19
|
+
*
|
|
20
|
+
* Fail-open everywhere: any unexpected error degrades to `{"continue":true}`.
|
|
21
|
+
*
|
|
22
|
+
* Security note (issue #1518 "guard shell interpolation"): every value that
|
|
23
|
+
* originates from the hook payload (session id, cwd, transcript path, prompt,
|
|
24
|
+
* tool name) is passed to child processes via argv or stdin — NEVER via a
|
|
25
|
+
* string-interpolated shell command. `spawn`/`spawnSync` are called with
|
|
26
|
+
* fixed literal argument arrays and `shell: false` (except on Windows, where
|
|
27
|
+
* `.cmd` shims require `shell: true` with fixed literal args), so a payload
|
|
28
|
+
* field containing shell metacharacters cannot achieve command injection.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require("fs");
|
|
32
|
+
const os = require("os");
|
|
33
|
+
const path = require("path");
|
|
34
|
+
const http = require("http");
|
|
35
|
+
const https = require("https");
|
|
36
|
+
const { execFileSync, spawn, spawnSync } = require("child_process");
|
|
37
|
+
|
|
38
|
+
// The detached observe worker re-spawns the HOST WRAPPER (not this core) so
|
|
39
|
+
// it re-enters through the same per-host config. When the core is required
|
|
40
|
+
// by a wrapper, `require.main` is the wrapper's module.
|
|
41
|
+
const ENTRY_FILE = (require.main && require.main.filename) || __filename;
|
|
42
|
+
|
|
43
|
+
function run(config) {
|
|
44
|
+
const CLIENT_ID = config.client;
|
|
45
|
+
const TOKEN_CONNECTORS = config.tokenConnectors;
|
|
46
|
+
const CONNECTOR_INSTALL = config.connectorInstall;
|
|
47
|
+
const PROG_NAME = config.progName;
|
|
48
|
+
const LOG_FILES = config.logFiles;
|
|
49
|
+
const LOG_TAGS = config.logTags;
|
|
50
|
+
const DEFAULT_LOG_FILE = config.defaultLogFile;
|
|
51
|
+
|
|
52
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
53
|
+
|
|
54
|
+
// Daemon base URL resolution (issue #1571 — remote/network parity with
|
|
55
|
+
// @remnic/plugin-pi's `remnicDaemonUrl`). A full REMNIC_DAEMON_URL
|
|
56
|
+
// (e.g. "http://host.tailnet:4318" or "https://remnic.internal:443")
|
|
57
|
+
// takes precedence and lets a host talk to a shared central daemon over
|
|
58
|
+
// Tailscale/LAN/VPN. The legacy REMNIC_HOST/REMNIC_PORT pair remains
|
|
59
|
+
// supported as a backward-compat fallback so existing installs and the
|
|
60
|
+
// test harness (which spins a mock on 127.0.0.1:<port>) keep working.
|
|
61
|
+
const RAW_DAEMON_URL = process.env.REMNIC_DAEMON_URL || process.env.ENGRAM_DAEMON_URL || "";
|
|
62
|
+
const HOST = process.env.REMNIC_HOST || process.env.ENGRAM_HOST || "127.0.0.1";
|
|
63
|
+
const PORT = process.env.REMNIC_PORT || process.env.ENGRAM_PORT || "4318";
|
|
64
|
+
|
|
65
|
+
// Parse once. `protocol` is "http:" or "https:" so https.request is selected
|
|
66
|
+
// for TLS daemons (common for remote/private deployments behind a proxy).
|
|
67
|
+
//
|
|
68
|
+
// An EXPLICIT but invalid REMNIC_DAEMON_URL (missing scheme, typo, e.g.
|
|
69
|
+
// "host:4318") must NOT silently fall through to REMNIC_HOST/REMNIC_PORT —
|
|
70
|
+
// that would route recall/observe/flush to a local daemon and corrupt the
|
|
71
|
+
// wrong memory store. Instead we disable the daemon entirely (all HTTP
|
|
72
|
+
// calls fail open) and surface the misconfiguration once on stderr so the
|
|
73
|
+
// operator fixes the URL rather than debugging silent cross-store writes
|
|
74
|
+
// (#1571 review).
|
|
75
|
+
let DAEMON_CONFIG_ERROR = "";
|
|
76
|
+
const DAEMON_URL = (function resolveDaemonUrl() {
|
|
77
|
+
if (RAW_DAEMON_URL) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = new URL(RAW_DAEMON_URL);
|
|
80
|
+
// Require an explicit scheme — a bare "host:4318" parses with
|
|
81
|
+
// protocol "host:" and would otherwise silently route to localhost.
|
|
82
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed;
|
|
83
|
+
} catch {
|
|
84
|
+
/* handled below */
|
|
85
|
+
}
|
|
86
|
+
DAEMON_CONFIG_ERROR =
|
|
87
|
+
'REMNIC_DAEMON_URL="' + RAW_DAEMON_URL + '" is not a valid http(s) URL — ' +
|
|
88
|
+
"daemon disabled (recall/observe/flush will no-op). Set a valid " +
|
|
89
|
+
"http(s):// URL or unset REMNIC_DAEMON_URL to use REMNIC_HOST/REMNIC_PORT.";
|
|
90
|
+
if (process.env.REMNIC_HOOK_QUIET !== "1") {
|
|
91
|
+
try { process.stderr.write(`[${PROG_NAME}] ${DAEMON_CONFIG_ERROR}\n`); } catch {}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return new URL(`http://${HOST}:${PORT}`);
|
|
97
|
+
} catch {
|
|
98
|
+
return new URL("http://127.0.0.1:4318");
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
|
|
102
|
+
// Base path from the daemon URL (for reverse-proxy subpath mounts, e.g.
|
|
103
|
+
// REMNIC_DAEMON_URL=http://gw/remnic). Mirrors plugin-pi's daemon-URL +
|
|
104
|
+
// route concatenation — without this, a path-qualified base URL has its
|
|
105
|
+
// prefix silently dropped and requests hit the host root (#1571 review).
|
|
106
|
+
// Trailing slashes are stripped so "/remnic/" + "/engram/v1/observe"
|
|
107
|
+
// becomes "/remnic/engram/v1/observe"; a bare root ("/") yields "".
|
|
108
|
+
const DAEMON_BASE_PATH = DAEMON_URL ? DAEMON_URL.pathname.replace(/\/+$/, "") : "";
|
|
109
|
+
|
|
110
|
+
// Internal re-entrant mode: post-tool-observe spawns a detached copy of
|
|
111
|
+
// itself so the (slow) observe runs in the background and never blocks the
|
|
112
|
+
// host past the short PostToolUse timeout — mirroring the original
|
|
113
|
+
// `( … ) & disown`.
|
|
114
|
+
const OBSERVE_WORKER = "__observe-worker__";
|
|
115
|
+
|
|
116
|
+
// PreCompact tail-drain lock-wait budget (100ms each → ~15s). Bounds how
|
|
117
|
+
// long handlePreCompact waits for a detached PostToolUse observe worker
|
|
118
|
+
// (whose own /observe can run up to 120s) to release the session lock
|
|
119
|
+
// before skipping the drain+flush. 15s covers a typical observe round-trip
|
|
120
|
+
// on a healthy daemon; a pathologically slow worker that outlasts this is
|
|
121
|
+
// left for the next cycle rather than blocking compaction indefinitely.
|
|
122
|
+
const _pcRaw = process.env.REMNIC_PRECOMPACT_LOCK_RETRIES;
|
|
123
|
+
const _pcParsed = _pcRaw != null && _pcRaw !== "" ? Number.parseInt(_pcRaw, 10) : 150;
|
|
124
|
+
// 0 is honored (immediate busy-skip); NaN/negative falls back to the 150 default.
|
|
125
|
+
const PRECOMPACT_LOCK_RETRIES = Number.isFinite(_pcParsed) && _pcParsed >= 0 ? _pcParsed : 150;
|
|
126
|
+
|
|
127
|
+
function makeLogger(event) {
|
|
128
|
+
const file = path.join(HOME, ".remnic", "logs", LOG_FILES[event] || DEFAULT_LOG_FILE);
|
|
129
|
+
const tag = LOG_TAGS[event] || PROG_NAME;
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
132
|
+
} catch {
|
|
133
|
+
/* best effort */
|
|
134
|
+
}
|
|
135
|
+
return (msg) => {
|
|
136
|
+
try {
|
|
137
|
+
const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
138
|
+
fs.appendFileSync(file, `${ts} [${tag}] ${msg}\n`);
|
|
139
|
+
} catch {
|
|
140
|
+
/* logging must never throw */
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let emitted = false;
|
|
146
|
+
function emit(obj) {
|
|
147
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
148
|
+
emitted = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readStdin() {
|
|
152
|
+
// Always read the real stdin. The foreground hook gets its payload from
|
|
153
|
+
// the host on fd 0; the detached observe worker gets it from the pipe the
|
|
154
|
+
// foreground writes. We deliberately do NOT consult an env var here — an
|
|
155
|
+
// inherited REMNIC_HOOK_INPUT in the parent environment would otherwise
|
|
156
|
+
// override the piped payload and the worker could observe stale/empty
|
|
157
|
+
// input (#1443 review).
|
|
158
|
+
try {
|
|
159
|
+
return fs.readFileSync(0, "utf8");
|
|
160
|
+
} catch {
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseInput(raw) {
|
|
166
|
+
try {
|
|
167
|
+
const d = JSON.parse(raw);
|
|
168
|
+
return d && typeof d === "object" ? d : {};
|
|
169
|
+
} catch {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── engram → remnic migration (CLAUDE.md rule #9) ──────────────────────────
|
|
175
|
+
function ensureMigrated() {
|
|
176
|
+
try {
|
|
177
|
+
if (fs.existsSync(path.join(HOME, ".remnic", ".migrated-from-engram"))) return;
|
|
178
|
+
const hasEngram =
|
|
179
|
+
fs.existsSync(path.join(HOME, ".engram")) ||
|
|
180
|
+
fs.existsSync(path.join(HOME, ".config", "engram", "config.json"));
|
|
181
|
+
if (!hasEngram) return;
|
|
182
|
+
// Try `remnic` first, fall through to legacy `engram` when missing on
|
|
183
|
+
// PATH. Pre-check PATH with onPath() (which is .cmd/.exe-aware on
|
|
184
|
+
// Windows) rather than relying on spawnSync ENOENT — under `shell: true`
|
|
185
|
+
// a missing command yields a non-zero shell exit, not ENOENT, so an
|
|
186
|
+
// exit-code check couldn't distinguish "missing" from "migration
|
|
187
|
+
// failed" (#1443 review). On Windows the CLIs are `.cmd` shims, which
|
|
188
|
+
// Node can only launch via a shell. Timeout is 5 min so a large
|
|
189
|
+
// migration can complete. Args are fixed literals — safe under a shell.
|
|
190
|
+
for (const bin of ["remnic", "engram"]) {
|
|
191
|
+
if (!onPath(bin)) continue;
|
|
192
|
+
spawnSync(bin, ["migrate"], {
|
|
193
|
+
stdio: "ignore",
|
|
194
|
+
timeout: 300000,
|
|
195
|
+
shell: process.platform === "win32",
|
|
196
|
+
windowsHide: true,
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
/* migration is best effort */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// PATH lookup helper (cross-platform equivalent of bash `command -v`).
|
|
206
|
+
// Returns true when an executable named `bin` is reachable via $PATH. Used
|
|
207
|
+
// before async `spawn()` calls so the remnic → engram fallthrough actually
|
|
208
|
+
// happens when only the legacy CLI is installed — `spawn` emits ENOENT
|
|
209
|
+
// asynchronously via 'error', so a naive try/break can't see it (#1443
|
|
210
|
+
// review).
|
|
211
|
+
function onPath(bin) {
|
|
212
|
+
const PATH = process.env.PATH || process.env.Path || process.env.path || "";
|
|
213
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
214
|
+
// Windows resolves names without an extension by appending PATHEXT entries.
|
|
215
|
+
const exts =
|
|
216
|
+
process.platform === "win32"
|
|
217
|
+
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
|
|
218
|
+
.split(";")
|
|
219
|
+
.map((e) => e.trim())
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
: [""];
|
|
222
|
+
for (const dir of PATH.split(sep)) {
|
|
223
|
+
if (!dir) continue;
|
|
224
|
+
for (const ext of exts) {
|
|
225
|
+
try {
|
|
226
|
+
const candidate = path.join(dir, bin + ext);
|
|
227
|
+
const info = fs.statSync(candidate);
|
|
228
|
+
if (info.isFile()) return true;
|
|
229
|
+
} catch {
|
|
230
|
+
/* try next */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── token resolution (per-plugin token store, then env) ────────────────────
|
|
238
|
+
// Env precedence is primary-before-legacy per AGENTS.md §9: the two current
|
|
239
|
+
// names (OPENCLAW_REMNIC_ACCESS_TOKEN, REMNIC_AUTH_TOKEN) before the two
|
|
240
|
+
// legacy aliases (OPENCLAW_ENGRAM_ACCESS_TOKEN, ENGRAM_AUTH_TOKEN), so a
|
|
241
|
+
// stale leftover from a pre-rename install cannot outrank the credential
|
|
242
|
+
// the daemon is actually running with. REMNIC_AUTH_TOKEN matters because
|
|
243
|
+
// the documented standalone-server setup authenticates the daemon with it
|
|
244
|
+
// alone and never mints a connector token — without it the health probe
|
|
245
|
+
// 401s and the hook wrongly reports "daemon not running", silently
|
|
246
|
+
// skipping recall/observe.
|
|
247
|
+
function resolveToken() {
|
|
248
|
+
for (const file of [
|
|
249
|
+
path.join(HOME, ".remnic", "tokens.json"),
|
|
250
|
+
path.join(HOME, ".engram", "tokens.json"),
|
|
251
|
+
]) {
|
|
252
|
+
try {
|
|
253
|
+
if (!fs.existsSync(file)) continue;
|
|
254
|
+
const store = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
255
|
+
const tokens = Array.isArray(store.tokens) ? store.tokens : [];
|
|
256
|
+
const byConnector = (c) => tokens.find((t) => t && t.connector === c);
|
|
257
|
+
// Two-pass precedence, host priority first: all connector-keyed
|
|
258
|
+
// lookups in TOKEN_CONNECTORS order, then the same order against the
|
|
259
|
+
// legacy flat store keys.
|
|
260
|
+
let tok = "";
|
|
261
|
+
for (const c of TOKEN_CONNECTORS) {
|
|
262
|
+
const t = (byConnector(c) || {}).token;
|
|
263
|
+
if (t) {
|
|
264
|
+
tok = t;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!tok) {
|
|
269
|
+
for (const c of TOKEN_CONNECTORS) {
|
|
270
|
+
if (store[c]) {
|
|
271
|
+
tok = store[c];
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (tok) return tok;
|
|
277
|
+
} catch {
|
|
278
|
+
/* try next file */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return (
|
|
282
|
+
process.env.OPENCLAW_REMNIC_ACCESS_TOKEN ||
|
|
283
|
+
process.env.REMNIC_AUTH_TOKEN ||
|
|
284
|
+
process.env.OPENCLAW_ENGRAM_ACCESS_TOKEN ||
|
|
285
|
+
process.env.ENGRAM_AUTH_TOKEN ||
|
|
286
|
+
""
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── HTTP helpers — return a real success signal (2xx) so callers can decide
|
|
291
|
+
// whether to advance/clear the cursor (fixes the data-loss bug in #1442).
|
|
292
|
+
//
|
|
293
|
+
// All requests route through the parsed DAEMON_URL (#1571), so a host can
|
|
294
|
+
// target a remote/central daemon (Tailscale/LAN/VPN, plain or TLS) by
|
|
295
|
+
// setting REMNIC_DAEMON_URL — same transport contract as @remnic/plugin-pi.
|
|
296
|
+
//
|
|
297
|
+
// Namespace chokepoint (#1571): every observe/recall/flush body carries the
|
|
298
|
+
// optional REMNIC_NAMESPACE / ENGRAM_NAMESPACE override so a namespaced
|
|
299
|
+
// install archives + flushes under one key — without it, buffered
|
|
300
|
+
// observations land on the default key while a namespaced flush drains a
|
|
301
|
+
// different (empty) queue, and most in-session memory never flushes before
|
|
302
|
+
// compaction. An explicit bodyObj.namespace still takes precedence.
|
|
303
|
+
function httpPost(urlPath, token, bodyObj, timeoutMs) {
|
|
304
|
+
if (DAEMON_URL === null) {
|
|
305
|
+
// Explicit-but-invalid REMNIC_DAEMON_URL — disabled, fail open (see
|
|
306
|
+
// resolveDaemonUrl). Callers treat this like a dead daemon.
|
|
307
|
+
return Promise.resolve({ ok: false, status: 0, body: "" });
|
|
308
|
+
}
|
|
309
|
+
const transport = DAEMON_URL.protocol === "https:" ? https : http;
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
let data;
|
|
312
|
+
try {
|
|
313
|
+
const ns = process.env.REMNIC_NAMESPACE || process.env.ENGRAM_NAMESPACE;
|
|
314
|
+
const outBody =
|
|
315
|
+
ns && bodyObj && typeof bodyObj === "object" && !Array.isArray(bodyObj)
|
|
316
|
+
? { namespace: ns, ...bodyObj }
|
|
317
|
+
: bodyObj;
|
|
318
|
+
data = Buffer.from(JSON.stringify(outBody), "utf8");
|
|
319
|
+
} catch {
|
|
320
|
+
resolve({ ok: false, status: 0, body: "" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const req = transport.request(
|
|
324
|
+
{
|
|
325
|
+
protocol: DAEMON_URL.protocol,
|
|
326
|
+
hostname: DAEMON_URL.hostname,
|
|
327
|
+
port: DAEMON_URL.port || (DAEMON_URL.protocol === "https:" ? 443 : 80),
|
|
328
|
+
path: DAEMON_BASE_PATH + urlPath,
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: {
|
|
331
|
+
Authorization: `Bearer ${token}`,
|
|
332
|
+
"Content-Type": "application/json",
|
|
333
|
+
"X-Engram-Client-Id": CLIENT_ID,
|
|
334
|
+
"Content-Length": data.length,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
(res) => {
|
|
338
|
+
let body = "";
|
|
339
|
+
res.setEncoding("utf8");
|
|
340
|
+
res.on("data", (c) => {
|
|
341
|
+
body += c;
|
|
342
|
+
});
|
|
343
|
+
res.on("end", () =>
|
|
344
|
+
resolve({
|
|
345
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
346
|
+
status: res.statusCode || 0,
|
|
347
|
+
body,
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
req.on("error", () => resolve({ ok: false, status: 0, body: "" }));
|
|
353
|
+
req.setTimeout(timeoutMs, () => {
|
|
354
|
+
req.destroy();
|
|
355
|
+
resolve({ ok: false, status: 0, body: "" });
|
|
356
|
+
});
|
|
357
|
+
req.write(data);
|
|
358
|
+
req.end();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// `token` is the caller's already-resolved credential, NOT a second
|
|
363
|
+
// resolveToken() call: the probe must authenticate with the exact bearer
|
|
364
|
+
// the operation it gates will send. Re-resolving could pick up a rotated
|
|
365
|
+
// tokens.json (or an inherited REMNIC_HOOK_TOKEN the foreground handlers
|
|
366
|
+
// ignore) and green-light a probe whose recall then 401s.
|
|
367
|
+
//
|
|
368
|
+
// When the daemon has an auth token configured, every route — including
|
|
369
|
+
// /engram/v1/health — returns 401 to unauthenticated requests, so an
|
|
370
|
+
// unauthenticated probe makes the hook wrongly report "daemon not running"
|
|
371
|
+
// and skip recall/observe. Unauthenticated daemons ignore the header.
|
|
372
|
+
function httpHealthy(timeoutMs, token) {
|
|
373
|
+
if (DAEMON_URL === null) return Promise.resolve(false);
|
|
374
|
+
const transport = DAEMON_URL.protocol === "https:" ? https : http;
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
const req = transport.request(
|
|
377
|
+
{
|
|
378
|
+
protocol: DAEMON_URL.protocol,
|
|
379
|
+
hostname: DAEMON_URL.hostname,
|
|
380
|
+
port: DAEMON_URL.port || (DAEMON_URL.protocol === "https:" ? 443 : 80),
|
|
381
|
+
path: DAEMON_BASE_PATH + "/engram/v1/health",
|
|
382
|
+
method: "GET",
|
|
383
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
384
|
+
},
|
|
385
|
+
(res) => {
|
|
386
|
+
res.resume();
|
|
387
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
req.on("error", () => resolve(false));
|
|
391
|
+
req.setTimeout(timeoutMs, () => {
|
|
392
|
+
req.destroy();
|
|
393
|
+
resolve(false);
|
|
394
|
+
});
|
|
395
|
+
req.end();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── git coding-context (mirrors @remnic/core git-context.ts) ───────────────
|
|
400
|
+
function git(args, cwd) {
|
|
401
|
+
try {
|
|
402
|
+
return execFileSync("git", args, {
|
|
403
|
+
cwd,
|
|
404
|
+
encoding: "utf8",
|
|
405
|
+
timeout: 5000,
|
|
406
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
407
|
+
}).trim();
|
|
408
|
+
} catch {
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function stableHash(input) {
|
|
414
|
+
let hash = 0x811c9dc5;
|
|
415
|
+
for (let i = 0; i < input.length; i++) {
|
|
416
|
+
hash ^= input.charCodeAt(i);
|
|
417
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
418
|
+
}
|
|
419
|
+
return hash.toString(16).padStart(8, "0");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Mirrors packages/remnic-core/src/coding/git-context.ts normalizeOriginUrl.
|
|
423
|
+
// Keep in sync so the hook-computed projectId matches the daemon's.
|
|
424
|
+
function normalizeOriginUrl(raw) {
|
|
425
|
+
let u = (raw || "").trim();
|
|
426
|
+
if (!u) return "";
|
|
427
|
+
if (/\.git$/i.test(u)) u = u.slice(0, -4);
|
|
428
|
+
if (/^[A-Za-z]:[\\/]/.test(u)) return u.toLowerCase();
|
|
429
|
+
const proto = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?(\[[^\]]+\]|[^/:]*)(?::(\d+))?(\/.*)?$/i.exec(u);
|
|
430
|
+
if (proto) {
|
|
431
|
+
let host = proto[1] || "";
|
|
432
|
+
const wasBracketed = host.startsWith("[") && host.endsWith("]");
|
|
433
|
+
if (wasBracketed) host = host.slice(1, -1);
|
|
434
|
+
const port = proto[2];
|
|
435
|
+
const p = (proto[3] || "").replace(/^\/+/, "");
|
|
436
|
+
const hostPort = port
|
|
437
|
+
? wasBracketed
|
|
438
|
+
? "[" + host + "]:" + port
|
|
439
|
+
: host + ":" + port
|
|
440
|
+
: host;
|
|
441
|
+
const prefix = hostPort.length > 0 ? hostPort : "localhost";
|
|
442
|
+
return (prefix + "/" + p).toLowerCase();
|
|
443
|
+
}
|
|
444
|
+
const scp = /^(?:([^@\s/]+)@)?(\[[^\]]+\]|[^:@\s/]+):(.+)$/.exec(u);
|
|
445
|
+
if (scp) {
|
|
446
|
+
let host = scp[2] || "";
|
|
447
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
448
|
+
const p = scp[3] || "";
|
|
449
|
+
if (p.startsWith("//")) return u.toLowerCase();
|
|
450
|
+
return (host + "/" + p.replace(/^\/+/, "")).toLowerCase();
|
|
451
|
+
}
|
|
452
|
+
return u.toLowerCase();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function resolveCodingContext(cwd) {
|
|
456
|
+
try {
|
|
457
|
+
if (!cwd || !fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return null;
|
|
458
|
+
const top = git(["-C", cwd, "rev-parse", "--show-toplevel"], cwd);
|
|
459
|
+
if (!top) return null;
|
|
460
|
+
let branch = git(["-C", top, "rev-parse", "--abbrev-ref", "HEAD"], top);
|
|
461
|
+
if (branch === "HEAD") branch = "";
|
|
462
|
+
const origin = git(["-C", top, "remote", "get-url", "origin"], top);
|
|
463
|
+
const defRef = git(["-C", top, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], top);
|
|
464
|
+
const defaultBranch = defRef ? defRef.replace(/^refs\/remotes\/origin\//, "") : "";
|
|
465
|
+
const normalized = normalizeOriginUrl(origin);
|
|
466
|
+
const projectId = normalized ? "origin:" + stableHash(normalized) : "root:" + stableHash(top);
|
|
467
|
+
return {
|
|
468
|
+
projectId,
|
|
469
|
+
branch: branch || null,
|
|
470
|
+
rootPath: top,
|
|
471
|
+
defaultBranch: defaultBranch || null,
|
|
472
|
+
};
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── transcript parsing ─────────────────────────────────────────────────────
|
|
479
|
+
function parseTranscript(transcriptPath) {
|
|
480
|
+
const lines = fs.readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean);
|
|
481
|
+
const messages = [];
|
|
482
|
+
for (const line of lines) {
|
|
483
|
+
try {
|
|
484
|
+
const entry = JSON.parse(line);
|
|
485
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
486
|
+
const msg = entry.message;
|
|
487
|
+
if (!msg || typeof msg !== "object") continue;
|
|
488
|
+
const role = msg.role;
|
|
489
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
490
|
+
let text = "";
|
|
491
|
+
if (typeof msg.content === "string") text = msg.content.trim();
|
|
492
|
+
else if (Array.isArray(msg.content)) {
|
|
493
|
+
text = msg.content
|
|
494
|
+
.filter((b) => b.type === "text" && b.text)
|
|
495
|
+
.map((b) => b.text.trim())
|
|
496
|
+
.join("\n")
|
|
497
|
+
.trim();
|
|
498
|
+
}
|
|
499
|
+
if (text) messages.push({ role, content: text });
|
|
500
|
+
} catch {
|
|
501
|
+
/* skip malformed line */
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return messages;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── cursor / lock state hardening (post-tool + session-end) ────────────────
|
|
508
|
+
const SELF_UID = typeof process.getuid === "function" ? process.getuid() : null;
|
|
509
|
+
|
|
510
|
+
function ownedByUs(info) {
|
|
511
|
+
return SELF_UID === null || info.uid === SELF_UID;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Returns { cursorFile, lockFile } or null if the state dir is unsafe.
|
|
515
|
+
function resolveState(sessionId, log) {
|
|
516
|
+
const stateHome = process.env.XDG_STATE_HOME || path.join(HOME, ".local", "state");
|
|
517
|
+
const stateDir = path.join(stateHome, "remnic", "hooks");
|
|
518
|
+
try {
|
|
519
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
520
|
+
} catch {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
const info = fs.lstatSync(stateDir);
|
|
525
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
526
|
+
log(`unsafe state directory ${stateDir}`);
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
if (!ownedByUs(info)) {
|
|
530
|
+
log(`unsafe state directory ${stateDir}`);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
if ((info.mode & 0o077) !== 0) {
|
|
534
|
+
try {
|
|
535
|
+
fs.chmodSync(stateDir, 0o700);
|
|
536
|
+
} catch {
|
|
537
|
+
/* best effort */
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
let cursorFile = path.join(stateDir, `remnic-cursor-${sessionId}`);
|
|
544
|
+
let lockFile = path.join(stateDir, `remnic-lock-${sessionId}.d`);
|
|
545
|
+
const legacyCursor = path.join(stateDir, `engram-cursor-${sessionId}`);
|
|
546
|
+
const legacyLock = path.join(stateDir, `engram-lock-${sessionId}.d`);
|
|
547
|
+
// Mid-migration fallback: adopt the legacy engram-* cursor/lock so we don't
|
|
548
|
+
// re-observe the whole transcript (CLAUDE.md rule #9).
|
|
549
|
+
if (!fs.existsSync(cursorFile) && (fs.existsSync(legacyCursor) || fs.existsSync(legacyLock))) {
|
|
550
|
+
cursorFile = legacyCursor;
|
|
551
|
+
lockFile = legacyLock;
|
|
552
|
+
}
|
|
553
|
+
return { cursorFile, lockFile, legacyCursor, legacyLock };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// true if the cursor file is safe to read/write (or absent).
|
|
557
|
+
function cursorSafe(cursorFile) {
|
|
558
|
+
try {
|
|
559
|
+
const info = fs.lstatSync(cursorFile);
|
|
560
|
+
if (info.isSymbolicLink() || !info.isFile()) return false;
|
|
561
|
+
return ownedByUs(info);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
return err && err.code === "ENOENT";
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readCursor(cursorFile, log) {
|
|
568
|
+
if (!cursorSafe(cursorFile)) {
|
|
569
|
+
log(`unsafe cursor file ${cursorFile}`);
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
const raw = fs.readFileSync(cursorFile, "utf8").trim();
|
|
574
|
+
const n = parseInt(raw, 10);
|
|
575
|
+
return Number.isFinite(n) ? n : 0;
|
|
576
|
+
} catch {
|
|
577
|
+
return 0;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function writeCursor(cursorFile, value, log) {
|
|
582
|
+
if (!cursorSafe(cursorFile)) {
|
|
583
|
+
log(`refusing unsafe cursor file ${cursorFile}`);
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
const tmp = `${cursorFile}.tmp.${process.pid}.${Math.abs(stableHash(String(value)) | 0)}`;
|
|
588
|
+
fs.writeFileSync(tmp, `${value}\n`, { mode: 0o600 });
|
|
589
|
+
fs.renameSync(tmp, cursorFile);
|
|
590
|
+
return true;
|
|
591
|
+
} catch {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function removeCursor(cursorFile, log) {
|
|
597
|
+
if (!cursorSafe(cursorFile)) {
|
|
598
|
+
log(`refusing unsafe cursor file ${cursorFile}`);
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
fs.rmSync(cursorFile, { force: true });
|
|
603
|
+
} catch {
|
|
604
|
+
/* best effort */
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Adopt a higher os.tmpdir() (or /tmp) cursor written by an older
|
|
610
|
+
// cross-process run, then clean.
|
|
611
|
+
function migrateTmpCursor(sessionId, cursorFile, log) {
|
|
612
|
+
for (const tmp of [
|
|
613
|
+
path.join(os.tmpdir(), `remnic-cursor-${sessionId}`),
|
|
614
|
+
path.join(os.tmpdir(), `engram-cursor-${sessionId}`),
|
|
615
|
+
`/tmp/remnic-cursor-${sessionId}`,
|
|
616
|
+
`/tmp/engram-cursor-${sessionId}`,
|
|
617
|
+
]) {
|
|
618
|
+
try {
|
|
619
|
+
if (!fs.existsSync(tmp)) continue;
|
|
620
|
+
const info = fs.lstatSync(tmp);
|
|
621
|
+
if (info.isSymbolicLink() || !info.isFile() || !ownedByUs(info)) continue;
|
|
622
|
+
const raw = fs.readFileSync(tmp, "utf8").trim();
|
|
623
|
+
if (!/^\d+$/.test(raw)) continue;
|
|
624
|
+
const tmpVal = parseInt(raw, 10);
|
|
625
|
+
const current = cursorSafe(cursorFile) ? readCursor(cursorFile, log) : -1;
|
|
626
|
+
if (tmpVal > (current === null ? -1 : current)) writeCursor(cursorFile, tmpVal, log);
|
|
627
|
+
fs.rmSync(tmp, { force: true });
|
|
628
|
+
} catch {
|
|
629
|
+
/* skip */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// mkdir-based mutex with stale-lock reaping (10 min). Returns true if acquired.
|
|
635
|
+
// `retries` bounds the synchronous wait (100ms each). The default (50 → ~5s)
|
|
636
|
+
// suits foreground hooks; PreCompact's tail drain passes a larger budget so it
|
|
637
|
+
// can outlast a detached PostToolUse observe worker holding the lock, ensuring
|
|
638
|
+
// the subsequent flush sees the worker's queued messages instead of racing.
|
|
639
|
+
// 0 means "try once, don't wait/retry" — a free lock is still taken; only a
|
|
640
|
+
// busy lock is skipped immediately (used by the 0-retry test/fleet path). This
|
|
641
|
+
// avoids the degenerate "0 retries skips acquisition entirely" case (#1571
|
|
642
|
+
// review) where a free lock would be wastefully bypassed.
|
|
643
|
+
function acquireLock(lockFile, log, retries = 50) {
|
|
644
|
+
const attempts = Math.max(1, retries);
|
|
645
|
+
for (let i = 0; i < attempts; i++) {
|
|
646
|
+
try {
|
|
647
|
+
fs.mkdirSync(lockFile);
|
|
648
|
+
return true;
|
|
649
|
+
} catch {
|
|
650
|
+
if (i === 0) reapStaleLock(lockFile);
|
|
651
|
+
sleepSync(100);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function reapStaleLock(lockFile) {
|
|
658
|
+
try {
|
|
659
|
+
const info = fs.lstatSync(lockFile);
|
|
660
|
+
if (info.isSymbolicLink() || !info.isDirectory() || !ownedByUs(info)) return;
|
|
661
|
+
if (Date.now() - info.mtimeMs < 10 * 60 * 1000) return;
|
|
662
|
+
fs.rmSync(lockFile, { recursive: true, force: true });
|
|
663
|
+
} catch {
|
|
664
|
+
/* best effort */
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function releaseLock(lockFile) {
|
|
669
|
+
try {
|
|
670
|
+
fs.rmdirSync(lockFile);
|
|
671
|
+
} catch {
|
|
672
|
+
/* best effort */
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function sleepSync(ms) {
|
|
677
|
+
// Synchronous sleep without busy-spin (Atomics.wait on a throwaway buffer).
|
|
678
|
+
try {
|
|
679
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
680
|
+
} catch {
|
|
681
|
+
const end = Date.now() + ms;
|
|
682
|
+
while (Date.now() < end) {
|
|
683
|
+
/* fallback spin */
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── event handlers ─────────────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
async function handleSessionStart(input, token, log) {
|
|
691
|
+
const sessionId = input.session_id || "";
|
|
692
|
+
const cwd = input.cwd || "";
|
|
693
|
+
const projectName = (cwd && path.basename(cwd)) || "unknown";
|
|
694
|
+
const codingContext = resolveCodingContext(cwd);
|
|
695
|
+
log(`session=${sessionId} project=${projectName} coding-context=${codingContext ? "yes" : ""}`);
|
|
696
|
+
|
|
697
|
+
// Health check — start daemon if not running.
|
|
698
|
+
if (!(await httpHealthy(2000, token))) {
|
|
699
|
+
log("daemon not responding, attempting start...");
|
|
700
|
+
// Try `remnic` first, fall through to legacy `engram` when only the
|
|
701
|
+
// older CLI is on PATH. spawn() emits ENOENT *asynchronously* via
|
|
702
|
+
// 'error', so we pre-check the binary with onPath() instead of relying
|
|
703
|
+
// on try/break (#1443 review — the bare try/break never reached
|
|
704
|
+
// `engram`).
|
|
705
|
+
for (const bin of ["remnic", "engram"]) {
|
|
706
|
+
if (!onPath(bin)) continue;
|
|
707
|
+
try {
|
|
708
|
+
// Windows: `remnic`/`engram` are `.cmd` shims, which Node can only
|
|
709
|
+
// launch via a shell (#1443 review). Args are fixed literals — safe.
|
|
710
|
+
const child = spawn(bin, ["daemon", "start"], {
|
|
711
|
+
detached: true,
|
|
712
|
+
stdio: "ignore",
|
|
713
|
+
shell: process.platform === "win32",
|
|
714
|
+
windowsHide: true,
|
|
715
|
+
});
|
|
716
|
+
child.on("error", () => {});
|
|
717
|
+
child.unref();
|
|
718
|
+
break;
|
|
719
|
+
} catch {
|
|
720
|
+
/* try next */
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
724
|
+
if (!(await httpHealthy(2000, token))) {
|
|
725
|
+
log("daemon still not responding after start attempt");
|
|
726
|
+
emit({
|
|
727
|
+
continue: true,
|
|
728
|
+
hookSpecificOutput: {
|
|
729
|
+
hookEventName: "SessionStart",
|
|
730
|
+
additionalContext: "[Remnic: daemon not running — start with: remnic daemon start]",
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (!token) {
|
|
738
|
+
log("skipping: no token found");
|
|
739
|
+
emit({
|
|
740
|
+
continue: true,
|
|
741
|
+
hookSpecificOutput: {
|
|
742
|
+
hookEventName: "SessionStart",
|
|
743
|
+
additionalContext: `[Remnic: no auth token — run: remnic connectors install ${CONNECTOR_INSTALL}]`,
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const query =
|
|
750
|
+
`Starting a new coding session in project: ${projectName}. ` +
|
|
751
|
+
"Recall relevant memories, preferences, decisions, patterns, and context about this project and the user.";
|
|
752
|
+
|
|
753
|
+
// codingContext is explicitly null when absent so stale namespace routing
|
|
754
|
+
// is cleared when a session moves out of a repo.
|
|
755
|
+
let res = await httpPost(
|
|
756
|
+
"/engram/v1/recall",
|
|
757
|
+
token,
|
|
758
|
+
{ query, sessionKey: sessionId, topK: 12, mode: "auto", codingContext },
|
|
759
|
+
45000,
|
|
760
|
+
);
|
|
761
|
+
if (!res.ok || !res.body) {
|
|
762
|
+
log(`full recall failed (http=${res.status}) — falling back to minimal`);
|
|
763
|
+
res = await httpPost(
|
|
764
|
+
"/engram/v1/recall",
|
|
765
|
+
token,
|
|
766
|
+
{ query, sessionKey: sessionId, topK: 8, mode: "minimal", codingContext },
|
|
767
|
+
20000,
|
|
768
|
+
);
|
|
769
|
+
log(res.ok && res.body ? "minimal recall succeeded" : "minimal recall also failed");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
let context;
|
|
773
|
+
if (res.ok && res.body) {
|
|
774
|
+
try {
|
|
775
|
+
const d = JSON.parse(res.body);
|
|
776
|
+
const ctx = d.context || "";
|
|
777
|
+
const count = d.count || 0;
|
|
778
|
+
const mode = d.mode || "";
|
|
779
|
+
context = ctx
|
|
780
|
+
? `[Remnic Memory Recall — ${count} memories${mode ? `, ${mode} mode` : ""}]\n\n${ctx}`
|
|
781
|
+
: "[Remnic: no relevant memories found for this session]";
|
|
782
|
+
} catch {
|
|
783
|
+
context = "[Remnic: recall parse error]";
|
|
784
|
+
}
|
|
785
|
+
log(`recall complete: ${context.split("\n")[0]}`);
|
|
786
|
+
} else {
|
|
787
|
+
context = "[Remnic: server unreachable — continuing without memory recall]";
|
|
788
|
+
log(context);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
emit({
|
|
792
|
+
continue: true,
|
|
793
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function handleUserPromptRecall(input, token, log) {
|
|
798
|
+
// No-token → bare continue (no banner noise on every prompt).
|
|
799
|
+
if (!token) {
|
|
800
|
+
emit({ continue: true });
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const sessionId = input.session_id || "";
|
|
804
|
+
const prompt = input.prompt || "";
|
|
805
|
+
const wordCount = prompt.trim() ? prompt.trim().split(/\s+/).length : 0;
|
|
806
|
+
if (wordCount < 4) {
|
|
807
|
+
emit({ continue: true });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
log(`session=${sessionId} words=${wordCount}`);
|
|
811
|
+
|
|
812
|
+
const res = await httpPost(
|
|
813
|
+
"/engram/v1/recall",
|
|
814
|
+
token,
|
|
815
|
+
{ query: prompt, sessionKey: sessionId, topK: 8, mode: "minimal" },
|
|
816
|
+
20000,
|
|
817
|
+
);
|
|
818
|
+
if (!res.ok || !res.body) {
|
|
819
|
+
log(`recall failed (http=${res.status})`);
|
|
820
|
+
emit({ continue: true });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
const d = JSON.parse(res.body);
|
|
825
|
+
const ctx = d.context || "";
|
|
826
|
+
const count = d.count || 0;
|
|
827
|
+
if (!ctx || count === 0) {
|
|
828
|
+
emit({ continue: true });
|
|
829
|
+
} else {
|
|
830
|
+
emit({
|
|
831
|
+
continue: true,
|
|
832
|
+
hookSpecificOutput: {
|
|
833
|
+
hookEventName: "UserPromptSubmit",
|
|
834
|
+
additionalContext: `<remnic-memory count="${count}">\n${ctx}\n</remnic-memory>`,
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
log(`done: ${count} memories injected`);
|
|
838
|
+
}
|
|
839
|
+
} catch {
|
|
840
|
+
emit({ continue: true });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Background worker: lock + cursor + observe the transcript delta. Detached
|
|
845
|
+
// from the foreground hook so a slow observe never blocks the host past the
|
|
846
|
+
// PostToolUse timeout.
|
|
847
|
+
async function observeWorker(input, token, log) {
|
|
848
|
+
const sessionId = input.session_id || "";
|
|
849
|
+
const transcriptPath = input.transcript_path || "";
|
|
850
|
+
const projectName = (input.cwd && path.basename(input.cwd)) || "unknown";
|
|
851
|
+
const toolName = input.tool_name || "";
|
|
852
|
+
|
|
853
|
+
if (!sessionId || /[^A-Za-z0-9._-]/.test(sessionId)) {
|
|
854
|
+
log(`invalid session id: ${sessionId}`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath) || !fs.statSync(transcriptPath).isFile()) {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const state = resolveState(sessionId, log);
|
|
861
|
+
if (!state) return;
|
|
862
|
+
const { cursorFile, lockFile } = state;
|
|
863
|
+
|
|
864
|
+
if (!acquireLock(lockFile, log)) return;
|
|
865
|
+
try {
|
|
866
|
+
migrateTmpCursor(sessionId, cursorFile, log);
|
|
867
|
+
const lastCount = readCursor(cursorFile, log);
|
|
868
|
+
if (lastCount === null) return;
|
|
869
|
+
|
|
870
|
+
let messages;
|
|
871
|
+
try {
|
|
872
|
+
messages = parseTranscript(transcriptPath);
|
|
873
|
+
} catch {
|
|
874
|
+
log(`parse failed for ${sessionId}`);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const newMessages = messages.slice(lastCount);
|
|
878
|
+
if (newMessages.length === 0) {
|
|
879
|
+
writeCursor(cursorFile, messages.length, log);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
log(
|
|
883
|
+
`observing ${newMessages.length} new messages (cursor ${lastCount}->${messages.length}) ` +
|
|
884
|
+
`project=${projectName} tool=${toolName}`,
|
|
885
|
+
);
|
|
886
|
+
const res = await httpPost(
|
|
887
|
+
"/engram/v1/observe",
|
|
888
|
+
token,
|
|
889
|
+
{ sessionKey: sessionId, messages: newMessages },
|
|
890
|
+
120000,
|
|
891
|
+
);
|
|
892
|
+
if (res.ok) {
|
|
893
|
+
log(`observe OK for ${sessionId}`);
|
|
894
|
+
writeCursor(cursorFile, messages.length, log);
|
|
895
|
+
} else {
|
|
896
|
+
log(`observe failed (http=${res.status}) — cursor not advanced`);
|
|
897
|
+
}
|
|
898
|
+
} finally {
|
|
899
|
+
releaseLock(lockFile);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function handlePostToolObserve(input, token, log, rawInput) {
|
|
904
|
+
// Return immediately — never block the tool.
|
|
905
|
+
emit({ continue: true });
|
|
906
|
+
if (!token) return;
|
|
907
|
+
// Spawn a detached copy to do the observe in the background (mirrors the
|
|
908
|
+
// original `( … ) & disown`). Pass the raw hook payload via the worker's
|
|
909
|
+
// STDIN, not the environment — Windows caps the environment block at
|
|
910
|
+
// ~32 KB, so large PostToolUse payloads (big file edits, command output)
|
|
911
|
+
// would fail with E2BIG/ENAMETOOLONG and the observation would silently
|
|
912
|
+
// drop (#1443 review). Stdin has no comparable limit.
|
|
913
|
+
try {
|
|
914
|
+
const child = spawn(process.execPath, [ENTRY_FILE, OBSERVE_WORKER], {
|
|
915
|
+
detached: true,
|
|
916
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
917
|
+
env: { ...process.env, REMNIC_HOOK_TOKEN: token },
|
|
918
|
+
});
|
|
919
|
+
child.on("error", (e) => log(`observe worker spawn error: ${e && e.message}`));
|
|
920
|
+
child.stdin.on("error", () => {
|
|
921
|
+
/* ignore EPIPE if the worker exits before we finish writing */
|
|
922
|
+
});
|
|
923
|
+
child.stdin.end(rawInput);
|
|
924
|
+
child.unref();
|
|
925
|
+
} catch (err) {
|
|
926
|
+
log(`failed to spawn observe worker: ${err && err.message}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function handleSessionEnd(input, token, log) {
|
|
931
|
+
// Acknowledge immediately.
|
|
932
|
+
emit({ continue: true });
|
|
933
|
+
|
|
934
|
+
const sessionId = input.session_id || "";
|
|
935
|
+
const transcriptPath = input.transcript_path || "";
|
|
936
|
+
const safe = sessionId !== "" && !/[^A-Za-z0-9._-]/.test(sessionId);
|
|
937
|
+
|
|
938
|
+
let state = null;
|
|
939
|
+
if (safe) state = resolveState(sessionId, log);
|
|
940
|
+
|
|
941
|
+
let removeCursorAfterFlush = true;
|
|
942
|
+
|
|
943
|
+
if (token && state && transcriptPath && fs.existsSync(transcriptPath)) {
|
|
944
|
+
const { cursorFile } = state;
|
|
945
|
+
migrateTmpCursor(sessionId, cursorFile, log);
|
|
946
|
+
const lastCount = readCursor(cursorFile, log);
|
|
947
|
+
if (lastCount === null) {
|
|
948
|
+
log(`final flush skipped for ${sessionId} due to unsafe cursor`);
|
|
949
|
+
} else {
|
|
950
|
+
let newMessages = null;
|
|
951
|
+
try {
|
|
952
|
+
newMessages = parseTranscript(transcriptPath).slice(lastCount);
|
|
953
|
+
} catch {
|
|
954
|
+
log(`final flush parse failed for ${sessionId}; cursor retained for retry`);
|
|
955
|
+
removeCursorAfterFlush = false;
|
|
956
|
+
}
|
|
957
|
+
if (newMessages && newMessages.length > 0) {
|
|
958
|
+
log(`final flush for ${sessionId}`);
|
|
959
|
+
const res = await httpPost(
|
|
960
|
+
"/engram/v1/observe",
|
|
961
|
+
token,
|
|
962
|
+
{ sessionKey: sessionId, messages: newMessages },
|
|
963
|
+
30000,
|
|
964
|
+
);
|
|
965
|
+
if (res.ok) {
|
|
966
|
+
log(`final flush OK for ${sessionId}`);
|
|
967
|
+
} else {
|
|
968
|
+
// Critical: retain the cursor so the tail is retried, never lost.
|
|
969
|
+
log(`final flush failed for ${sessionId} (http=${res.status}); cursor retained for retry`);
|
|
970
|
+
removeCursorAfterFlush = false;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Cleanup — only remove the cursor when the flush succeeded or there was
|
|
977
|
+
// nothing pending.
|
|
978
|
+
if (state) {
|
|
979
|
+
const { cursorFile, lockFile, legacyCursor, legacyLock } = state;
|
|
980
|
+
if (removeCursorAfterFlush) removeCursor(cursorFile, log);
|
|
981
|
+
releaseLock(lockFile);
|
|
982
|
+
if (cursorFile !== legacyCursor) {
|
|
983
|
+
try {
|
|
984
|
+
const info = fs.lstatSync(legacyCursor);
|
|
985
|
+
if (!info.isSymbolicLink()) fs.rmSync(legacyCursor, { force: true });
|
|
986
|
+
} catch {
|
|
987
|
+
/* absent */
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (lockFile !== legacyLock) {
|
|
991
|
+
try {
|
|
992
|
+
fs.rmdirSync(legacyLock);
|
|
993
|
+
} catch {
|
|
994
|
+
/* absent */
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (config.enableMaterialize) runMaterialize(log);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Drain any unobserved transcript tail to /engram/v1/observe under the
|
|
1003
|
+
// session's cursor/lock, advancing the cursor only on a successful observe.
|
|
1004
|
+
// Shared mid-session drain used by handlePreCompact (the session-end path in
|
|
1005
|
+
// handleSessionEnd has its own cursor-removal cleanup and is intentionally
|
|
1006
|
+
// not refactored onto this helper to avoid perturbing its retention
|
|
1007
|
+
// semantics).
|
|
1008
|
+
//
|
|
1009
|
+
// Why this exists separately from the LCM flush: /engram/v1/lcm/compaction/flush
|
|
1010
|
+
// can only drain work ALREADY queued by prior /observe calls. If a turn
|
|
1011
|
+
// landed after the last PostToolUse (e.g. a long user prompt that triggers
|
|
1012
|
+
// auto compaction without a Bash tool call in between), that tail is still
|
|
1013
|
+
// only in the transcript and would be lost when the host summarizes/replaces
|
|
1014
|
+
// context. So PreCompact must observe the delta FIRST, then ask LCM to
|
|
1015
|
+
// flush.
|
|
1016
|
+
//
|
|
1017
|
+
// Returns "busy" when the session lock could not be acquired within the
|
|
1018
|
+
// caller's budget (a detached PostToolUse observe worker is still holding
|
|
1019
|
+
// it), so handlePreCompact can SKIP the flush instead of letting it drain
|
|
1020
|
+
// nothing and race the worker; true when the tail was observed (or nothing
|
|
1021
|
+
// pending); false on parse/observe failure (cursor retained) so the caller
|
|
1022
|
+
// can still flush already-queued work. Always fail-open.
|
|
1023
|
+
//
|
|
1024
|
+
// `lockRetries` (100ms each) bounds how long to wait for a busy worker.
|
|
1025
|
+
async function drainTranscriptTail(sessionId, transcriptPath, token, log, lockRetries = 50) {
|
|
1026
|
+
const safe = sessionId !== "" && !/[^A-Za-z0-9._-]/.test(sessionId);
|
|
1027
|
+
if (!safe || !token || !transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
const state = resolveState(sessionId, log);
|
|
1031
|
+
if (!state) return false;
|
|
1032
|
+
const { cursorFile, lockFile } = state;
|
|
1033
|
+
// The lock guards against the detached post-tool observe worker racing
|
|
1034
|
+
// this drain. Wait up to the caller's budget for the worker to finish its
|
|
1035
|
+
// /observe and release the lock — once it does, either the worker already
|
|
1036
|
+
// advanced the cursor (nothing left to drain) or we observe the remaining
|
|
1037
|
+
// delta, and in both cases the subsequent flush sees all queued work.
|
|
1038
|
+
// Only if the worker outlasts our budget do we return "busy" so
|
|
1039
|
+
// handlePreCompact SKIPS the flush rather than racing ahead of the
|
|
1040
|
+
// worker's in-flight /observe.
|
|
1041
|
+
if (!acquireLock(lockFile, log, lockRetries)) {
|
|
1042
|
+
log(`transcript tail drain skipped for ${sessionId}: lock busy (worker still running)`);
|
|
1043
|
+
return "busy";
|
|
1044
|
+
}
|
|
1045
|
+
try {
|
|
1046
|
+
migrateTmpCursor(sessionId, cursorFile, log);
|
|
1047
|
+
const lastCount = readCursor(cursorFile, log);
|
|
1048
|
+
if (lastCount === null) {
|
|
1049
|
+
log(`transcript tail drain skipped for ${sessionId}: unsafe cursor`);
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
let newMessages;
|
|
1053
|
+
try {
|
|
1054
|
+
newMessages = parseTranscript(transcriptPath).slice(lastCount);
|
|
1055
|
+
} catch {
|
|
1056
|
+
log(`transcript tail parse failed for ${sessionId}; cursor retained`);
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
if (newMessages.length === 0) return true;
|
|
1060
|
+
log(`transcript tail drain: ${newMessages.length} new message(s) for ${sessionId}`);
|
|
1061
|
+
const res = await httpPost(
|
|
1062
|
+
"/engram/v1/observe",
|
|
1063
|
+
token,
|
|
1064
|
+
{ sessionKey: sessionId, messages: newMessages },
|
|
1065
|
+
30000,
|
|
1066
|
+
);
|
|
1067
|
+
if (res.ok) {
|
|
1068
|
+
// Advance (not remove) the cursor — the session continues after
|
|
1069
|
+
// compaction, so the post-compact transcript must not re-observe this
|
|
1070
|
+
// tail.
|
|
1071
|
+
writeCursor(cursorFile, lastCount + newMessages.length, log);
|
|
1072
|
+
return true;
|
|
1073
|
+
}
|
|
1074
|
+
log(`transcript tail drain failed for ${sessionId} (http=${res.status}); cursor retained`);
|
|
1075
|
+
return false;
|
|
1076
|
+
} finally {
|
|
1077
|
+
releaseLock(lockFile);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// PreCompact (#1571) — coordinate with Remnic's LCM layer before the host
|
|
1082
|
+
// compacts the conversation. Mirrors @remnic/plugin-pi's
|
|
1083
|
+
// session_before_compact handler, with one host-specific addition: because
|
|
1084
|
+
// the host observes asynchronously (PostToolUse on Bash + Stop), an
|
|
1085
|
+
// unobserved transcript tail can exist when compaction fires mid-session.
|
|
1086
|
+
// So we drain that tail to /engram/v1/observe FIRST, then POST
|
|
1087
|
+
// /engram/v1/lcm/compaction/flush so the daemon flushes the now-complete
|
|
1088
|
+
// observe buffer into long-term memory before the transcript is summarized.
|
|
1089
|
+
// ALWAYS returns continue:true — a hook failure must never block the
|
|
1090
|
+
// host's compaction (the upstream contract notes continue:false stops the
|
|
1091
|
+
// compact entirely), so this handler is fail-open end to end.
|
|
1092
|
+
async function handlePreCompact(input, token, log) {
|
|
1093
|
+
const sessionId = input.session_id || "";
|
|
1094
|
+
const transcriptPath = input.transcript_path || "";
|
|
1095
|
+
const trigger = input.trigger || "auto";
|
|
1096
|
+
// Acknowledge first so the host never waits on the network for compaction
|
|
1097
|
+
// to proceed — the drain + flush are best-effort coordination, not a gate.
|
|
1098
|
+
emit({ continue: true });
|
|
1099
|
+
if (!token) {
|
|
1100
|
+
log(`skipping pre-compact drain+flush: no token (session=${sessionId} trigger=${trigger})`);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
// 1. Drain any unobserved transcript tail so it's archived before compaction.
|
|
1104
|
+
// Wait up to PRECOMPACT_LOCK_RETRIES for a concurrent observe worker to
|
|
1105
|
+
// release the session lock, so the flush below sees the worker's queued
|
|
1106
|
+
// messages. If the worker outlasts us, drain returns "busy" and we SKIP
|
|
1107
|
+
// the flush entirely — flushing now would drain nothing and the worker's
|
|
1108
|
+
// in-flight observe would miss this compaction, so we defer to the next
|
|
1109
|
+
// PreCompact/Stop rather than race.
|
|
1110
|
+
const drainResult = await drainTranscriptTail(
|
|
1111
|
+
sessionId,
|
|
1112
|
+
transcriptPath,
|
|
1113
|
+
token,
|
|
1114
|
+
log,
|
|
1115
|
+
PRECOMPACT_LOCK_RETRIES,
|
|
1116
|
+
);
|
|
1117
|
+
if (drainResult === "busy") {
|
|
1118
|
+
log(`skipping LCM flush: tail drain lock busy (session=${sessionId} trigger=${trigger}); deferring to next cycle`);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// 2. Ask the LCM layer to flush the (now-complete) observe buffer.
|
|
1122
|
+
const res = await httpPost(
|
|
1123
|
+
"/engram/v1/lcm/compaction/flush",
|
|
1124
|
+
token,
|
|
1125
|
+
{ sessionKey: sessionId },
|
|
1126
|
+
20000,
|
|
1127
|
+
);
|
|
1128
|
+
if (res.ok) {
|
|
1129
|
+
log(`LCM compaction flush OK (session=${sessionId} trigger=${trigger})`);
|
|
1130
|
+
} else {
|
|
1131
|
+
// Fail-open: log and move on. Compaction proceeds regardless.
|
|
1132
|
+
log(`LCM compaction flush failed (http=${res.status}) — compaction proceeds`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ── host-native memory materialization (#378, Codex-only) ─────────────────
|
|
1137
|
+
function runMaterialize(log) {
|
|
1138
|
+
if (process.env.REMNIC_CODEX_MATERIALIZE === "0") return;
|
|
1139
|
+
const hookDir = __dirname;
|
|
1140
|
+
|
|
1141
|
+
// 1. explicit override → 2. packaged bin → 3. dev tsx fallback.
|
|
1142
|
+
let bin = process.env.REMNIC_CODEX_MATERIALIZE_BIN || "";
|
|
1143
|
+
if (!bin) {
|
|
1144
|
+
const candidate = path.join(hookDir, "..", "..", "bin", "materialize.cjs");
|
|
1145
|
+
try {
|
|
1146
|
+
if (fs.existsSync(candidate)) bin = fs.realpathSync(candidate);
|
|
1147
|
+
} catch {
|
|
1148
|
+
/* ignore */
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
let repoRoot = process.env.REMNIC_REPO_ROOT || "";
|
|
1152
|
+
if (!repoRoot) {
|
|
1153
|
+
try {
|
|
1154
|
+
const candidateRoot = fs.realpathSync(path.join(hookDir, "..", "..", "..", ".."));
|
|
1155
|
+
if (fs.existsSync(path.join(candidateRoot, "scripts", "codex-materialize.ts"))) {
|
|
1156
|
+
repoRoot = candidateRoot;
|
|
1157
|
+
}
|
|
1158
|
+
} catch {
|
|
1159
|
+
/* ignore */
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Force HOME to the home dir the runner resolved (HOME → USERPROFILE →
|
|
1164
|
+
// os.homedir()). The materializer resolves config paths from HOME and
|
|
1165
|
+
// only falls back to os.homedir(); on Windows, where HOME is typically
|
|
1166
|
+
// unset, passing it explicitly guarantees the child uses the SAME home
|
|
1167
|
+
// as the hook instead of diverging (#1443 review).
|
|
1168
|
+
const childEnv = { ...process.env, HOME };
|
|
1169
|
+
try {
|
|
1170
|
+
if (bin && fs.existsSync(bin)) {
|
|
1171
|
+
const r = spawnSync(process.execPath, [bin, "--reason", "session_end"], {
|
|
1172
|
+
stdio: "ignore",
|
|
1173
|
+
timeout: 60000,
|
|
1174
|
+
env: childEnv,
|
|
1175
|
+
});
|
|
1176
|
+
if (r.status !== 0) log(`codex-materialize session_end failed (packaged bin=${bin})`);
|
|
1177
|
+
} else if (repoRoot) {
|
|
1178
|
+
const r = spawnSync("npx", ["--yes", "tsx", "scripts/codex-materialize.ts", "--reason", "session_end"], {
|
|
1179
|
+
cwd: repoRoot,
|
|
1180
|
+
stdio: "ignore",
|
|
1181
|
+
timeout: 120000,
|
|
1182
|
+
shell: process.platform === "win32",
|
|
1183
|
+
env: childEnv,
|
|
1184
|
+
});
|
|
1185
|
+
if (r.status !== 0) log("codex-materialize session_end failed (dev script)");
|
|
1186
|
+
} else {
|
|
1187
|
+
log(`codex-materialize skipped — could not resolve packaged bin or REMNIC_REPO_ROOT (hook_dir=${hookDir})`);
|
|
1188
|
+
}
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
log(`codex-materialize error: ${err && err.message}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ── entrypoint ─────────────────────────────────────────────────────────────
|
|
1195
|
+
async function main() {
|
|
1196
|
+
const event = process.argv[2] || "";
|
|
1197
|
+
|
|
1198
|
+
// Detached background worker for post-tool-observe.
|
|
1199
|
+
if (event === OBSERVE_WORKER) {
|
|
1200
|
+
const log = makeLogger("post-tool-observe");
|
|
1201
|
+
try {
|
|
1202
|
+
const raw = readStdin();
|
|
1203
|
+
const input = parseInput(raw);
|
|
1204
|
+
const token = process.env.REMNIC_HOOK_TOKEN || resolveToken();
|
|
1205
|
+
if (token) await observeWorker(input, token, log);
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
log(`observe worker error: ${err && err.message}`);
|
|
1208
|
+
}
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (!Object.prototype.hasOwnProperty.call(LOG_FILES, event)) {
|
|
1213
|
+
// Unknown event — fail open without side effects.
|
|
1214
|
+
emit({ continue: true });
|
|
1215
|
+
if (event) process.stderr.write(`${PROG_NAME}: unknown event "${event}"\n`);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const log = makeLogger(event);
|
|
1220
|
+
const raw = readStdin();
|
|
1221
|
+
const input = parseInput(raw);
|
|
1222
|
+
|
|
1223
|
+
const handlers = {
|
|
1224
|
+
"session-start": handleSessionStart,
|
|
1225
|
+
"user-prompt-recall": handleUserPromptRecall,
|
|
1226
|
+
"post-tool-observe": handlePostToolObserve,
|
|
1227
|
+
"session-end": handleSessionEnd,
|
|
1228
|
+
};
|
|
1229
|
+
if (config.enablePreCompact) handlers["pre-compact"] = handlePreCompact;
|
|
1230
|
+
|
|
1231
|
+
try {
|
|
1232
|
+
ensureMigrated();
|
|
1233
|
+
const token = resolveToken();
|
|
1234
|
+
const handler = handlers[event];
|
|
1235
|
+
if (handler) await handler(input, token, log, raw);
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
log(`unhandled error in ${event}: ${err && err.message}`);
|
|
1238
|
+
// Best-effort fail-open: emit a bare continue only if we haven't already.
|
|
1239
|
+
if (!emitted) {
|
|
1240
|
+
try {
|
|
1241
|
+
emit({ continue: true });
|
|
1242
|
+
} catch {
|
|
1243
|
+
/* stdout already written */
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
main().catch((err) => {
|
|
1250
|
+
// Belt-and-suspenders fail-open for anything main() itself throws before
|
|
1251
|
+
// its internal try/catch (rule: a hook error must never block the host).
|
|
1252
|
+
try {
|
|
1253
|
+
process.stderr.write(`${PROG_NAME}: unhandled error: ${err && err.message}\n`);
|
|
1254
|
+
} catch {
|
|
1255
|
+
/* stderr already closed */
|
|
1256
|
+
}
|
|
1257
|
+
if (!emitted) {
|
|
1258
|
+
try {
|
|
1259
|
+
emit({ continue: true });
|
|
1260
|
+
} catch {
|
|
1261
|
+
/* stdout already written */
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
module.exports = { run };
|