@remnic/plugin-claude-code 9.3.687 → 9.3.689
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/bin/remnic-cc-hook.cjs +917 -0
- package/hooks/bin/remnic-cc-hook.ps1 +10 -0
- package/hooks/bin/remnic-cc-hook.sh +7 -0
- package/hooks/bin/remnic-cc-hook.test.cjs +581 -0
- package/hooks/hooks.json +15 -3
- package/package.json +1 -1
- package/hooks/bin/post-tool-observe.sh +0 -279
- package/hooks/bin/session-end.sh +0 -38
- package/hooks/bin/session-start.sh +0 -254
- package/hooks/bin/user-prompt-recall.sh +0 -113
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Remnic unified Claude Code hook runner (issue #1518).
|
|
4
|
+
*
|
|
5
|
+
* A single cross-platform Node.js implementation of all four Claude Code
|
|
6
|
+
* hooks. Thin `.sh` (POSIX) and `.ps1` (Windows) wrappers exec this file with
|
|
7
|
+
* the event name as argv[2]:
|
|
8
|
+
*
|
|
9
|
+
* node remnic-cc-hook.cjs <event>
|
|
10
|
+
*
|
|
11
|
+
* Events: session-start | user-prompt-recall | post-tool-observe | session-end
|
|
12
|
+
*
|
|
13
|
+
* This is a faithful port of the original four bash scripts — same endpoints,
|
|
14
|
+
* env vars, token resolution, cursor/lock hardening, engram→remnic migration,
|
|
15
|
+
* daemon health/auto-start, and git coding-context projectId derivation. Node
|
|
16
|
+
* replaces the per-script `node -e` one-liners and Unix tools
|
|
17
|
+
* (curl/git/sed/mktemp/…) so the exact same logic runs on Windows, macOS, and
|
|
18
|
+
* Linux. This mirrors the proven @remnic/plugin-codex runner (issue #1440);
|
|
19
|
+
* the only differences are the client-id header, token connector priority,
|
|
20
|
+
* log tags, and the absence of Codex-native memory materialization.
|
|
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
|
+
* Fail-open everywhere: any unexpected error degrades to `{"continue":true}`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
"use strict";
|
|
34
|
+
|
|
35
|
+
const fs = require("fs");
|
|
36
|
+
const os = require("os");
|
|
37
|
+
const path = require("path");
|
|
38
|
+
const http = require("http");
|
|
39
|
+
const { execFileSync, spawn, spawnSync } = require("child_process");
|
|
40
|
+
|
|
41
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
42
|
+
const HOST = process.env.REMNIC_HOST || process.env.ENGRAM_HOST || "127.0.0.1";
|
|
43
|
+
const PORT = process.env.REMNIC_PORT || process.env.ENGRAM_PORT || "4318";
|
|
44
|
+
|
|
45
|
+
// Internal re-entrant mode: post-tool-observe spawns a detached copy of itself
|
|
46
|
+
// so the (slow) observe runs in the background and never blocks Claude Code
|
|
47
|
+
// past the short PostToolUse timeout — mirroring the original `( … ) & disown`.
|
|
48
|
+
const OBSERVE_WORKER = "__observe-worker__";
|
|
49
|
+
|
|
50
|
+
const LOG_FILES = {
|
|
51
|
+
"session-start": "remnic-session-recall.log",
|
|
52
|
+
"user-prompt-recall": "remnic-user-prompt-recall.log",
|
|
53
|
+
"post-tool-observe": "remnic-post-tool-observe.log",
|
|
54
|
+
"session-end": "remnic-cc-session-end.log",
|
|
55
|
+
};
|
|
56
|
+
const LOG_TAGS = {
|
|
57
|
+
"session-start": "cc-session-start",
|
|
58
|
+
"user-prompt-recall": "cc-user-prompt",
|
|
59
|
+
"post-tool-observe": "cc-post-tool",
|
|
60
|
+
"session-end": "cc-stop",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function makeLogger(event) {
|
|
64
|
+
const file = path.join(HOME, ".remnic", "logs", LOG_FILES[event] || "remnic-cc-hook.log");
|
|
65
|
+
const tag = LOG_TAGS[event] || "cc-hook";
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
} catch {
|
|
69
|
+
/* best effort */
|
|
70
|
+
}
|
|
71
|
+
return (msg) => {
|
|
72
|
+
try {
|
|
73
|
+
const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
74
|
+
fs.appendFileSync(file, `${ts} [${tag}] ${msg}\n`);
|
|
75
|
+
} catch {
|
|
76
|
+
/* logging must never throw */
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let emitted = false;
|
|
82
|
+
function emit(obj) {
|
|
83
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
84
|
+
emitted = true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readStdin() {
|
|
88
|
+
// Always read the real stdin. The foreground hook gets its payload from
|
|
89
|
+
// Claude Code on fd 0; the detached observe worker gets it from the pipe the
|
|
90
|
+
// foreground writes. We deliberately do NOT consult an env var here — an
|
|
91
|
+
// inherited REMNIC_HOOK_INPUT in the parent environment would otherwise
|
|
92
|
+
// override the piped payload and the worker could observe stale/empty input.
|
|
93
|
+
try {
|
|
94
|
+
return fs.readFileSync(0, "utf8");
|
|
95
|
+
} catch {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseInput(raw) {
|
|
101
|
+
try {
|
|
102
|
+
const d = JSON.parse(raw);
|
|
103
|
+
return d && typeof d === "object" ? d : {};
|
|
104
|
+
} catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── engram → remnic migration (CLAUDE.md rule #9) ──────────────────────────
|
|
110
|
+
function ensureMigrated() {
|
|
111
|
+
try {
|
|
112
|
+
if (fs.existsSync(path.join(HOME, ".remnic", ".migrated-from-engram"))) return;
|
|
113
|
+
const hasEngram =
|
|
114
|
+
fs.existsSync(path.join(HOME, ".engram")) ||
|
|
115
|
+
fs.existsSync(path.join(HOME, ".config", "engram", "config.json"));
|
|
116
|
+
if (!hasEngram) return;
|
|
117
|
+
// Try `remnic` first, fall through to legacy `engram` when missing on PATH.
|
|
118
|
+
// Pre-check PATH with onPath() (which is .cmd/.exe-aware on Windows) rather
|
|
119
|
+
// than relying on spawnSync ENOENT — under `shell: true` a missing command
|
|
120
|
+
// yields a non-zero shell exit, not ENOENT, so an exit-code check couldn't
|
|
121
|
+
// distinguish "missing" from "migration failed".
|
|
122
|
+
// On Windows the CLIs are `.cmd` shims, which Node can only launch via a
|
|
123
|
+
// shell. Timeout is 5 min so a large migration can complete. Args are
|
|
124
|
+
// fixed literals — safe under a shell.
|
|
125
|
+
for (const bin of ["remnic", "engram"]) {
|
|
126
|
+
if (!onPath(bin)) continue;
|
|
127
|
+
spawnSync(bin, ["migrate"], {
|
|
128
|
+
stdio: "ignore",
|
|
129
|
+
timeout: 300000,
|
|
130
|
+
shell: process.platform === "win32",
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
/* migration is best effort */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// PATH lookup helper (cross-platform equivalent of bash `command -v`).
|
|
141
|
+
// Returns true when an executable named `bin` is reachable via $PATH. Used
|
|
142
|
+
// before async `spawn()` calls so the remnic → engram fallthrough actually
|
|
143
|
+
// happens when only the legacy CLI is installed — `spawn` emits ENOENT
|
|
144
|
+
// asynchronously via 'error', so a naive try/break can't see it.
|
|
145
|
+
function onPath(bin) {
|
|
146
|
+
const PATH = process.env.PATH || process.env.Path || process.env.path || "";
|
|
147
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
148
|
+
// Windows resolves names without an extension by appending PATHEXT entries.
|
|
149
|
+
const exts =
|
|
150
|
+
process.platform === "win32"
|
|
151
|
+
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
|
|
152
|
+
.split(";")
|
|
153
|
+
.map((e) => e.trim())
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
: [""];
|
|
156
|
+
for (const dir of PATH.split(sep)) {
|
|
157
|
+
if (!dir) continue;
|
|
158
|
+
for (const ext of exts) {
|
|
159
|
+
try {
|
|
160
|
+
const candidate = path.join(dir, bin + ext);
|
|
161
|
+
const info = fs.statSync(candidate);
|
|
162
|
+
if (info.isFile()) return true;
|
|
163
|
+
} catch {
|
|
164
|
+
/* try next */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── token resolution (per-plugin token store, then env) ────────────────────
|
|
172
|
+
function resolveToken() {
|
|
173
|
+
for (const file of [
|
|
174
|
+
path.join(HOME, ".remnic", "tokens.json"),
|
|
175
|
+
path.join(HOME, ".engram", "tokens.json"),
|
|
176
|
+
]) {
|
|
177
|
+
try {
|
|
178
|
+
if (!fs.existsSync(file)) continue;
|
|
179
|
+
const store = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
180
|
+
const tokens = Array.isArray(store.tokens) ? store.tokens : [];
|
|
181
|
+
const byConnector = (c) => tokens.find((t) => t && t.connector === c);
|
|
182
|
+
const tok =
|
|
183
|
+
(byConnector("claude-code") || {}).token ||
|
|
184
|
+
(byConnector("openclaw") || {}).token ||
|
|
185
|
+
store["claude-code"] ||
|
|
186
|
+
store["openclaw"] ||
|
|
187
|
+
"";
|
|
188
|
+
if (tok) return tok;
|
|
189
|
+
} catch {
|
|
190
|
+
/* try next file */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return (
|
|
194
|
+
process.env.OPENCLAW_REMNIC_ACCESS_TOKEN ||
|
|
195
|
+
process.env.OPENCLAW_ENGRAM_ACCESS_TOKEN ||
|
|
196
|
+
""
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── HTTP helpers — return a real success signal (2xx) so callers can decide
|
|
201
|
+
// whether to advance/clear the cursor. ─────────────────────────────────────
|
|
202
|
+
function httpPost(urlPath, token, bodyObj, timeoutMs) {
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
let data;
|
|
205
|
+
try {
|
|
206
|
+
data = Buffer.from(JSON.stringify(bodyObj), "utf8");
|
|
207
|
+
} catch {
|
|
208
|
+
resolve({ ok: false, status: 0, body: "" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const req = http.request(
|
|
212
|
+
{
|
|
213
|
+
host: HOST,
|
|
214
|
+
port: PORT,
|
|
215
|
+
path: urlPath,
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `Bearer ${token}`,
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
"X-Engram-Client-Id": "claude-code",
|
|
221
|
+
"Content-Length": data.length,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
(res) => {
|
|
225
|
+
let body = "";
|
|
226
|
+
res.setEncoding("utf8");
|
|
227
|
+
res.on("data", (c) => {
|
|
228
|
+
body += c;
|
|
229
|
+
});
|
|
230
|
+
res.on("end", () =>
|
|
231
|
+
resolve({
|
|
232
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
233
|
+
status: res.statusCode || 0,
|
|
234
|
+
body,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
req.on("error", () => resolve({ ok: false, status: 0, body: "" }));
|
|
240
|
+
req.setTimeout(timeoutMs, () => {
|
|
241
|
+
req.destroy();
|
|
242
|
+
resolve({ ok: false, status: 0, body: "" });
|
|
243
|
+
});
|
|
244
|
+
req.write(data);
|
|
245
|
+
req.end();
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function httpHealthy(timeoutMs) {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
const req = http.request(
|
|
252
|
+
{ host: HOST, port: PORT, path: "/engram/v1/health", method: "GET" },
|
|
253
|
+
(res) => {
|
|
254
|
+
res.resume();
|
|
255
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
req.on("error", () => resolve(false));
|
|
259
|
+
req.setTimeout(timeoutMs, () => {
|
|
260
|
+
req.destroy();
|
|
261
|
+
resolve(false);
|
|
262
|
+
});
|
|
263
|
+
req.end();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── git coding-context (mirrors @remnic/core git-context.ts) ───────────────
|
|
268
|
+
function git(args, cwd) {
|
|
269
|
+
try {
|
|
270
|
+
return execFileSync("git", args, {
|
|
271
|
+
cwd,
|
|
272
|
+
encoding: "utf8",
|
|
273
|
+
timeout: 5000,
|
|
274
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
275
|
+
}).trim();
|
|
276
|
+
} catch {
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function stableHash(input) {
|
|
282
|
+
let hash = 0x811c9dc5;
|
|
283
|
+
for (let i = 0; i < input.length; i++) {
|
|
284
|
+
hash ^= input.charCodeAt(i);
|
|
285
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
286
|
+
}
|
|
287
|
+
return hash.toString(16).padStart(8, "0");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Mirrors packages/remnic-core/src/coding/git-context.ts normalizeOriginUrl.
|
|
291
|
+
// Keep in sync so the hook-computed projectId matches the daemon's.
|
|
292
|
+
function normalizeOriginUrl(raw) {
|
|
293
|
+
let u = (raw || "").trim();
|
|
294
|
+
if (!u) return "";
|
|
295
|
+
if (/\.git$/i.test(u)) u = u.slice(0, -4);
|
|
296
|
+
if (/^[A-Za-z]:[\\/]/.test(u)) return u.toLowerCase();
|
|
297
|
+
const proto = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?(\[[^\]]+\]|[^/:]*)(?::(\d+))?(\/.*)?$/i.exec(u);
|
|
298
|
+
if (proto) {
|
|
299
|
+
let host = proto[1] || "";
|
|
300
|
+
const wasBracketed = host.startsWith("[") && host.endsWith("]");
|
|
301
|
+
if (wasBracketed) host = host.slice(1, -1);
|
|
302
|
+
const port = proto[2];
|
|
303
|
+
const p = (proto[3] || "").replace(/^\/+/, "");
|
|
304
|
+
const hostPort = port
|
|
305
|
+
? wasBracketed
|
|
306
|
+
? "[" + host + "]:" + port
|
|
307
|
+
: host + ":" + port
|
|
308
|
+
: host;
|
|
309
|
+
const prefix = hostPort.length > 0 ? hostPort : "localhost";
|
|
310
|
+
return (prefix + "/" + p).toLowerCase();
|
|
311
|
+
}
|
|
312
|
+
const scp = /^(?:([^@\s/]+)@)?(\[[^\]]+\]|[^:@\s/]+):(.+)$/.exec(u);
|
|
313
|
+
if (scp) {
|
|
314
|
+
let host = scp[2] || "";
|
|
315
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
316
|
+
const p = scp[3] || "";
|
|
317
|
+
if (p.startsWith("//")) return u.toLowerCase();
|
|
318
|
+
return (host + "/" + p.replace(/^\/+/, "")).toLowerCase();
|
|
319
|
+
}
|
|
320
|
+
return u.toLowerCase();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function resolveCodingContext(cwd) {
|
|
324
|
+
try {
|
|
325
|
+
if (!cwd || !fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) return null;
|
|
326
|
+
const top = git(["-C", cwd, "rev-parse", "--show-toplevel"], cwd);
|
|
327
|
+
if (!top) return null;
|
|
328
|
+
let branch = git(["-C", top, "rev-parse", "--abbrev-ref", "HEAD"], top);
|
|
329
|
+
if (branch === "HEAD") branch = "";
|
|
330
|
+
const origin = git(["-C", top, "remote", "get-url", "origin"], top);
|
|
331
|
+
const defRef = git(["-C", top, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], top);
|
|
332
|
+
const defaultBranch = defRef ? defRef.replace(/^refs\/remotes\/origin\//, "") : "";
|
|
333
|
+
const normalized = normalizeOriginUrl(origin);
|
|
334
|
+
const projectId = normalized ? "origin:" + stableHash(normalized) : "root:" + stableHash(top);
|
|
335
|
+
return {
|
|
336
|
+
projectId,
|
|
337
|
+
branch: branch || null,
|
|
338
|
+
rootPath: top,
|
|
339
|
+
defaultBranch: defaultBranch || null,
|
|
340
|
+
};
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── transcript parsing ─────────────────────────────────────────────────────
|
|
347
|
+
function parseTranscript(transcriptPath) {
|
|
348
|
+
const lines = fs.readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean);
|
|
349
|
+
const messages = [];
|
|
350
|
+
for (const line of lines) {
|
|
351
|
+
try {
|
|
352
|
+
const entry = JSON.parse(line);
|
|
353
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
354
|
+
const msg = entry.message;
|
|
355
|
+
if (!msg || typeof msg !== "object") continue;
|
|
356
|
+
const role = msg.role;
|
|
357
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
358
|
+
let text = "";
|
|
359
|
+
if (typeof msg.content === "string") text = msg.content.trim();
|
|
360
|
+
else if (Array.isArray(msg.content)) {
|
|
361
|
+
text = msg.content
|
|
362
|
+
.filter((b) => b.type === "text" && b.text)
|
|
363
|
+
.map((b) => b.text.trim())
|
|
364
|
+
.join("\n")
|
|
365
|
+
.trim();
|
|
366
|
+
}
|
|
367
|
+
if (text) messages.push({ role, content: text });
|
|
368
|
+
} catch {
|
|
369
|
+
/* skip malformed line */
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return messages;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── cursor / lock state hardening (post-tool + session-end) ────────────────
|
|
376
|
+
const SELF_UID = typeof process.getuid === "function" ? process.getuid() : null;
|
|
377
|
+
|
|
378
|
+
function ownedByUs(info) {
|
|
379
|
+
return SELF_UID === null || info.uid === SELF_UID;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Returns { cursorFile, lockFile } or null if the state dir is unsafe.
|
|
383
|
+
function resolveState(sessionId, log) {
|
|
384
|
+
const stateHome = process.env.XDG_STATE_HOME || path.join(HOME, ".local", "state");
|
|
385
|
+
const stateDir = path.join(stateHome, "remnic", "hooks");
|
|
386
|
+
try {
|
|
387
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const info = fs.lstatSync(stateDir);
|
|
393
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
394
|
+
log(`unsafe state directory ${stateDir}`);
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
if (!ownedByUs(info)) {
|
|
398
|
+
log(`unsafe state directory ${stateDir}`);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
if ((info.mode & 0o077) !== 0) {
|
|
402
|
+
try {
|
|
403
|
+
fs.chmodSync(stateDir, 0o700);
|
|
404
|
+
} catch {
|
|
405
|
+
/* best effort */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
let cursorFile = path.join(stateDir, `remnic-cursor-${sessionId}`);
|
|
412
|
+
let lockFile = path.join(stateDir, `remnic-lock-${sessionId}.d`);
|
|
413
|
+
const legacyCursor = path.join(stateDir, `engram-cursor-${sessionId}`);
|
|
414
|
+
const legacyLock = path.join(stateDir, `engram-lock-${sessionId}.d`);
|
|
415
|
+
// Mid-migration fallback: adopt the legacy engram-* cursor/lock so we don't
|
|
416
|
+
// re-observe the whole transcript (CLAUDE.md rule #9).
|
|
417
|
+
if (!fs.existsSync(cursorFile) && (fs.existsSync(legacyCursor) || fs.existsSync(legacyLock))) {
|
|
418
|
+
cursorFile = legacyCursor;
|
|
419
|
+
lockFile = legacyLock;
|
|
420
|
+
}
|
|
421
|
+
return { cursorFile, lockFile, legacyCursor, legacyLock };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// true if the cursor file is safe to read/write (or absent).
|
|
425
|
+
function cursorSafe(cursorFile) {
|
|
426
|
+
try {
|
|
427
|
+
const info = fs.lstatSync(cursorFile);
|
|
428
|
+
if (info.isSymbolicLink() || !info.isFile()) return false;
|
|
429
|
+
return ownedByUs(info);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return err && err.code === "ENOENT";
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function readCursor(cursorFile, log) {
|
|
436
|
+
if (!cursorSafe(cursorFile)) {
|
|
437
|
+
log(`unsafe cursor file ${cursorFile}`);
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const raw = fs.readFileSync(cursorFile, "utf8").trim();
|
|
442
|
+
const n = parseInt(raw, 10);
|
|
443
|
+
return Number.isFinite(n) ? n : 0;
|
|
444
|
+
} catch {
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function writeCursor(cursorFile, value, log) {
|
|
450
|
+
if (!cursorSafe(cursorFile)) {
|
|
451
|
+
log(`refusing unsafe cursor file ${cursorFile}`);
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const tmp = `${cursorFile}.tmp.${process.pid}.${Math.abs(stableHash(String(value)) | 0)}`;
|
|
456
|
+
fs.writeFileSync(tmp, `${value}\n`, { mode: 0o600 });
|
|
457
|
+
fs.renameSync(tmp, cursorFile);
|
|
458
|
+
return true;
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function removeCursor(cursorFile, log) {
|
|
465
|
+
if (!cursorSafe(cursorFile)) {
|
|
466
|
+
log(`refusing unsafe cursor file ${cursorFile}`);
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
fs.rmSync(cursorFile, { force: true });
|
|
471
|
+
} catch {
|
|
472
|
+
/* best effort */
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Adopt a higher os.tmpdir() cursor written by an older/cross-process run.
|
|
478
|
+
function migrateTmpCursor(sessionId, cursorFile, log) {
|
|
479
|
+
for (const tmp of [
|
|
480
|
+
path.join(os.tmpdir(), `remnic-cursor-${sessionId}`),
|
|
481
|
+
path.join(os.tmpdir(), `engram-cursor-${sessionId}`),
|
|
482
|
+
]) {
|
|
483
|
+
try {
|
|
484
|
+
if (!fs.existsSync(tmp)) continue;
|
|
485
|
+
const info = fs.lstatSync(tmp);
|
|
486
|
+
if (info.isSymbolicLink() || !info.isFile() || !ownedByUs(info)) continue;
|
|
487
|
+
const raw = fs.readFileSync(tmp, "utf8").trim();
|
|
488
|
+
if (!/^\d+$/.test(raw)) continue;
|
|
489
|
+
const tmpVal = parseInt(raw, 10);
|
|
490
|
+
const current = cursorSafe(cursorFile) ? readCursor(cursorFile, log) : -1;
|
|
491
|
+
if (tmpVal > (current === null ? -1 : current)) writeCursor(cursorFile, tmpVal, log);
|
|
492
|
+
fs.rmSync(tmp, { force: true });
|
|
493
|
+
} catch {
|
|
494
|
+
/* skip */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// mkdir-based mutex with stale-lock reaping (10 min). Returns true if acquired.
|
|
500
|
+
function acquireLock(lockFile, log) {
|
|
501
|
+
for (let i = 0; i < 50; i++) {
|
|
502
|
+
try {
|
|
503
|
+
fs.mkdirSync(lockFile);
|
|
504
|
+
return true;
|
|
505
|
+
} catch {
|
|
506
|
+
if (i === 0) reapStaleLock(lockFile);
|
|
507
|
+
sleepSync(100);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function reapStaleLock(lockFile) {
|
|
514
|
+
try {
|
|
515
|
+
const info = fs.lstatSync(lockFile);
|
|
516
|
+
if (info.isSymbolicLink() || !info.isDirectory() || !ownedByUs(info)) return;
|
|
517
|
+
if (Date.now() - info.mtimeMs < 10 * 60 * 1000) return;
|
|
518
|
+
fs.rmSync(lockFile, { recursive: true, force: true });
|
|
519
|
+
} catch {
|
|
520
|
+
/* best effort */
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function releaseLock(lockFile) {
|
|
525
|
+
try {
|
|
526
|
+
fs.rmdirSync(lockFile);
|
|
527
|
+
} catch {
|
|
528
|
+
/* best effort */
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function sleepSync(ms) {
|
|
533
|
+
// Synchronous sleep without busy-spin (Atomics.wait on a throwaway buffer).
|
|
534
|
+
try {
|
|
535
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
536
|
+
} catch {
|
|
537
|
+
const end = Date.now() + ms;
|
|
538
|
+
while (Date.now() < end) {
|
|
539
|
+
/* fallback spin */
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── event handlers ─────────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
async function handleSessionStart(input, token, log) {
|
|
547
|
+
const sessionId = input.session_id || "";
|
|
548
|
+
const cwd = input.cwd || "";
|
|
549
|
+
const projectName = (cwd && path.basename(cwd)) || "unknown";
|
|
550
|
+
const codingContext = resolveCodingContext(cwd);
|
|
551
|
+
log(`session=${sessionId} project=${projectName} coding-context=${codingContext ? "yes" : ""}`);
|
|
552
|
+
|
|
553
|
+
// Health check — start daemon if not running.
|
|
554
|
+
if (!(await httpHealthy(2000))) {
|
|
555
|
+
log("daemon not responding, attempting start...");
|
|
556
|
+
// Try `remnic` first, fall through to legacy `engram` when only the older
|
|
557
|
+
// CLI is on PATH. spawn() emits ENOENT *asynchronously* via 'error', so we
|
|
558
|
+
// pre-check the binary with onPath() instead of relying on try/break.
|
|
559
|
+
for (const bin of ["remnic", "engram"]) {
|
|
560
|
+
if (!onPath(bin)) continue;
|
|
561
|
+
try {
|
|
562
|
+
// Windows: `remnic`/`engram` are `.cmd` shims, which Node can only
|
|
563
|
+
// launch via a shell. Args are fixed literals — safe.
|
|
564
|
+
const child = spawn(bin, ["daemon", "start"], {
|
|
565
|
+
detached: true,
|
|
566
|
+
stdio: "ignore",
|
|
567
|
+
shell: process.platform === "win32",
|
|
568
|
+
windowsHide: true,
|
|
569
|
+
});
|
|
570
|
+
child.on("error", () => {});
|
|
571
|
+
child.unref();
|
|
572
|
+
break;
|
|
573
|
+
} catch {
|
|
574
|
+
/* try next */
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
578
|
+
if (!(await httpHealthy(2000))) {
|
|
579
|
+
log("daemon still not responding after start attempt");
|
|
580
|
+
emit({
|
|
581
|
+
continue: true,
|
|
582
|
+
hookSpecificOutput: {
|
|
583
|
+
hookEventName: "SessionStart",
|
|
584
|
+
additionalContext: "[Remnic: daemon not running — start with: remnic daemon start]",
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (!token) {
|
|
592
|
+
log("skipping: no token found");
|
|
593
|
+
emit({
|
|
594
|
+
continue: true,
|
|
595
|
+
hookSpecificOutput: {
|
|
596
|
+
hookEventName: "SessionStart",
|
|
597
|
+
additionalContext: "[Remnic: no auth token — run: remnic connectors install claude-code]",
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const query =
|
|
604
|
+
`Starting a new coding session in project: ${projectName}. ` +
|
|
605
|
+
"Recall relevant memories, preferences, decisions, patterns, and context about this project and the user.";
|
|
606
|
+
|
|
607
|
+
// codingContext is explicitly null when absent so stale namespace routing is
|
|
608
|
+
// cleared when a session moves out of a repo.
|
|
609
|
+
let res = await httpPost(
|
|
610
|
+
"/engram/v1/recall",
|
|
611
|
+
token,
|
|
612
|
+
{ query, sessionKey: sessionId, topK: 12, mode: "auto", codingContext },
|
|
613
|
+
45000,
|
|
614
|
+
);
|
|
615
|
+
if (!res.ok || !res.body) {
|
|
616
|
+
log(`full recall failed (http=${res.status}) — falling back to minimal`);
|
|
617
|
+
res = await httpPost(
|
|
618
|
+
"/engram/v1/recall",
|
|
619
|
+
token,
|
|
620
|
+
{ query, sessionKey: sessionId, topK: 8, mode: "minimal", codingContext },
|
|
621
|
+
20000,
|
|
622
|
+
);
|
|
623
|
+
log(res.ok && res.body ? "minimal recall succeeded" : "minimal recall also failed");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let context;
|
|
627
|
+
if (res.ok && res.body) {
|
|
628
|
+
try {
|
|
629
|
+
const d = JSON.parse(res.body);
|
|
630
|
+
const ctx = d.context || "";
|
|
631
|
+
const count = d.count || 0;
|
|
632
|
+
const mode = d.mode || "";
|
|
633
|
+
context = ctx
|
|
634
|
+
? `[Remnic Memory Recall — ${count} memories${mode ? `, ${mode} mode` : ""}]\n\n${ctx}`
|
|
635
|
+
: "[Remnic: no relevant memories found for this session]";
|
|
636
|
+
} catch {
|
|
637
|
+
context = "[Remnic: recall parse error]";
|
|
638
|
+
}
|
|
639
|
+
log(`recall complete: ${context.split("\n")[0]}`);
|
|
640
|
+
} else {
|
|
641
|
+
context = "[Remnic: server unreachable — continuing without memory recall]";
|
|
642
|
+
log(context);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
emit({
|
|
646
|
+
continue: true,
|
|
647
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function handleUserPromptRecall(input, token, log) {
|
|
652
|
+
// No-token → bare continue (no banner noise on every prompt).
|
|
653
|
+
if (!token) {
|
|
654
|
+
emit({ continue: true });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const sessionId = input.session_id || "";
|
|
658
|
+
const prompt = input.prompt || "";
|
|
659
|
+
const wordCount = prompt.trim() ? prompt.trim().split(/\s+/).length : 0;
|
|
660
|
+
if (wordCount < 4) {
|
|
661
|
+
emit({ continue: true });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
log(`session=${sessionId} words=${wordCount}`);
|
|
665
|
+
|
|
666
|
+
const res = await httpPost(
|
|
667
|
+
"/engram/v1/recall",
|
|
668
|
+
token,
|
|
669
|
+
{ query: prompt, sessionKey: sessionId, topK: 8, mode: "minimal" },
|
|
670
|
+
20000,
|
|
671
|
+
);
|
|
672
|
+
if (!res.ok || !res.body) {
|
|
673
|
+
log(`recall failed (http=${res.status})`);
|
|
674
|
+
emit({ continue: true });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
const d = JSON.parse(res.body);
|
|
679
|
+
const ctx = d.context || "";
|
|
680
|
+
const count = d.count || 0;
|
|
681
|
+
if (!ctx || count === 0) {
|
|
682
|
+
emit({ continue: true });
|
|
683
|
+
} else {
|
|
684
|
+
emit({
|
|
685
|
+
continue: true,
|
|
686
|
+
hookSpecificOutput: {
|
|
687
|
+
hookEventName: "UserPromptSubmit",
|
|
688
|
+
additionalContext: `<remnic-memory count="${count}">\n${ctx}\n</remnic-memory>`,
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
log(`done: ${count} memories injected`);
|
|
692
|
+
}
|
|
693
|
+
} catch {
|
|
694
|
+
emit({ continue: true });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Background worker: lock + cursor + observe the transcript delta. Detached
|
|
699
|
+
// from the foreground hook so a slow observe never blocks Claude Code past the
|
|
700
|
+
// PostToolUse timeout.
|
|
701
|
+
async function observeWorker(input, token, log) {
|
|
702
|
+
const sessionId = input.session_id || "";
|
|
703
|
+
const transcriptPath = input.transcript_path || "";
|
|
704
|
+
const projectName = (input.cwd && path.basename(input.cwd)) || "unknown";
|
|
705
|
+
const toolName = input.tool_name || "";
|
|
706
|
+
|
|
707
|
+
if (!sessionId || /[^A-Za-z0-9._-]/.test(sessionId)) {
|
|
708
|
+
log(`invalid session id: ${sessionId}`);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath) || !fs.statSync(transcriptPath).isFile()) {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const state = resolveState(sessionId, log);
|
|
715
|
+
if (!state) return;
|
|
716
|
+
const { cursorFile, lockFile } = state;
|
|
717
|
+
|
|
718
|
+
if (!acquireLock(lockFile, log)) return;
|
|
719
|
+
try {
|
|
720
|
+
migrateTmpCursor(sessionId, cursorFile, log);
|
|
721
|
+
const lastCount = readCursor(cursorFile, log);
|
|
722
|
+
if (lastCount === null) return;
|
|
723
|
+
|
|
724
|
+
let messages;
|
|
725
|
+
try {
|
|
726
|
+
messages = parseTranscript(transcriptPath);
|
|
727
|
+
} catch {
|
|
728
|
+
log(`parse failed for ${sessionId}`);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const newMessages = messages.slice(lastCount);
|
|
732
|
+
if (newMessages.length === 0) {
|
|
733
|
+
writeCursor(cursorFile, messages.length, log);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
log(
|
|
737
|
+
`observing ${newMessages.length} new messages (cursor ${lastCount}->${messages.length}) ` +
|
|
738
|
+
`project=${projectName} tool=${toolName}`,
|
|
739
|
+
);
|
|
740
|
+
const res = await httpPost(
|
|
741
|
+
"/engram/v1/observe",
|
|
742
|
+
token,
|
|
743
|
+
{ sessionKey: sessionId, messages: newMessages },
|
|
744
|
+
120000,
|
|
745
|
+
);
|
|
746
|
+
if (res.ok) {
|
|
747
|
+
log(`observe OK for ${sessionId}`);
|
|
748
|
+
writeCursor(cursorFile, messages.length, log);
|
|
749
|
+
} else {
|
|
750
|
+
log(`observe failed (http=${res.status}) — cursor not advanced`);
|
|
751
|
+
}
|
|
752
|
+
} finally {
|
|
753
|
+
releaseLock(lockFile);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function handlePostToolObserve(rawInput, input, token, log) {
|
|
758
|
+
// Return immediately — never block the tool.
|
|
759
|
+
emit({ continue: true });
|
|
760
|
+
if (!token) return;
|
|
761
|
+
// Spawn a detached copy to do the observe in the background (mirrors the
|
|
762
|
+
// original `( … ) & disown`). Pass the raw hook payload via the worker's
|
|
763
|
+
// STDIN, not the environment — Windows caps the environment block at ~32 KB,
|
|
764
|
+
// so large PostToolUse payloads (big file edits, command output) would fail
|
|
765
|
+
// with E2BIG/ENAMETOOLONG and the observation would silently drop. Stdin has
|
|
766
|
+
// no comparable limit.
|
|
767
|
+
try {
|
|
768
|
+
const child = spawn(process.execPath, [__filename, OBSERVE_WORKER], {
|
|
769
|
+
detached: true,
|
|
770
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
771
|
+
env: { ...process.env, REMNIC_HOOK_TOKEN: token },
|
|
772
|
+
});
|
|
773
|
+
child.on("error", (e) => log(`observe worker spawn error: ${e && e.message}`));
|
|
774
|
+
child.stdin.on("error", () => {
|
|
775
|
+
/* ignore EPIPE if the worker exits before we finish writing */
|
|
776
|
+
});
|
|
777
|
+
child.stdin.end(rawInput);
|
|
778
|
+
child.unref();
|
|
779
|
+
} catch (err) {
|
|
780
|
+
log(`failed to spawn observe worker: ${err && err.message}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function handleSessionEnd(input, token, log) {
|
|
785
|
+
// Acknowledge immediately.
|
|
786
|
+
emit({ continue: true });
|
|
787
|
+
|
|
788
|
+
const sessionId = input.session_id || "";
|
|
789
|
+
const transcriptPath = input.transcript_path || "";
|
|
790
|
+
const safe = sessionId !== "" && !/[^A-Za-z0-9._-]/.test(sessionId);
|
|
791
|
+
|
|
792
|
+
// NOTE: Claude Code does not currently emit a Stop/SessionEnd event. This
|
|
793
|
+
// handler runs only when invoked manually or once Claude Code adds the
|
|
794
|
+
// event. It is kept here so the final-flush + cursor-cleanup parity with
|
|
795
|
+
// the Codex runner is in place from day one (issue #1518).
|
|
796
|
+
let state = null;
|
|
797
|
+
if (safe) state = resolveState(sessionId, log);
|
|
798
|
+
|
|
799
|
+
let removeCursorAfterFlush = true;
|
|
800
|
+
|
|
801
|
+
if (token && state && transcriptPath && fs.existsSync(transcriptPath)) {
|
|
802
|
+
const { cursorFile } = state;
|
|
803
|
+
migrateTmpCursor(sessionId, cursorFile, log);
|
|
804
|
+
const lastCount = readCursor(cursorFile, log);
|
|
805
|
+
if (lastCount === null) {
|
|
806
|
+
log(`final flush skipped for ${sessionId} due to unsafe cursor`);
|
|
807
|
+
} else {
|
|
808
|
+
let newMessages = null;
|
|
809
|
+
try {
|
|
810
|
+
newMessages = parseTranscript(transcriptPath).slice(lastCount);
|
|
811
|
+
} catch {
|
|
812
|
+
log(`final flush parse failed for ${sessionId}; cursor retained for retry`);
|
|
813
|
+
removeCursorAfterFlush = false;
|
|
814
|
+
}
|
|
815
|
+
if (newMessages && newMessages.length > 0) {
|
|
816
|
+
log(`final flush for ${sessionId}`);
|
|
817
|
+
const res = await httpPost(
|
|
818
|
+
"/engram/v1/observe",
|
|
819
|
+
token,
|
|
820
|
+
{ sessionKey: sessionId, messages: newMessages },
|
|
821
|
+
30000,
|
|
822
|
+
);
|
|
823
|
+
if (res.ok) {
|
|
824
|
+
log(`final flush OK for ${sessionId}`);
|
|
825
|
+
} else {
|
|
826
|
+
// Critical: retain the cursor so the tail is retried, never lost.
|
|
827
|
+
log(`final flush failed for ${sessionId} (http=${res.status}); cursor retained for retry`);
|
|
828
|
+
removeCursorAfterFlush = false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Cleanup — only remove the cursor when the flush succeeded or there was
|
|
835
|
+
// nothing pending.
|
|
836
|
+
if (state) {
|
|
837
|
+
const { cursorFile, lockFile, legacyCursor, legacyLock } = state;
|
|
838
|
+
if (removeCursorAfterFlush) removeCursor(cursorFile, log);
|
|
839
|
+
releaseLock(lockFile);
|
|
840
|
+
if (cursorFile !== legacyCursor) {
|
|
841
|
+
try {
|
|
842
|
+
const info = fs.lstatSync(legacyCursor);
|
|
843
|
+
if (!info.isSymbolicLink()) fs.rmSync(legacyCursor, { force: true });
|
|
844
|
+
} catch {
|
|
845
|
+
/* absent */
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (lockFile !== legacyLock) {
|
|
849
|
+
try {
|
|
850
|
+
fs.rmdirSync(legacyLock);
|
|
851
|
+
} catch {
|
|
852
|
+
/* absent */
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── entrypoint ─────────────────────────────────────────────────────────────
|
|
859
|
+
async function main() {
|
|
860
|
+
const event = process.argv[2] || "";
|
|
861
|
+
|
|
862
|
+
// Detached background worker for post-tool-observe.
|
|
863
|
+
if (event === OBSERVE_WORKER) {
|
|
864
|
+
const log = makeLogger("post-tool-observe");
|
|
865
|
+
try {
|
|
866
|
+
const raw = readStdin();
|
|
867
|
+
const input = parseInput(raw);
|
|
868
|
+
const token = process.env.REMNIC_HOOK_TOKEN || resolveToken();
|
|
869
|
+
if (token) await observeWorker(input, token, log);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
log(`observe worker error: ${err && err.message}`);
|
|
872
|
+
}
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (!Object.prototype.hasOwnProperty.call(LOG_FILES, event)) {
|
|
877
|
+
// Unknown event — fail open without side effects.
|
|
878
|
+
emit({ continue: true });
|
|
879
|
+
if (event) process.stderr.write(`remnic-cc-hook: unknown event "${event}"\n`);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const log = makeLogger(event);
|
|
884
|
+
const raw = readStdin();
|
|
885
|
+
const input = parseInput(raw);
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
ensureMigrated();
|
|
889
|
+
const token = resolveToken();
|
|
890
|
+
switch (event) {
|
|
891
|
+
case "session-start":
|
|
892
|
+
await handleSessionStart(input, token, log);
|
|
893
|
+
break;
|
|
894
|
+
case "user-prompt-recall":
|
|
895
|
+
await handleUserPromptRecall(input, token, log);
|
|
896
|
+
break;
|
|
897
|
+
case "post-tool-observe":
|
|
898
|
+
handlePostToolObserve(raw, input, token, log);
|
|
899
|
+
break;
|
|
900
|
+
case "session-end":
|
|
901
|
+
await handleSessionEnd(input, token, log);
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
} catch (err) {
|
|
905
|
+
log(`unhandled error in ${event}: ${err && err.message}`);
|
|
906
|
+
// Best-effort fail-open: emit a bare continue only if we haven't already.
|
|
907
|
+
if (!emitted) {
|
|
908
|
+
try {
|
|
909
|
+
emit({ continue: true });
|
|
910
|
+
} catch {
|
|
911
|
+
/* stdout already written */
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
main();
|