codemem 0.43.1 → 0.43.2
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/dist/index.js +401 -595
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -102,7 +102,7 @@ var BOOLEAN_TOGGLE_VALUES = new Set([
|
|
|
102
102
|
"on",
|
|
103
103
|
"no"
|
|
104
104
|
]);
|
|
105
|
-
function expandHome$
|
|
105
|
+
function expandHome$3(value) {
|
|
106
106
|
const home = process.env.HOME?.trim() || homedir();
|
|
107
107
|
if (value === "~") return home;
|
|
108
108
|
if (value.startsWith("~/")) return join(home, value.slice(2));
|
|
@@ -111,8 +111,8 @@ function expandHome$4(value) {
|
|
|
111
111
|
function pluginLogPath() {
|
|
112
112
|
const raw = process.env.CODEMEM_PLUGIN_LOG_PATH ?? process.env.CODEMEM_PLUGIN_LOG ?? "";
|
|
113
113
|
const normalized = raw.trim().toLowerCase();
|
|
114
|
-
if (BOOLEAN_TOGGLE_VALUES.has(normalized)) return expandHome$
|
|
115
|
-
return expandHome$
|
|
114
|
+
if (BOOLEAN_TOGGLE_VALUES.has(normalized)) return expandHome$3("~/.codemem/plugin.log");
|
|
115
|
+
return expandHome$3(raw.trim());
|
|
116
116
|
}
|
|
117
117
|
/**
|
|
118
118
|
* Append a single timestamped line to the plugin log. Best-effort: any
|
|
@@ -164,7 +164,7 @@ function envTruthy$3(value) {
|
|
|
164
164
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
165
165
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
166
166
|
}
|
|
167
|
-
function expandHome$
|
|
167
|
+
function expandHome$2(value) {
|
|
168
168
|
if (value === "~") return homedir();
|
|
169
169
|
if (value.startsWith("~/")) return resolve(homedir(), value.slice(2));
|
|
170
170
|
return value;
|
|
@@ -360,7 +360,7 @@ async function buildClaudeFileContext(payload, opts, deps = {}) {
|
|
|
360
360
|
return finish(continueResult$2());
|
|
361
361
|
}
|
|
362
362
|
const cwd = typeof payload.cwd === "string" && payload.cwd.trim() ? payload.cwd : process.cwd();
|
|
363
|
-
const expandedPath = expandHome$
|
|
363
|
+
const expandedPath = expandHome$2(filePath);
|
|
364
364
|
const absolutePath = isAbsolute(expandedPath) ? expandedPath : resolve(cwd, expandedPath);
|
|
365
365
|
const relativePath = relative(cwd, absolutePath).split(sep).join("/");
|
|
366
366
|
const escapesCwd = relativePath === ".." || relativePath.startsWith("../") || isAbsolute(relativePath);
|
|
@@ -519,34 +519,22 @@ var claudeHookFileContextCommand = claudeHookFileContextCmd.action(async (opts)
|
|
|
519
519
|
emitJson$2(await buildClaudeFileContext(payload, opts));
|
|
520
520
|
});
|
|
521
521
|
//#endregion
|
|
522
|
-
//#region src/commands/
|
|
522
|
+
//#region src/commands/hook-ingest-spool.ts
|
|
523
523
|
/**
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* payloads when both HTTP and direct ingestion
|
|
527
|
-
* recovery routine that promotes stale temp files back into the
|
|
524
|
+
* Shared durability layer for the hook ingest commands (claude, codex):
|
|
525
|
+
* a file-based mutex that serializes concurrent invocations, an on-disk
|
|
526
|
+
* spool that captures payloads when both HTTP and direct ingestion fail,
|
|
527
|
+
* and a recovery routine that promotes stale temp files back into the
|
|
528
|
+
* queue. Each client instantiates its own copy via `createHookIngestSpool`
|
|
529
|
+
* with its own lock/spool dirs, TTL, retry budget, log prefix, and
|
|
530
|
+
* client-specific `LockBusyError` name/message. Per-client flush
|
|
531
|
+
* predicates stay in the client files.
|
|
532
|
+
*/
|
|
533
|
+
var LOCK_ACQUIRE_BACKOFF_MS = 50;
|
|
534
|
+
/**
|
|
535
|
+
* Boolean-shaped env toggle used by per-client flush predicates
|
|
536
|
+
* (claude's `shouldForceBoundaryFlush`).
|
|
528
537
|
*/
|
|
529
|
-
var DEFAULT_LOCK_TTL_S$1 = 300;
|
|
530
|
-
var DEFAULT_LOCK_GRACE_S$1 = 2;
|
|
531
|
-
var LOCK_ACQUIRE_ATTEMPTS$1 = 100;
|
|
532
|
-
var LOCK_ACQUIRE_BACKOFF_MS$1 = 50;
|
|
533
|
-
var LockBusyError = class extends Error {
|
|
534
|
-
constructor() {
|
|
535
|
-
super("claude-hook-ingest lock busy");
|
|
536
|
-
this.name = "LockBusyError";
|
|
537
|
-
}
|
|
538
|
-
};
|
|
539
|
-
function expandHome$2(value) {
|
|
540
|
-
if (value === "~") return homedir();
|
|
541
|
-
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
542
|
-
return value;
|
|
543
|
-
}
|
|
544
|
-
function envInt$1(name, fallback) {
|
|
545
|
-
const raw = process.env[name];
|
|
546
|
-
if (raw === void 0) return fallback;
|
|
547
|
-
const parsed = Number.parseInt(raw, 10);
|
|
548
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
549
|
-
}
|
|
550
538
|
function envTruthy$2(name, fallback) {
|
|
551
539
|
const raw = process.env[name];
|
|
552
540
|
if (raw === void 0) return fallback;
|
|
@@ -565,336 +553,401 @@ function envTruthy$2(name, fallback) {
|
|
|
565
553
|
].includes(normalized)) return false;
|
|
566
554
|
return fallback;
|
|
567
555
|
}
|
|
568
|
-
function
|
|
569
|
-
return
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
graceSeconds: Math.max(1, envInt$1("CODEMEM_CLAUDE_HOOK_LOCK_GRACE_S", DEFAULT_LOCK_GRACE_S$1))
|
|
573
|
-
};
|
|
556
|
+
function expandHome$1(value) {
|
|
557
|
+
if (value === "~") return homedir();
|
|
558
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
559
|
+
return value;
|
|
574
560
|
}
|
|
575
|
-
function
|
|
576
|
-
|
|
561
|
+
function envInt(name, fallback) {
|
|
562
|
+
const raw = process.env[name];
|
|
563
|
+
if (raw === void 0) return fallback;
|
|
564
|
+
const parsed = Number.parseInt(raw, 10);
|
|
565
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
577
566
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
567
|
+
function createHookIngestSpool(cfg) {
|
|
568
|
+
class LockBusyError extends Error {
|
|
569
|
+
constructor() {
|
|
570
|
+
super(cfg.lockBusyErrorMessage);
|
|
571
|
+
this.name = cfg.lockBusyErrorName;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function lockConfig() {
|
|
575
|
+
return {
|
|
576
|
+
lockDir: expandHome$1(process.env[cfg.lockDirEnv]?.trim() || cfg.lockDirDefault),
|
|
577
|
+
ttlSeconds: Math.max(1, envInt(cfg.lockTtlEnv, cfg.lockTtlDefault)),
|
|
578
|
+
graceSeconds: Math.max(1, envInt(cfg.lockGraceEnv, cfg.lockGraceDefault))
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function spoolDir() {
|
|
582
|
+
return expandHome$1(process.env[cfg.spoolDirEnv]?.trim() || cfg.spoolDirDefault);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Returns the configured lock TTL so callers (hook-ingest commands)
|
|
586
|
+
* can pass the same value to `recoverStaleTmpSpool` without re-reading
|
|
587
|
+
* the env.
|
|
588
|
+
*/
|
|
589
|
+
function lockTtlSeconds() {
|
|
590
|
+
return lockConfig().ttlSeconds;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Cheap pre-check used by the unlocked HTTP-success path to decide
|
|
594
|
+
* whether it needs to acquire the ingest lock and drain queued
|
|
595
|
+
* payloads. Returns true when the spool directory contains at least
|
|
596
|
+
* one active entry (a `*.json` file that is neither an in-flight
|
|
597
|
+
* `.hook-tmp-*` nor a quarantined `.bad-*` file). Any I/O failure
|
|
598
|
+
* is treated as "no entries" so callers stay on the fast path.
|
|
599
|
+
*/
|
|
600
|
+
function hasSpooledEntries() {
|
|
601
|
+
let entries;
|
|
602
|
+
try {
|
|
603
|
+
entries = readdirSync(spoolDir());
|
|
604
|
+
} catch {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
for (const name of entries) {
|
|
608
|
+
if (!name.endsWith(".json")) continue;
|
|
609
|
+
if (name.startsWith(".hook-tmp-") || name.startsWith(".bad-")) continue;
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
592
612
|
return false;
|
|
593
613
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
614
|
+
function readFileTrimmedOrEmpty(path) {
|
|
615
|
+
try {
|
|
616
|
+
return readFileSync(path, "utf8").trim();
|
|
617
|
+
} catch {
|
|
618
|
+
return "";
|
|
619
|
+
}
|
|
598
620
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
621
|
+
function readLockMetadata(lockDir) {
|
|
622
|
+
const pid = readFileTrimmedOrEmpty(join(lockDir, "pid"));
|
|
623
|
+
const owner = readFileTrimmedOrEmpty(join(lockDir, "owner"));
|
|
624
|
+
const tsRaw = readFileTrimmedOrEmpty(join(lockDir, "ts"));
|
|
625
|
+
const ts = tsRaw === "" ? null : Number.parseInt(tsRaw, 10);
|
|
626
|
+
return {
|
|
627
|
+
pid,
|
|
628
|
+
ts: ts === null || !Number.isFinite(ts) ? null : ts,
|
|
629
|
+
owner
|
|
630
|
+
};
|
|
606
631
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
owner
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
function isPidAlive$1(pidText) {
|
|
620
|
-
const pid = Number.parseInt(pidText, 10);
|
|
621
|
-
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
622
|
-
try {
|
|
623
|
-
process.kill(pid, 0);
|
|
624
|
-
return true;
|
|
625
|
-
} catch {
|
|
626
|
-
return false;
|
|
632
|
+
function isPidAlive(pidText) {
|
|
633
|
+
const pid = Number.parseInt(pidText, 10);
|
|
634
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
635
|
+
try {
|
|
636
|
+
process.kill(pid, 0);
|
|
637
|
+
return true;
|
|
638
|
+
} catch {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
627
641
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
642
|
+
function lockIsStale(cfg) {
|
|
643
|
+
const snapshot = readLockMetadata(cfg.lockDir);
|
|
644
|
+
const nowS = Math.floor(Date.now() / 1e3);
|
|
645
|
+
if (snapshot.pid) {
|
|
646
|
+
if (isPidAlive(snapshot.pid)) {
|
|
647
|
+
if (snapshot.ts === null) return {
|
|
648
|
+
stale: false,
|
|
649
|
+
snapshot
|
|
650
|
+
};
|
|
651
|
+
return {
|
|
652
|
+
stale: nowS - snapshot.ts > cfg.ttlSeconds,
|
|
653
|
+
snapshot
|
|
654
|
+
};
|
|
655
|
+
}
|
|
638
656
|
return {
|
|
639
|
-
stale:
|
|
657
|
+
stale: true,
|
|
640
658
|
snapshot
|
|
641
659
|
};
|
|
642
660
|
}
|
|
643
|
-
return {
|
|
644
|
-
stale:
|
|
661
|
+
if (snapshot.ts !== null) return {
|
|
662
|
+
stale: nowS - snapshot.ts > cfg.graceSeconds,
|
|
645
663
|
snapshot
|
|
646
664
|
};
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
665
|
+
let mtimeS;
|
|
666
|
+
try {
|
|
667
|
+
mtimeS = Math.floor(statSync(cfg.lockDir).mtimeMs / 1e3);
|
|
668
|
+
} catch {
|
|
669
|
+
return {
|
|
670
|
+
stale: true,
|
|
671
|
+
snapshot
|
|
672
|
+
};
|
|
673
|
+
}
|
|
656
674
|
return {
|
|
657
|
-
stale:
|
|
675
|
+
stale: nowS - mtimeS > cfg.graceSeconds,
|
|
658
676
|
snapshot
|
|
659
677
|
};
|
|
660
678
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
"ts",
|
|
670
|
-
"owner"
|
|
671
|
-
]) try {
|
|
672
|
-
unlinkSync(join(lockDir, name));
|
|
673
|
-
} catch {}
|
|
674
|
-
try {
|
|
675
|
-
rmdirSync(lockDir);
|
|
676
|
-
} catch {}
|
|
677
|
-
}
|
|
678
|
-
function snapshotsEqual(a, b) {
|
|
679
|
-
return a.pid === b.pid && a.ts === b.ts && a.owner === b.owner;
|
|
680
|
-
}
|
|
681
|
-
function cleanupLockDirIfUnchanged$1(lockDir, snapshot) {
|
|
682
|
-
if (snapshotsEqual(readLockMetadata$1(lockDir), snapshot)) cleanupLockDir$1(lockDir);
|
|
683
|
-
}
|
|
684
|
-
function isErrnoException$1(err) {
|
|
685
|
-
return typeof err === "object" && err !== null && "code" in err;
|
|
686
|
-
}
|
|
687
|
-
async function sleep$1(ms) {
|
|
688
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
689
|
-
}
|
|
690
|
-
/**
|
|
691
|
-
* Run `fn` while holding the claude-hook-ingest lock. Throws
|
|
692
|
-
* `LockBusyError` when the lock cannot be acquired within
|
|
693
|
-
* `LOCK_ACQUIRE_ATTEMPTS` attempts.
|
|
694
|
-
*
|
|
695
|
-
* The lock is a directory at `lockDir`, with three sentinel files
|
|
696
|
-
* (`pid`, `ts`, `owner`) recording who currently holds it. Stale locks
|
|
697
|
-
* are detected via PID liveness, TTL, and a grace window for the
|
|
698
|
-
* race between mkdir and writing pid/ts.
|
|
699
|
-
*/
|
|
700
|
-
async function withClaudeHookIngestLock(fn) {
|
|
701
|
-
const cfg = lockConfig$1();
|
|
702
|
-
mkdirSync(dirname(cfg.lockDir), { recursive: true });
|
|
703
|
-
const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
|
|
704
|
-
let acquired = false;
|
|
705
|
-
for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS$1; attempt++) {
|
|
679
|
+
function cleanupLockDir(lockDir) {
|
|
680
|
+
for (const name of [
|
|
681
|
+
"pid",
|
|
682
|
+
"ts",
|
|
683
|
+
"owner"
|
|
684
|
+
]) try {
|
|
685
|
+
unlinkSync(join(lockDir, name));
|
|
686
|
+
} catch {}
|
|
706
687
|
try {
|
|
707
|
-
|
|
708
|
-
} catch
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
688
|
+
rmdirSync(lockDir);
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
691
|
+
function snapshotsEqual(a, b) {
|
|
692
|
+
return a.pid === b.pid && a.ts === b.ts && a.owner === b.owner;
|
|
693
|
+
}
|
|
694
|
+
function cleanupLockDirIfUnchanged(lockDir, snapshot) {
|
|
695
|
+
if (snapshotsEqual(readLockMetadata(lockDir), snapshot)) cleanupLockDir(lockDir);
|
|
696
|
+
}
|
|
697
|
+
function isErrnoException(err) {
|
|
698
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
699
|
+
}
|
|
700
|
+
function sleep(ms) {
|
|
701
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Run `fn` while holding the client's ingest lock. Throws the
|
|
705
|
+
* client-specific `LockBusyError` when the lock cannot be acquired
|
|
706
|
+
* within `cfg.lockAcquireAttempts` attempts.
|
|
707
|
+
*
|
|
708
|
+
* The lock is a directory at `lockDir`, with three sentinel files
|
|
709
|
+
* (`pid`, `ts`, `owner`) recording who currently holds it. Stale locks
|
|
710
|
+
* are detected via PID liveness, TTL, and a grace window for the
|
|
711
|
+
* race between mkdir and writing pid/ts.
|
|
712
|
+
*/
|
|
713
|
+
async function withLock(fn) {
|
|
714
|
+
const lock = lockConfig();
|
|
715
|
+
mkdirSync(dirname(lock.lockDir), { recursive: true });
|
|
716
|
+
const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
|
|
717
|
+
let acquired = false;
|
|
718
|
+
for (let attempt = 0; attempt < cfg.lockAcquireAttempts; attempt++) {
|
|
719
|
+
try {
|
|
720
|
+
mkdirSync(lock.lockDir);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
if (isErrnoException(err) && err.code === "EEXIST") {
|
|
723
|
+
const { stale, snapshot } = lockIsStale(lock);
|
|
724
|
+
if (stale) cleanupLockDirIfUnchanged(lock.lockDir, snapshot);
|
|
725
|
+
}
|
|
726
|
+
await sleep(LOCK_ACQUIRE_BACKOFF_MS);
|
|
713
727
|
continue;
|
|
714
728
|
}
|
|
715
|
-
|
|
716
|
-
|
|
729
|
+
try {
|
|
730
|
+
writeFileSync(join(lock.lockDir, "ts"), String(Math.floor(Date.now() / 1e3)), { encoding: "utf8" });
|
|
731
|
+
writeFileSync(join(lock.lockDir, "pid"), String(process.pid), { encoding: "utf8" });
|
|
732
|
+
writeFileSync(join(lock.lockDir, "owner"), ownerToken, { encoding: "utf8" });
|
|
733
|
+
acquired = true;
|
|
734
|
+
break;
|
|
735
|
+
} catch {
|
|
736
|
+
cleanupLockDir(lock.lockDir);
|
|
737
|
+
await sleep(LOCK_ACQUIRE_BACKOFF_MS);
|
|
738
|
+
}
|
|
717
739
|
}
|
|
740
|
+
if (!acquired) throw new LockBusyError();
|
|
718
741
|
try {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
acquired = true;
|
|
723
|
-
break;
|
|
724
|
-
} catch {
|
|
725
|
-
cleanupLockDir$1(cfg.lockDir);
|
|
726
|
-
await sleep$1(LOCK_ACQUIRE_BACKOFF_MS$1);
|
|
742
|
+
return await fn();
|
|
743
|
+
} finally {
|
|
744
|
+
if (readFileTrimmedOrEmpty(join(lock.lockDir, "owner")) === ownerToken) cleanupLockDir(lock.lockDir);
|
|
727
745
|
}
|
|
728
746
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Persist a payload to the spool directory using a tmp+rename so that
|
|
738
|
-
* a partially-written file is never visible to the drainer. Returns
|
|
739
|
-
* true on success, false on any I/O failure.
|
|
740
|
-
*/
|
|
741
|
-
function spoolPayload(payload) {
|
|
742
|
-
const dir = spoolDir();
|
|
743
|
-
try {
|
|
744
|
-
mkdirSync(dir, { recursive: true });
|
|
745
|
-
} catch {
|
|
746
|
-
logHookEvent("codemem claude-hook-ingest failed to create spool dir");
|
|
747
|
-
return false;
|
|
748
|
-
}
|
|
749
|
-
const payloadText = JSON.stringify(payload);
|
|
750
|
-
const tmpPath = join(dir, `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`);
|
|
751
|
-
try {
|
|
752
|
-
writeFileSync(tmpPath, payloadText, { encoding: "utf8" });
|
|
753
|
-
} catch {
|
|
754
|
-
logHookEvent("codemem claude-hook-ingest failed to allocate spool temp file");
|
|
755
|
-
return false;
|
|
756
|
-
}
|
|
757
|
-
const finalPath = join(dir, `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
|
|
758
|
-
try {
|
|
759
|
-
renameSync(tmpPath, finalPath);
|
|
760
|
-
} catch {
|
|
747
|
+
/**
|
|
748
|
+
* Persist a payload to the spool directory using a tmp+rename so that
|
|
749
|
+
* a partially-written file is never visible to the drainer. Returns
|
|
750
|
+
* true on success, false on any I/O failure.
|
|
751
|
+
*/
|
|
752
|
+
function spoolPayload(payload) {
|
|
753
|
+
const dir = spoolDir();
|
|
761
754
|
try {
|
|
762
|
-
|
|
763
|
-
} catch {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
/**
|
|
771
|
-
* Promote any `.hook-tmp-*.json` files older than `ttlSeconds` to a
|
|
772
|
-
* recovered name so they are picked up by the next drain. Caller is
|
|
773
|
-
* responsible for passing the same TTL used by lock acquisition so
|
|
774
|
-
* that an in-flight write inside an active locked region is never
|
|
775
|
-
* mistaken for a crashed-writer leftover.
|
|
776
|
-
*/
|
|
777
|
-
function recoverStaleTmpSpool(ttlSeconds) {
|
|
778
|
-
const dir = spoolDir();
|
|
779
|
-
try {
|
|
780
|
-
mkdirSync(dir, { recursive: true });
|
|
781
|
-
} catch {
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
let entries;
|
|
785
|
-
try {
|
|
786
|
-
entries = readdirSync(dir);
|
|
787
|
-
} catch {
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
const nowS = Date.now() / 1e3;
|
|
791
|
-
for (const name of entries) {
|
|
792
|
-
if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
|
|
793
|
-
const tmpPath = join(dir, name);
|
|
794
|
-
let mtimeS;
|
|
755
|
+
mkdirSync(dir, { recursive: true });
|
|
756
|
+
} catch {
|
|
757
|
+
logHookEvent(`${cfg.logPrefix} failed to create spool dir`);
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
const payloadText = JSON.stringify(payload);
|
|
761
|
+
const tmpPath = join(dir, `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`);
|
|
795
762
|
try {
|
|
796
|
-
|
|
763
|
+
writeFileSync(tmpPath, payloadText, { encoding: "utf8" });
|
|
797
764
|
} catch {
|
|
798
|
-
|
|
765
|
+
logHookEvent(`${cfg.logPrefix} failed to allocate spool temp file`);
|
|
766
|
+
return false;
|
|
799
767
|
}
|
|
800
|
-
|
|
801
|
-
const recoveredPath = join(dir, `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
|
|
768
|
+
const finalPath = join(dir, `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
|
|
802
769
|
try {
|
|
803
|
-
renameSync(tmpPath,
|
|
804
|
-
|
|
805
|
-
|
|
770
|
+
renameSync(tmpPath, finalPath);
|
|
771
|
+
} catch {
|
|
772
|
+
try {
|
|
773
|
+
unlinkSync(tmpPath);
|
|
774
|
+
} catch {}
|
|
775
|
+
logHookEvent(`${cfg.logPrefix} failed to spool payload`);
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
logHookEvent(`${cfg.logPrefix} spooled payload: ${finalPath}`);
|
|
779
|
+
return true;
|
|
806
780
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*/
|
|
814
|
-
function
|
|
815
|
-
|
|
816
|
-
const quarantineName = `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`;
|
|
817
|
-
try {
|
|
818
|
-
renameSync(sourcePath, join(dir, quarantineName));
|
|
819
|
-
logHookEvent(`codemem claude-hook-ingest quarantined corrupt spool payload (${reason}): ${quarantineName}`);
|
|
820
|
-
} catch {
|
|
781
|
+
/**
|
|
782
|
+
* Promote any `.hook-tmp-*.json` files older than `ttlSeconds` to a
|
|
783
|
+
* recovered name so they are picked up by the next drain. Caller is
|
|
784
|
+
* responsible for passing the same TTL used by lock acquisition so
|
|
785
|
+
* that an in-flight write inside an active locked region is never
|
|
786
|
+
* mistaken for a crashed-writer leftover.
|
|
787
|
+
*/
|
|
788
|
+
function recoverStaleTmpSpool(ttlSeconds) {
|
|
789
|
+
const dir = spoolDir();
|
|
821
790
|
try {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* Process every queued payload in the spool directory in lexicographic
|
|
829
|
-
* order (which approximates oldest-first because filenames embed the
|
|
830
|
-
* second-precision creation timestamp). The handler returns true to
|
|
831
|
-
* indicate the payload has been durably accepted; only then is the
|
|
832
|
-
* spool entry deleted. Failed entries are left on disk for the next
|
|
833
|
-
* drain attempt.
|
|
834
|
-
*/
|
|
835
|
-
async function drainSpool(handler) {
|
|
836
|
-
const dir = spoolDir();
|
|
837
|
-
try {
|
|
838
|
-
mkdirSync(dir, { recursive: true });
|
|
839
|
-
} catch {
|
|
840
|
-
return {
|
|
841
|
-
processed: 0,
|
|
842
|
-
failed: 0
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
let entries;
|
|
846
|
-
try {
|
|
847
|
-
entries = readdirSync(dir);
|
|
848
|
-
} catch {
|
|
849
|
-
return {
|
|
850
|
-
processed: 0,
|
|
851
|
-
failed: 0
|
|
852
|
-
};
|
|
853
|
-
}
|
|
854
|
-
const queued = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-")).sort();
|
|
855
|
-
const result = {
|
|
856
|
-
processed: 0,
|
|
857
|
-
failed: 0
|
|
858
|
-
};
|
|
859
|
-
for (const name of queued) {
|
|
860
|
-
const path = join(dir, name);
|
|
861
|
-
let raw;
|
|
791
|
+
mkdirSync(dir, { recursive: true });
|
|
792
|
+
} catch {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
let entries;
|
|
862
796
|
try {
|
|
863
|
-
|
|
797
|
+
entries = readdirSync(dir);
|
|
864
798
|
} catch {
|
|
865
|
-
|
|
866
|
-
result.failed++;
|
|
867
|
-
continue;
|
|
799
|
+
return;
|
|
868
800
|
}
|
|
869
|
-
|
|
801
|
+
const nowS = Date.now() / 1e3;
|
|
802
|
+
for (const name of entries) {
|
|
803
|
+
if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
|
|
804
|
+
const tmpPath = join(dir, name);
|
|
805
|
+
let mtimeS;
|
|
806
|
+
try {
|
|
807
|
+
mtimeS = statSync(tmpPath).mtimeMs / 1e3;
|
|
808
|
+
} catch {
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (nowS - mtimeS <= ttlSeconds) continue;
|
|
812
|
+
const recoveredPath = join(dir, `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
|
|
813
|
+
try {
|
|
814
|
+
renameSync(tmpPath, recoveredPath);
|
|
815
|
+
logHookEvent(`${cfg.logPrefix} recovered stale temp spool payload: ${recoveredPath}`);
|
|
816
|
+
} catch {}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Move a permanently-broken spool entry out of the queue so that it
|
|
821
|
+
* stops being picked up by future drains. The entry is renamed in
|
|
822
|
+
* place with a `.bad-<reason>-` prefix so an operator can inspect or
|
|
823
|
+
* delete it manually.
|
|
824
|
+
*/
|
|
825
|
+
function quarantineSpoolEntry(dir, name, reason) {
|
|
826
|
+
const sourcePath = join(dir, name);
|
|
827
|
+
const quarantineName = `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`;
|
|
870
828
|
try {
|
|
871
|
-
|
|
829
|
+
renameSync(sourcePath, join(dir, quarantineName));
|
|
830
|
+
logHookEvent(`${cfg.logPrefix} quarantined corrupt spool payload (${reason}): ${quarantineName}`);
|
|
872
831
|
} catch {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
832
|
+
try {
|
|
833
|
+
unlinkSync(sourcePath);
|
|
834
|
+
logHookEvent(`${cfg.logPrefix} dropped corrupt spool payload (${reason}): ${name}`);
|
|
835
|
+
} catch {}
|
|
876
836
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Process every queued payload in the spool directory in lexicographic
|
|
840
|
+
* order (which approximates oldest-first because filenames embed the
|
|
841
|
+
* second-precision creation timestamp). The handler returns true to
|
|
842
|
+
* indicate the payload has been durably accepted; only then is the
|
|
843
|
+
* spool entry deleted. Failed entries are left on disk for the next
|
|
844
|
+
* drain attempt.
|
|
845
|
+
*/
|
|
846
|
+
async function drainSpool(handler) {
|
|
847
|
+
const dir = spoolDir();
|
|
848
|
+
try {
|
|
849
|
+
mkdirSync(dir, { recursive: true });
|
|
850
|
+
} catch {
|
|
851
|
+
return {
|
|
852
|
+
processed: 0,
|
|
853
|
+
failed: 0
|
|
854
|
+
};
|
|
880
855
|
}
|
|
881
|
-
let
|
|
856
|
+
let entries;
|
|
882
857
|
try {
|
|
883
|
-
|
|
858
|
+
entries = readdirSync(dir);
|
|
884
859
|
} catch {
|
|
885
|
-
|
|
860
|
+
return {
|
|
861
|
+
processed: 0,
|
|
862
|
+
failed: 0
|
|
863
|
+
};
|
|
886
864
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
865
|
+
const queued = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-")).sort();
|
|
866
|
+
const result = {
|
|
867
|
+
processed: 0,
|
|
868
|
+
failed: 0
|
|
869
|
+
};
|
|
870
|
+
for (const name of queued) {
|
|
871
|
+
const path = join(dir, name);
|
|
872
|
+
let raw;
|
|
873
|
+
try {
|
|
874
|
+
raw = readFileSync(path, "utf8");
|
|
875
|
+
} catch {
|
|
876
|
+
logHookEvent(`${cfg.logPrefix} failed to read spooled payload: ${path}`);
|
|
877
|
+
result.failed++;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
let parsed;
|
|
881
|
+
try {
|
|
882
|
+
parsed = JSON.parse(raw);
|
|
883
|
+
} catch {
|
|
884
|
+
quarantineSpoolEntry(dir, name, "parse-error");
|
|
885
|
+
result.failed++;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
889
|
+
quarantineSpoolEntry(dir, name, "wrong-shape");
|
|
890
|
+
result.failed++;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
let ok = false;
|
|
894
|
+
try {
|
|
895
|
+
ok = await handler(parsed);
|
|
896
|
+
} catch {
|
|
897
|
+
ok = false;
|
|
898
|
+
}
|
|
899
|
+
if (ok) try {
|
|
900
|
+
unlinkSync(path);
|
|
901
|
+
result.processed++;
|
|
902
|
+
} catch {}
|
|
903
|
+
else {
|
|
904
|
+
logHookEvent(`${cfg.logPrefix} failed processing spooled payload: ${path}`);
|
|
905
|
+
result.failed++;
|
|
906
|
+
}
|
|
894
907
|
}
|
|
908
|
+
return result;
|
|
895
909
|
}
|
|
896
|
-
return
|
|
910
|
+
return {
|
|
911
|
+
LockBusyError,
|
|
912
|
+
spoolDir,
|
|
913
|
+
lockTtlSeconds,
|
|
914
|
+
hasSpooledEntries,
|
|
915
|
+
withLock,
|
|
916
|
+
spoolPayload,
|
|
917
|
+
recoverStaleTmpSpool,
|
|
918
|
+
drainSpool
|
|
919
|
+
};
|
|
897
920
|
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/commands/claude-hook-ingest-spool.ts
|
|
923
|
+
/**
|
|
924
|
+
* Durability layer for `claude-hook-ingest`. The lock/spool/drain/
|
|
925
|
+
* quarantine machinery is shared in `hook-ingest-spool.ts`; this file
|
|
926
|
+
* wires the claude-specific config (dirs, TTL 300s, 100 acquire
|
|
927
|
+
* attempts, error name/message) and keeps the claude flush predicate.
|
|
928
|
+
*/
|
|
929
|
+
var spool$1 = createHookIngestSpool({
|
|
930
|
+
logPrefix: "codemem claude-hook-ingest",
|
|
931
|
+
lockDirEnv: "CODEMEM_CLAUDE_HOOK_LOCK_DIR",
|
|
932
|
+
lockDirDefault: "~/.codemem/claude-hook-ingest.lock",
|
|
933
|
+
lockTtlEnv: "CODEMEM_CLAUDE_HOOK_LOCK_TTL_S",
|
|
934
|
+
lockTtlDefault: 300,
|
|
935
|
+
lockGraceEnv: "CODEMEM_CLAUDE_HOOK_LOCK_GRACE_S",
|
|
936
|
+
lockGraceDefault: 2,
|
|
937
|
+
lockAcquireAttempts: 100,
|
|
938
|
+
spoolDirEnv: "CODEMEM_CLAUDE_HOOK_SPOOL_DIR",
|
|
939
|
+
spoolDirDefault: "~/.codemem/claude-hook-spool",
|
|
940
|
+
lockBusyErrorName: "LockBusyError",
|
|
941
|
+
lockBusyErrorMessage: "claude-hook-ingest lock busy"
|
|
942
|
+
});
|
|
943
|
+
var LockBusyError = spool$1.LockBusyError;
|
|
944
|
+
var withClaudeHookIngestLock = spool$1.withLock;
|
|
945
|
+
var spoolPayload = spool$1.spoolPayload;
|
|
946
|
+
var drainSpool = spool$1.drainSpool;
|
|
947
|
+
var hasSpooledEntries = spool$1.hasSpooledEntries;
|
|
948
|
+
var recoverStaleTmpSpool = spool$1.recoverStaleTmpSpool;
|
|
949
|
+
var lockTtlSeconds = spool$1.lockTtlSeconds;
|
|
950
|
+
spool$1.spoolDir;
|
|
898
951
|
/**
|
|
899
952
|
* Whether the boundary-flush write-through should run for this hook
|
|
900
953
|
* payload. SessionEnd defaults to forcing a flush; Stop only flushes
|
|
@@ -908,14 +961,6 @@ function shouldForceBoundaryFlush(payload) {
|
|
|
908
961
|
if (!envTruthy$2("CODEMEM_CLAUDE_HOOK_FLUSH", false)) return false;
|
|
909
962
|
return envTruthy$2("CODEMEM_CLAUDE_HOOK_FLUSH_ON_STOP", false);
|
|
910
963
|
}
|
|
911
|
-
/**
|
|
912
|
-
* Returns the configured lock TTL so callers (`claude-hook-ingest`)
|
|
913
|
-
* can pass the same value to `recoverStaleTmpSpool` without re-reading
|
|
914
|
-
* the env.
|
|
915
|
-
*/
|
|
916
|
-
function lockTtlSeconds() {
|
|
917
|
-
return lockConfig$1().ttlSeconds;
|
|
918
|
-
}
|
|
919
964
|
//#endregion
|
|
920
965
|
//#region src/commands/claude-hook-session-state.ts
|
|
921
966
|
/**
|
|
@@ -945,14 +990,14 @@ function defaultSessionState() {
|
|
|
945
990
|
updated_at: ""
|
|
946
991
|
};
|
|
947
992
|
}
|
|
948
|
-
function expandHome
|
|
993
|
+
function expandHome(value) {
|
|
949
994
|
if (value === "~") return homedir();
|
|
950
995
|
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
951
996
|
return value;
|
|
952
997
|
}
|
|
953
998
|
function contextDir() {
|
|
954
999
|
const override = process.env.CODEMEM_CLAUDE_HOOK_CONTEXT_DIR;
|
|
955
|
-
return expandHome
|
|
1000
|
+
return expandHome(override?.trim() ? override : "~/.codemem/claude-hook-context");
|
|
956
1001
|
}
|
|
957
1002
|
function sessionFileStem(sessionId) {
|
|
958
1003
|
const trimmed = sessionId.trim();
|
|
@@ -1381,8 +1426,8 @@ async function ingestClaudeHookPayload(payload, opts, deps = {}) {
|
|
|
1381
1426
|
};
|
|
1382
1427
|
const httpResult = await httpIngest(httpPayload(payload), opts.host, port);
|
|
1383
1428
|
if (httpResult.ok) {
|
|
1384
|
-
await flushOnBoundaryIfRequested();
|
|
1385
1429
|
await drainBacklogIfPresent();
|
|
1430
|
+
await flushOnBoundaryIfRequested();
|
|
1386
1431
|
return {
|
|
1387
1432
|
inserted: httpResult.inserted,
|
|
1388
1433
|
skipped: httpResult.skipped,
|
|
@@ -1622,273 +1667,34 @@ var claudeHookInjectCommand = claudeHookInjectCmd.action(async (opts) => {
|
|
|
1622
1667
|
});
|
|
1623
1668
|
//#endregion
|
|
1624
1669
|
//#region src/commands/codex-hook-ingest-spool.ts
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1654
|
-
function codexHookLockTtlSeconds() {
|
|
1655
|
-
return lockConfig().ttlSeconds;
|
|
1656
|
-
}
|
|
1657
|
-
function hasCodexHookSpooledEntries() {
|
|
1658
|
-
let entries;
|
|
1659
|
-
try {
|
|
1660
|
-
entries = readdirSync(codexHookSpoolDir());
|
|
1661
|
-
} catch {
|
|
1662
|
-
return false;
|
|
1663
|
-
}
|
|
1664
|
-
return entries.some((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-"));
|
|
1665
|
-
}
|
|
1666
|
-
function readTrimmed(path) {
|
|
1667
|
-
try {
|
|
1668
|
-
return readFileSync(path, "utf8").trim();
|
|
1669
|
-
} catch {
|
|
1670
|
-
return "";
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
function readLockMetadata(lockDir) {
|
|
1674
|
-
const rawTs = readTrimmed(join(lockDir, "ts"));
|
|
1675
|
-
const ts = rawTs === "" ? null : Number.parseInt(rawTs, 10);
|
|
1676
|
-
return {
|
|
1677
|
-
pid: readTrimmed(join(lockDir, "pid")),
|
|
1678
|
-
ts: ts === null || !Number.isFinite(ts) ? null : ts,
|
|
1679
|
-
owner: readTrimmed(join(lockDir, "owner"))
|
|
1680
|
-
};
|
|
1681
|
-
}
|
|
1682
|
-
function isPidAlive(pidText) {
|
|
1683
|
-
const pid = Number.parseInt(pidText, 10);
|
|
1684
|
-
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
1685
|
-
try {
|
|
1686
|
-
process.kill(pid, 0);
|
|
1687
|
-
return true;
|
|
1688
|
-
} catch {
|
|
1689
|
-
return false;
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
function lockIsStale(cfg) {
|
|
1693
|
-
const snapshot = readLockMetadata(cfg.lockDir);
|
|
1694
|
-
const nowS = Math.floor(Date.now() / 1e3);
|
|
1695
|
-
if (snapshot.pid) {
|
|
1696
|
-
if (isPidAlive(snapshot.pid)) return {
|
|
1697
|
-
stale: snapshot.ts !== null && nowS - snapshot.ts > cfg.ttlSeconds,
|
|
1698
|
-
snapshot
|
|
1699
|
-
};
|
|
1700
|
-
return {
|
|
1701
|
-
stale: true,
|
|
1702
|
-
snapshot
|
|
1703
|
-
};
|
|
1704
|
-
}
|
|
1705
|
-
if (snapshot.ts !== null) return {
|
|
1706
|
-
stale: nowS - snapshot.ts > cfg.graceSeconds,
|
|
1707
|
-
snapshot
|
|
1708
|
-
};
|
|
1709
|
-
try {
|
|
1710
|
-
return {
|
|
1711
|
-
stale: nowS - Math.floor(statSync(cfg.lockDir).mtimeMs / 1e3) > cfg.graceSeconds,
|
|
1712
|
-
snapshot
|
|
1713
|
-
};
|
|
1714
|
-
} catch {
|
|
1715
|
-
return {
|
|
1716
|
-
stale: true,
|
|
1717
|
-
snapshot
|
|
1718
|
-
};
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
function cleanupLockDir(lockDir) {
|
|
1722
|
-
for (const name of [
|
|
1723
|
-
"pid",
|
|
1724
|
-
"ts",
|
|
1725
|
-
"owner"
|
|
1726
|
-
]) try {
|
|
1727
|
-
unlinkSync(join(lockDir, name));
|
|
1728
|
-
} catch {}
|
|
1729
|
-
try {
|
|
1730
|
-
rmdirSync(lockDir);
|
|
1731
|
-
} catch {}
|
|
1732
|
-
}
|
|
1733
|
-
function cleanupLockDirIfUnchanged(lockDir, snapshot) {
|
|
1734
|
-
const current = readLockMetadata(lockDir);
|
|
1735
|
-
if (current.pid === snapshot.pid && current.ts === snapshot.ts && current.owner === snapshot.owner) cleanupLockDir(lockDir);
|
|
1736
|
-
}
|
|
1737
|
-
function isErrnoException(err) {
|
|
1738
|
-
return typeof err === "object" && err !== null && "code" in err;
|
|
1739
|
-
}
|
|
1740
|
-
function sleep(ms) {
|
|
1741
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1742
|
-
}
|
|
1743
|
-
async function withCodexHookIngestLock(fn) {
|
|
1744
|
-
const cfg = lockConfig();
|
|
1745
|
-
mkdirSync(dirname(cfg.lockDir), { recursive: true });
|
|
1746
|
-
const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
|
|
1747
|
-
let acquired = false;
|
|
1748
|
-
for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
|
1749
|
-
try {
|
|
1750
|
-
mkdirSync(cfg.lockDir);
|
|
1751
|
-
} catch (err) {
|
|
1752
|
-
if (isErrnoException(err) && err.code === "EEXIST") {
|
|
1753
|
-
const { stale, snapshot } = lockIsStale(cfg);
|
|
1754
|
-
if (stale) cleanupLockDirIfUnchanged(cfg.lockDir, snapshot);
|
|
1755
|
-
}
|
|
1756
|
-
await sleep(LOCK_ACQUIRE_BACKOFF_MS);
|
|
1757
|
-
continue;
|
|
1758
|
-
}
|
|
1759
|
-
try {
|
|
1760
|
-
writeFileSync(join(cfg.lockDir, "ts"), String(Math.floor(Date.now() / 1e3)), "utf8");
|
|
1761
|
-
writeFileSync(join(cfg.lockDir, "pid"), String(process.pid), "utf8");
|
|
1762
|
-
writeFileSync(join(cfg.lockDir, "owner"), ownerToken, "utf8");
|
|
1763
|
-
acquired = true;
|
|
1764
|
-
break;
|
|
1765
|
-
} catch {
|
|
1766
|
-
cleanupLockDir(cfg.lockDir);
|
|
1767
|
-
await sleep(LOCK_ACQUIRE_BACKOFF_MS);
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
if (!acquired) throw new CodexHookLockBusyError();
|
|
1771
|
-
try {
|
|
1772
|
-
return await fn();
|
|
1773
|
-
} finally {
|
|
1774
|
-
if (readTrimmed(join(cfg.lockDir, "owner")) === ownerToken) cleanupLockDir(cfg.lockDir);
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
function spoolCodexHookPayload(payload) {
|
|
1778
|
-
const dir = codexHookSpoolDir();
|
|
1779
|
-
try {
|
|
1780
|
-
mkdirSync(dir, { recursive: true });
|
|
1781
|
-
} catch {
|
|
1782
|
-
logHookEvent("codemem codex-hook-ingest failed to create spool dir");
|
|
1783
|
-
return false;
|
|
1784
|
-
}
|
|
1785
|
-
const tmpPath = join(dir, `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`);
|
|
1786
|
-
try {
|
|
1787
|
-
writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
|
|
1788
|
-
} catch {
|
|
1789
|
-
logHookEvent("codemem codex-hook-ingest failed to allocate spool temp file");
|
|
1790
|
-
return false;
|
|
1791
|
-
}
|
|
1792
|
-
const finalPath = join(dir, `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
|
|
1793
|
-
try {
|
|
1794
|
-
renameSync(tmpPath, finalPath);
|
|
1795
|
-
} catch {
|
|
1796
|
-
try {
|
|
1797
|
-
unlinkSync(tmpPath);
|
|
1798
|
-
} catch {}
|
|
1799
|
-
logHookEvent("codemem codex-hook-ingest failed to spool payload");
|
|
1800
|
-
return false;
|
|
1801
|
-
}
|
|
1802
|
-
logHookEvent(`codemem codex-hook-ingest spooled payload: ${finalPath}`);
|
|
1803
|
-
return true;
|
|
1804
|
-
}
|
|
1805
|
-
function recoverStaleCodexHookTmpSpool(ttlSeconds) {
|
|
1806
|
-
const dir = codexHookSpoolDir();
|
|
1807
|
-
try {
|
|
1808
|
-
mkdirSync(dir, { recursive: true });
|
|
1809
|
-
} catch {
|
|
1810
|
-
return;
|
|
1811
|
-
}
|
|
1812
|
-
let entries;
|
|
1813
|
-
try {
|
|
1814
|
-
entries = readdirSync(dir);
|
|
1815
|
-
} catch {
|
|
1816
|
-
return;
|
|
1817
|
-
}
|
|
1818
|
-
const nowS = Date.now() / 1e3;
|
|
1819
|
-
for (const name of entries) {
|
|
1820
|
-
if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
|
|
1821
|
-
const tmpPath = join(dir, name);
|
|
1822
|
-
try {
|
|
1823
|
-
if (nowS - statSync(tmpPath).mtimeMs / 1e3 <= ttlSeconds) continue;
|
|
1824
|
-
renameSync(tmpPath, join(dir, `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`));
|
|
1825
|
-
} catch {}
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
function quarantineSpoolEntry(dir, name, reason) {
|
|
1829
|
-
try {
|
|
1830
|
-
renameSync(join(dir, name), join(dir, `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`));
|
|
1831
|
-
} catch {
|
|
1832
|
-
try {
|
|
1833
|
-
unlinkSync(join(dir, name));
|
|
1834
|
-
} catch {}
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
async function drainCodexHookSpool(handler) {
|
|
1838
|
-
const dir = codexHookSpoolDir();
|
|
1839
|
-
try {
|
|
1840
|
-
mkdirSync(dir, { recursive: true });
|
|
1841
|
-
} catch {
|
|
1842
|
-
return {
|
|
1843
|
-
processed: 0,
|
|
1844
|
-
failed: 0
|
|
1845
|
-
};
|
|
1846
|
-
}
|
|
1847
|
-
let entries;
|
|
1848
|
-
try {
|
|
1849
|
-
entries = readdirSync(dir);
|
|
1850
|
-
} catch {
|
|
1851
|
-
return {
|
|
1852
|
-
processed: 0,
|
|
1853
|
-
failed: 0
|
|
1854
|
-
};
|
|
1855
|
-
}
|
|
1856
|
-
const result = {
|
|
1857
|
-
processed: 0,
|
|
1858
|
-
failed: 0
|
|
1859
|
-
};
|
|
1860
|
-
for (const name of entries.filter((entry) => entry.endsWith(".json") && !entry.startsWith(".hook-tmp-") && !entry.startsWith(".bad-")).sort()) {
|
|
1861
|
-
const path = join(dir, name);
|
|
1862
|
-
let parsed;
|
|
1863
|
-
try {
|
|
1864
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1865
|
-
} catch {
|
|
1866
|
-
quarantineSpoolEntry(dir, name, "parse-error");
|
|
1867
|
-
result.failed++;
|
|
1868
|
-
continue;
|
|
1869
|
-
}
|
|
1870
|
-
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1871
|
-
quarantineSpoolEntry(dir, name, "wrong-shape");
|
|
1872
|
-
result.failed++;
|
|
1873
|
-
continue;
|
|
1874
|
-
}
|
|
1875
|
-
let ok = false;
|
|
1876
|
-
try {
|
|
1877
|
-
ok = await handler(parsed);
|
|
1878
|
-
} catch {
|
|
1879
|
-
ok = false;
|
|
1880
|
-
}
|
|
1881
|
-
if (!ok) {
|
|
1882
|
-
result.failed++;
|
|
1883
|
-
continue;
|
|
1884
|
-
}
|
|
1885
|
-
try {
|
|
1886
|
-
unlinkSync(path);
|
|
1887
|
-
result.processed++;
|
|
1888
|
-
} catch {}
|
|
1889
|
-
}
|
|
1890
|
-
return result;
|
|
1891
|
-
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Durability layer for `codex-hook-ingest`. The lock/spool/drain/
|
|
1672
|
+
* quarantine machinery is shared in `hook-ingest-spool.ts`; this file
|
|
1673
|
+
* wires the codex-specific config (dirs, TTL 120s, 20 acquire attempts,
|
|
1674
|
+
* error name/message).
|
|
1675
|
+
*/
|
|
1676
|
+
var spool = createHookIngestSpool({
|
|
1677
|
+
logPrefix: "codemem codex-hook-ingest",
|
|
1678
|
+
lockDirEnv: "CODEMEM_CODEX_HOOK_LOCK_DIR",
|
|
1679
|
+
lockDirDefault: "~/.codemem/codex-hook-ingest.lock",
|
|
1680
|
+
lockTtlEnv: "CODEMEM_CODEX_HOOK_LOCK_TTL_S",
|
|
1681
|
+
lockTtlDefault: 120,
|
|
1682
|
+
lockGraceEnv: "CODEMEM_CODEX_HOOK_LOCK_GRACE_S",
|
|
1683
|
+
lockGraceDefault: 2,
|
|
1684
|
+
lockAcquireAttempts: 20,
|
|
1685
|
+
spoolDirEnv: "CODEMEM_CODEX_HOOK_SPOOL_DIR",
|
|
1686
|
+
spoolDirDefault: "~/.codemem/codex-hook-spool",
|
|
1687
|
+
lockBusyErrorName: "CodexHookLockBusyError",
|
|
1688
|
+
lockBusyErrorMessage: "codex-hook-ingest lock busy"
|
|
1689
|
+
});
|
|
1690
|
+
var CodexHookLockBusyError = spool.LockBusyError;
|
|
1691
|
+
var withCodexHookIngestLock = spool.withLock;
|
|
1692
|
+
var spoolCodexHookPayload = spool.spoolPayload;
|
|
1693
|
+
var drainCodexHookSpool = spool.drainSpool;
|
|
1694
|
+
var hasCodexHookSpooledEntries = spool.hasSpooledEntries;
|
|
1695
|
+
var recoverStaleCodexHookTmpSpool = spool.recoverStaleTmpSpool;
|
|
1696
|
+
var codexHookLockTtlSeconds = spool.lockTtlSeconds;
|
|
1697
|
+
spool.spoolDir;
|
|
1892
1698
|
//#endregion
|
|
1893
1699
|
//#region src/commands/codex-hook-ingest.ts
|
|
1894
1700
|
/**
|