@ngockhoale/ukit 2.3.7 → 2.3.9

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.
@@ -15,6 +15,7 @@ NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
15
15
 
16
16
  node -e '
17
17
  const fs = require("fs");
18
+ const path = require("path");
18
19
 
19
20
  const settingsPath = process.argv[1];
20
21
  const usagePath = process.argv[2];
@@ -31,45 +32,102 @@ function readJson(filePath) {
31
32
  }
32
33
  }
33
34
 
35
+ function writeJsonAtomic(filePath, value) {
36
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
37
+ const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
38
+ try {
39
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n");
40
+ fs.renameSync(tempPath, filePath);
41
+ } catch (error) {
42
+ try {
43
+ fs.rmSync(tempPath, { force: true });
44
+ } catch {}
45
+ throw error;
46
+ }
47
+ }
48
+
34
49
  function appendAudit(event, payload) {
35
50
  fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
36
51
  }
37
52
 
38
- const settings = readJson(settingsPath);
39
- const usage = readJson(usagePath);
40
- if (!settings || !usage) process.exit(0);
41
- if (!settings.permissions || typeof settings.permissions !== "object") process.exit(0);
42
- if (!Array.isArray(settings.permissions.allow)) process.exit(0);
43
- if (!usage.managedRules || typeof usage.managedRules !== "object") process.exit(0);
44
-
45
- const allow = settings.permissions.allow;
46
- let changedSettings = false;
47
- let prunedCount = 0;
48
-
49
- for (const [rule, meta] of Object.entries(usage.managedRules)) {
50
- const lastSeen = Date.parse(meta?.lastSeen || "");
51
- if (!Number.isFinite(lastSeen) || lastSeen >= cutoff) continue;
52
-
53
- const idx = allow.indexOf(rule);
54
- if (idx !== -1) {
55
- allow.splice(idx, 1);
56
- changedSettings = true;
53
+ // Same lock point as auto-allow-bash.sh (<usage>.lock): the two hooks serialize their
54
+ // settings/usage rewrites against each other and against parallel invocations. Atomic
55
+ // writes because Claude Code re-reads settings.local.json — a torn write would drop the
56
+ // ENTIRE allow list and every command would prompt again. Fail-open after 5s: a hook
57
+ // must never hang the chain.
58
+ async function withLock(lockPath, fn) {
59
+ const staleMs = 10000;
60
+ const maxWaitMs = 5000;
61
+ const startedAt = Date.now();
62
+ let held = false;
63
+ while (!held) {
64
+ try {
65
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
66
+ fs.mkdirSync(lockPath);
67
+ held = true;
68
+ } catch (error) {
69
+ if (!error || error.code !== "EEXIST") return fn();
70
+ try {
71
+ const stat = fs.statSync(lockPath);
72
+ if (Date.now() - stat.mtimeMs > staleMs) {
73
+ try {
74
+ fs.rmSync(lockPath, { recursive: true, force: true });
75
+ continue;
76
+ } catch {}
77
+ }
78
+ } catch {}
79
+ if (Date.now() - startedAt > maxWaitMs) break;
80
+ await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
81
+ }
82
+ }
83
+ try {
84
+ return await fn();
85
+ } finally {
86
+ if (held) {
87
+ try {
88
+ fs.rmdirSync(lockPath);
89
+ } catch {}
90
+ }
57
91
  }
58
- delete usage.managedRules[rule];
59
- prunedCount += 1;
60
- appendAudit("auto_prune_ttl", { rule, ttlDays });
61
92
  }
62
93
 
63
- usage.lastPruneAt = nowUtc;
94
+ withLock(`${usagePath}.lock`, async () => {
95
+ const settings = readJson(settingsPath);
96
+ const usage = readJson(usagePath);
97
+ if (!settings || !usage) return;
98
+ if (!settings.permissions || typeof settings.permissions !== "object") return;
99
+ if (!Array.isArray(settings.permissions.allow)) return;
100
+ if (!usage.managedRules || typeof usage.managedRules !== "object") return;
64
101
 
65
- if (changedSettings) {
66
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
67
- }
68
- fs.writeFileSync(usagePath, JSON.stringify(usage, null, 2) + "\n");
102
+ const allow = settings.permissions.allow;
103
+ let changedSettings = false;
104
+ let prunedCount = 0;
69
105
 
70
- if (prunedCount > 0) {
71
- appendAudit("auto_prune_summary", { prunedCount, ttlDays });
72
- }
106
+ for (const [rule, meta] of Object.entries(usage.managedRules)) {
107
+ const lastSeen = Date.parse(meta?.lastSeen || "");
108
+ if (!Number.isFinite(lastSeen) || lastSeen >= cutoff) continue;
109
+
110
+ const idx = allow.indexOf(rule);
111
+ if (idx !== -1) {
112
+ allow.splice(idx, 1);
113
+ changedSettings = true;
114
+ }
115
+ delete usage.managedRules[rule];
116
+ prunedCount += 1;
117
+ appendAudit("auto_prune_ttl", { rule, ttlDays });
118
+ }
119
+
120
+ usage.lastPruneAt = nowUtc;
121
+
122
+ if (changedSettings) {
123
+ writeJsonAtomic(settingsPath, settings);
124
+ }
125
+ writeJsonAtomic(usagePath, usage);
126
+
127
+ if (prunedCount > 0) {
128
+ appendAudit("auto_prune_summary", { prunedCount, ttlDays });
129
+ }
130
+ }).catch(() => {});
73
131
  ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$TTL_DAYS" "$NOW_UTC" >/dev/null 2>&1 || true
74
132
 
75
133
  exit 0
@@ -87,7 +87,21 @@ function readRunCursor() {
87
87
 
88
88
  (async () => {
89
89
  const config = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), {}) || {};
90
- if (config?.compact?.hardCapBlock === false) {
90
+ // Session-scoped pressure bookkeeping: the gate decides for THIS session's record, so a
91
+ // sibling session starting (SessionStart reset) or compacting never trips or clears
92
+ // this gate for the wrong session.
93
+ const sessionConfig = {
94
+ ...config,
95
+ sessionId: typeof payload.session_id === 'string' && payload.session_id.trim()
96
+ ? payload.session_id.trim()
97
+ : undefined,
98
+ };
99
+ // This is a liveness backstop, not a security control. A present value with the wrong
100
+ // JSON shape must never leave the gate unexpectedly enabled and brick every mutation once
101
+ // the cap is reached. Only an explicitly configured boolean `true` enables the gate;
102
+ // missing preserves the documented default-on behavior for existing installations.
103
+ const rawHardCapBlock = config?.compact?.hardCapBlock;
104
+ if (rawHardCapBlock === false || (rawHardCapBlock !== undefined && typeof rawHardCapBlock !== 'boolean')) {
91
105
  process.exit(0);
92
106
  return;
93
107
  }
@@ -111,14 +125,14 @@ function readRunCursor() {
111
125
  // bricks a healthy session at this cap. The transcript's own compact_boundary entries
112
126
  // are the reliable record of real compactions, so sync before deciding, and persist the
113
127
  // reset so it re-arms exactly once per boundary instead of disabling the gate.
114
- let state = mod.buildCompactPressureState(rawState, config);
128
+ let state = mod.buildCompactPressureState(rawState, sessionConfig);
115
129
  try {
116
- const synced = await mod.syncCompactPressureStateWithTranscript(rawState, config, payload.transcript_path);
130
+ const synced = await mod.syncCompactPressureStateWithTranscript(state, sessionConfig, payload.transcript_path);
117
131
  if (synced.reset) {
118
- state = await mod.writeCompactPressureState(projectRoot, synced.state, config);
132
+ state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
119
133
  }
120
134
  } catch {
121
- state = mod.buildCompactPressureState(rawState, config);
135
+ state = mod.buildCompactPressureState(rawState, sessionConfig);
122
136
  }
123
137
  const thresholds = mod.buildCompactThresholds(config);
124
138
 
@@ -166,8 +180,15 @@ function readRunCursor() {
166
180
  }
167
181
  const resumable = run || ordinaryTask;
168
182
  if (resumable) {
169
- const graceCalls = Number.isFinite(config?.compact?.hardCapGraceCalls)
170
- ? config.compact.hardCapGraceCalls
183
+ // Invalid config must not turn the landing allowance negative/zero (which makes every
184
+ // unfinished run hard-block immediately). This hook is a liveness backstop, so malformed
185
+ // values deliberately fall back to the documented default rather than fail closed.
186
+ const configuredGraceCalls = config?.compact?.hardCapGraceCalls;
187
+ const graceCalls = typeof configuredGraceCalls === 'number'
188
+ && Number.isFinite(configuredGraceCalls)
189
+ && Number.isInteger(configuredGraceCalls)
190
+ && configuredGraceCalls > 0
191
+ ? configuredGraceCalls
171
192
  : 10;
172
193
 
173
194
  let slots = readSlots();
@@ -73,7 +73,7 @@ function loadHardCap() {
73
73
  try {
74
74
  const raw = fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8');
75
75
  const value = JSON.parse(raw)?.compact?.hardCapTokens;
76
- if (Number.isFinite(value) && value > 0) return value;
76
+ if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0) return value;
77
77
  } catch { /* fall through to the default */ }
78
78
  return 500_000;
79
79
  }
@@ -1,25 +1,129 @@
1
1
  #!/bin/bash
2
- # SessionStart hook: zero the compact pressure tracker at the start of every session.
2
+ # SessionStart hook: clear THIS session's compact pressure record at session start.
3
3
  #
4
- # compact-pressure.json is a single per-project file whose sessionTokens counter only ever
5
- # accumulates (see registerPromptPressure/registerOutputPressure in compact-threshold.mjs).
6
- # Without this reset it sums every prompt and every command output across the entire
7
- # lifetime of the project, so it eventually crosses compact.hardCapTokens and permanently
8
- # blocks Edit/Write/Bash in every future session -- even when actual context is tiny.
9
- # That is a deadlock: the gate blocks Bash, so the documented remedies (ukit install,
10
- # ukit doctor) cannot run, and compaction never fires because real context is small.
4
+ # compact-pressure.json stores one record per session id (see compact-threshold.mjs).
5
+ # sessionTokens accumulates within a record, so a record left over from a previous
6
+ # session would keep counting against this one — eventually crossing
7
+ # compact.hardCapTokens and blocking Edit/Write/Bash even when actual context is tiny.
8
+ # Clearing the calling session's record at start makes the counter measure THIS session,
9
+ # and gives the gate a guaranteed escape hatch: restarting the session always clears a
10
+ # stuck gate, with no manual file surgery.
11
11
  #
12
- # Resetting here makes the counter measure THIS session only, which is what the hard cap
13
- # is actually trying to approximate. It also gives the gate a guaranteed escape hatch:
14
- # restarting the session always clears a stuck gate, with no manual file surgery.
12
+ # Session-scoped (P1 fix): an earlier version deleted the whole file, which also erased
13
+ # the pressure history of any OTHER live session in this project — that session silently
14
+ # lost its near-cap warnings and could run into the real context cap. Now only the
15
+ # calling session's record is removed; sibling records and the legacy flat file content
16
+ # are preserved.
15
17
  #
16
18
  # Always exits 0 -- a failure here must never prevent a session from starting.
17
19
 
18
20
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
19
21
  PRESSURE_FILE="$PROJECT_ROOT/.ukit/storage/cache/compact-pressure.json"
20
22
 
21
- if [ -f "$PRESSURE_FILE" ]; then
22
- rm -f "$PRESSURE_FILE" 2>/dev/null || true
23
+ if [ ! -f "$PRESSURE_FILE" ]; then
24
+ exit 0
23
25
  fi
24
26
 
27
+ # The lock directory lives beside the state file; its parent exists because the file does.
28
+ node -e '
29
+ const fs = require("fs");
30
+
31
+ async function withLock(lockPath, fn) {
32
+ const staleMs = 10000;
33
+ const maxWaitMs = 5000;
34
+ const startedAt = Date.now();
35
+ let held = false;
36
+ while (!held) {
37
+ try {
38
+ fs.mkdirSync(lockPath);
39
+ held = true;
40
+ } catch (error) {
41
+ if (!error || error.code !== "EEXIST") return fn();
42
+ try {
43
+ const stat = fs.statSync(lockPath);
44
+ if (Date.now() - stat.mtimeMs > staleMs) {
45
+ try {
46
+ fs.rmSync(lockPath, { recursive: true, force: true });
47
+ continue;
48
+ } catch {}
49
+ }
50
+ } catch {}
51
+ if (Date.now() - startedAt > maxWaitMs) break;
52
+ await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
53
+ }
54
+ }
55
+ try {
56
+ return await fn();
57
+ } finally {
58
+ if (held) {
59
+ try {
60
+ fs.rmdirSync(lockPath);
61
+ } catch {}
62
+ }
63
+ }
64
+ }
65
+
66
+ const chunks = [];
67
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
68
+ process.stdin.on("end", () => {
69
+ const payload = (() => {
70
+ try {
71
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
72
+ } catch {
73
+ return {};
74
+ }
75
+ })();
76
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
77
+ const pressurePath = process.argv[1];
78
+
79
+ withLock(`${pressurePath}.lock`, () => {
80
+ const raw = (() => {
81
+ try {
82
+ return JSON.parse(fs.readFileSync(pressurePath, "utf8"));
83
+ } catch {
84
+ return null;
85
+ }
86
+ })();
87
+ if (!raw || typeof raw !== "object") {
88
+ try {
89
+ fs.rmSync(pressurePath, { force: true });
90
+ } catch {}
91
+ return;
92
+ }
93
+
94
+ // Without a session id (unknown caller) keep the old whole-file reset: this hook is
95
+ // the documented escape hatch for a bricked gate, and it must stay one.
96
+ if (!sessionId || !raw.sessions || typeof raw.sessions !== "object") {
97
+ try {
98
+ fs.rmSync(pressurePath, { force: true });
99
+ } catch {}
100
+ return;
101
+ }
102
+
103
+ const sessions = { ...raw.sessions };
104
+ delete sessions[sessionId];
105
+ const newest = Object.entries(sessions)
106
+ .sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
107
+ const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
108
+ try {
109
+ fs.writeFileSync(
110
+ tempPath,
111
+ JSON.stringify({
112
+ ...(newest && typeof newest === "object" ? newest : {}),
113
+ updatedAt: newest?.updatedAt ?? Date.now(),
114
+ v: 2,
115
+ sessions,
116
+ }, null, 2) + "\n",
117
+ );
118
+ fs.renameSync(tempPath, pressurePath);
119
+ } catch (error) {
120
+ try {
121
+ fs.rmSync(tempPath, { force: true });
122
+ } catch {}
123
+ throw error;
124
+ }
125
+ }).catch(() => {});
126
+ });
127
+ ' "$PRESSURE_FILE" >/dev/null 2>&1 || true
128
+
25
129
  exit 0
@@ -20,9 +20,64 @@ function readJson(filePath, fallback = null) {
20
20
  }
21
21
  }
22
22
 
23
- function writeJson(filePath, value) {
23
+ function writeJsonAtomic(filePath, value) {
24
24
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
25
- fs.writeFileSync(filePath, JSON.stringify(value));
25
+ const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
26
+ try {
27
+ fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n');
28
+ fs.renameSync(tempPath, filePath);
29
+ } catch (error) {
30
+ try {
31
+ fs.rmSync(tempPath, { force: true });
32
+ } catch {}
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ // Sync sleep without burning CPU — the hook's flow calls process.exit right after
38
+ // persistAttempt, so the critical section must complete synchronously.
39
+ function sleepSync(ms) {
40
+ const buffer = new SharedArrayBuffer(4);
41
+ Atomics.wait(new Int32Array(buffer), 0, 0, ms);
42
+ }
43
+
44
+ // mkdir-based lock, protocol-compatible with runtime token-utils withFileLock. Sync on
45
+ // purpose (see sleepSync). Fail-open after 5s: this hook must never hang a PreToolUse
46
+ // chain, and a lost progress entry only costs one repeated advisory.
47
+ function withLockSync(lockPath, fn) {
48
+ const staleMs = 10000;
49
+ const maxWaitMs = 5000;
50
+ const startedAt = Date.now();
51
+ let held = false;
52
+ while (!held) {
53
+ try {
54
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
55
+ fs.mkdirSync(lockPath);
56
+ held = true;
57
+ } catch (error) {
58
+ if (!error || error.code !== 'EEXIST') return fn();
59
+ try {
60
+ const stat = fs.statSync(lockPath);
61
+ if (Date.now() - stat.mtimeMs > staleMs) {
62
+ try {
63
+ fs.rmSync(lockPath, { recursive: true, force: true });
64
+ continue;
65
+ } catch {}
66
+ }
67
+ } catch {}
68
+ if (Date.now() - startedAt > maxWaitMs) break;
69
+ sleepSync(3 + Math.floor(Math.random() * 9));
70
+ }
71
+ }
72
+ try {
73
+ return fn();
74
+ } finally {
75
+ if (held) {
76
+ try {
77
+ fs.rmdirSync(lockPath);
78
+ } catch {}
79
+ }
80
+ }
26
81
  }
27
82
 
28
83
  function normalizeCommand(value) {
@@ -316,23 +371,36 @@ function persistAttempt(commandText) {
316
371
  const normalizedCommandText = normalizeCommand(commandText);
317
372
  attemptedCommands.add(normalizedCommandText);
318
373
  const now = Date.now();
319
- const recentAttempts = [
320
- ...(progress?.recentAttempts ?? []),
321
- { command: normalizedCommandText, ts: now },
322
- ]
323
- .map((entry) => ({
324
- command: normalizeCommand(entry?.command),
325
- ts: Number(entry?.ts ?? now),
326
- }))
327
- .filter((entry) => entry.command && Number.isFinite(entry.ts) && now - entry.ts <= STATE_FRESH_MS)
328
- .filter((entry, index, list) => (
329
- list.findLastIndex((candidate) => candidate.command === entry.command) === index
330
- ));
331
- writeJson(progressPath, {
332
- fingerprint: state?.fingerprint || null,
333
- updatedAt: now,
334
- attemptedCommands: [...attemptedCommands],
335
- recentAttempts,
374
+ // Locked re-read + merge: parallel subagents record attempts concurrently, and a
375
+ // stale-snapshot rewrite would silently drop their entries — the guard would then keep
376
+ // advising instead of allowing, re-litigating verification order forever.
377
+ withLockSync(`${progressPath}.lock`, () => {
378
+ const current = readJson(progressPath, {});
379
+ const mergedAttempts = new Set(collectAttemptedCommands(current, {
380
+ currentFingerprint: state?.fingerprint || null,
381
+ maxAgeMs: STATE_FRESH_MS,
382
+ }));
383
+ for (const command of attemptedCommands) {
384
+ mergedAttempts.add(command);
385
+ }
386
+ const recentAttempts = [
387
+ ...(current?.recentAttempts ?? []),
388
+ { command: normalizedCommandText, ts: now },
389
+ ]
390
+ .map((entry) => ({
391
+ command: normalizeCommand(entry?.command),
392
+ ts: Number(entry?.ts ?? now),
393
+ }))
394
+ .filter((entry) => entry.command && Number.isFinite(entry.ts) && now - entry.ts <= STATE_FRESH_MS)
395
+ .filter((entry, index, list) => (
396
+ list.findLastIndex((candidate) => candidate.command === entry.command) === index
397
+ ));
398
+ writeJsonAtomic(progressPath, {
399
+ fingerprint: state?.fingerprint || null,
400
+ updatedAt: now,
401
+ attemptedCommands: [...mergedAttempts],
402
+ recentAttempts,
403
+ });
336
404
  });
337
405
  }
338
406
 
@@ -1,6 +1,30 @@
1
1
  #!/usr/bin/env node
2
+ // Cache read-modify-write cycles run in separate node processes (query/triage/route/
3
+ // resolve-context hooks and CLI calls can fire concurrently). They must serialize on
4
+ // withFileLock — without it, concurrent writers each append against a stale snapshot
5
+ // and silently drop each other's entries (verified: 20 concurrent writers kept 2).
2
6
  import fs from 'node:fs/promises';
3
7
  import path from 'node:path';
8
+ import { readJson, withFileLock } from '../runtime/token-utils.mjs';
9
+
10
+ // Cache files stay single-line compact JSON (locked by tests): they sit inside .claude/
11
+ // and pretty-printing them would turn every cache touch into a noisy multi-line diff.
12
+ // Atomic like token-utils writeJson (tmp + rename), only compact.
13
+ async function writeCacheJson(cachePath, value) {
14
+ await fs.mkdir(path.dirname(cachePath), { recursive: true });
15
+ const tempPath = `${cachePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
16
+ try {
17
+ await fs.writeFile(tempPath, JSON.stringify(value), 'utf8');
18
+ await fs.rename(tempPath, cachePath);
19
+ } catch (error) {
20
+ try {
21
+ await fs.rm(tempPath, { force: true });
22
+ } catch {
23
+ // ignore cleanup errors
24
+ }
25
+ throw error;
26
+ }
27
+ }
4
28
 
5
29
  export const DEFAULT_RECENT_CACHE_MAX_ENTRIES = 6;
6
30
 
@@ -36,14 +60,25 @@ export async function readRecentCacheEntry(
36
60
  ...entry,
37
61
  updatedAt: Date.now(),
38
62
  };
39
- const entries = [
40
- touchedEntry,
41
- ...cache.entries.slice(0, entryIndex),
42
- ...cache.entries.slice(entryIndex + 1),
43
- ].slice(0, maxEntries);
44
- const document = buildCacheDocument(entries, { maxEntries });
45
- await writeJson(cachePath, document);
46
- return document.entries[0] ?? touchedEntry;
63
+ return withFileLock(cachePath, async () => {
64
+ const locked = await readRecentCache(cachePath, { maxEntries });
65
+ const lockedIndex = locked.entries.findIndex((lockedEntry) => lockedEntry.requestKey === requestKey);
66
+ if (lockedIndex < 0) {
67
+ return touchedEntry;
68
+ }
69
+ const lockedEntry = {
70
+ ...locked.entries[lockedIndex],
71
+ updatedAt: touchedEntry.updatedAt,
72
+ };
73
+ const entries = [
74
+ lockedEntry,
75
+ ...locked.entries.slice(0, lockedIndex),
76
+ ...locked.entries.slice(lockedIndex + 1),
77
+ ].slice(0, maxEntries);
78
+ const document = buildCacheDocument(entries, { maxEntries });
79
+ await writeCacheJson(cachePath, document);
80
+ return document.entries[0] ?? lockedEntry;
81
+ });
47
82
  }
48
83
 
49
84
  export async function writeRecentCacheEntry(
@@ -56,14 +91,16 @@ export async function writeRecentCacheEntry(
56
91
  return readRecentCache(cachePath, { maxEntries });
57
92
  }
58
93
 
59
- const cache = await readRecentCache(cachePath, { maxEntries });
60
- const entries = [
61
- normalizedEntry,
62
- ...cache.entries.filter((existingEntry) => existingEntry.requestKey !== normalizedEntry.requestKey),
63
- ].slice(0, maxEntries);
64
- const document = buildCacheDocument(entries, { maxEntries });
65
- await writeJson(cachePath, document);
66
- return document;
94
+ return withFileLock(cachePath, async () => {
95
+ const cache = await readRecentCache(cachePath, { maxEntries });
96
+ const entries = [
97
+ normalizedEntry,
98
+ ...cache.entries.filter((existingEntry) => existingEntry.requestKey !== normalizedEntry.requestKey),
99
+ ].slice(0, maxEntries);
100
+ const document = buildCacheDocument(entries, { maxEntries });
101
+ await writeCacheJson(cachePath, document);
102
+ return document;
103
+ });
67
104
  }
68
105
 
69
106
  function normalizeCacheDocument(raw, { maxEntries = DEFAULT_RECENT_CACHE_MAX_ENTRIES } = {}) {
@@ -125,16 +162,3 @@ function normalizeCacheEntry(entry) {
125
162
  delete normalizedEntry.entries;
126
163
  return normalizedEntry;
127
164
  }
128
-
129
- async function readJson(filePath, fallback = null) {
130
- try {
131
- return JSON.parse(await fs.readFile(filePath, 'utf8'));
132
- } catch {
133
- return fallback;
134
- }
135
- }
136
-
137
- async function writeJson(filePath, value) {
138
- await fs.mkdir(path.dirname(typeof filePath === 'string' && filePath ? filePath : '.'), { recursive: true });
139
- await fs.writeFile(filePath, JSON.stringify(value), 'utf8');
140
- }