@ngockhoale/ukit 2.3.7 → 2.3.8
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/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/core/compact/threshold.js +96 -6
- package/templates/.claude/hooks/auto-allow-bash.sh +115 -71
- package/templates/.claude/hooks/auto-prune-bash.sh +88 -30
- package/templates/.claude/hooks/context-hardcap-gate.sh +13 -4
- package/templates/.claude/hooks/reset-compact-pressure.sh +117 -13
- package/templates/.claude/hooks/verification-guard.sh +87 -19
- package/templates/.claude/ukit/index/cache-utils.mjs +53 -29
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +110 -10
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +30 -20
- package/templates/.claude/ukit/runtime/output-compression.mjs +10 -2
- package/templates/.claude/ukit/runtime/reinject-context.mjs +30 -4
|
@@ -1,25 +1,129 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# SessionStart hook:
|
|
2
|
+
# SessionStart hook: clear THIS session's compact pressure record at session start.
|
|
3
3
|
#
|
|
4
|
-
# compact-pressure.json
|
|
5
|
-
# accumulates
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
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
|
|
23
|
+
function writeJsonAtomic(filePath, value) {
|
|
24
24
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
25
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
}
|
|
@@ -668,7 +668,71 @@ export function resolveThresholdCompactBudget({
|
|
|
668
668
|
};
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
+
// ---- session-scoped pressure document ---------------------------------------
|
|
672
|
+
// compact-pressure.json holds ONE record per session id. A session starting must never
|
|
673
|
+
// zero another live session's pressure bookkeeping (its hard-cap gate and compact
|
|
674
|
+
// advisories), and a brand-new session must not inherit a sibling's totals. The newest
|
|
675
|
+
// record's flat fields stay projected at the document's top level so readers that predate
|
|
676
|
+
// this shape (raw JSON readers) still see a coherent state.
|
|
677
|
+
const PRESSURE_SESSIONS_MAX = 8;
|
|
678
|
+
|
|
679
|
+
function normalizeSessionId(value) {
|
|
680
|
+
const id = typeof value === 'string' ? value.trim() : '';
|
|
681
|
+
return id || null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function readPressureDocument(raw) {
|
|
685
|
+
if (raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object') {
|
|
686
|
+
const sessions = {};
|
|
687
|
+
for (const [id, record] of Object.entries(raw.sessions)) {
|
|
688
|
+
if (id && record && typeof record === 'object') {
|
|
689
|
+
sessions[id] = record;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return sessions;
|
|
693
|
+
}
|
|
694
|
+
// Legacy single-record file: preserve the in-flight session under a shared bucket.
|
|
695
|
+
return raw && typeof raw === 'object' ? { default: raw } : {};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function pickPressureSession(sessions, sessionId) {
|
|
699
|
+
if (sessionId) {
|
|
700
|
+
// A session id that has no record yet starts clean — it must not inherit another
|
|
701
|
+
// session's totals, and the other way round nothing here touches that session.
|
|
702
|
+
return { id: sessionId, record: sessions[sessionId] ?? null };
|
|
703
|
+
}
|
|
704
|
+
const entries = Object.entries(sessions);
|
|
705
|
+
if (!entries.length) {
|
|
706
|
+
return { id: 'default', record: null };
|
|
707
|
+
}
|
|
708
|
+
const newest = entries
|
|
709
|
+
.sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))[0];
|
|
710
|
+
return { id: newest[0], record: newest[1] };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function resolvePressureRecord(raw, config) {
|
|
714
|
+
if (!raw || typeof raw !== 'object' || !raw.sessions || typeof raw.sessions !== 'object') {
|
|
715
|
+
return raw; // flat legacy state — normalized as-is below
|
|
716
|
+
}
|
|
717
|
+
const { record } = pickPressureSession(readPressureDocument(raw), normalizeSessionId(config?.sessionId));
|
|
718
|
+
return record;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function projectPressureDocument(sessions) {
|
|
722
|
+
const newestFirst = Object.entries(sessions)
|
|
723
|
+
.sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))
|
|
724
|
+
.slice(0, PRESSURE_SESSIONS_MAX);
|
|
725
|
+
const newestRecord = newestFirst[0]?.[1] ?? null;
|
|
726
|
+
return {
|
|
727
|
+
...(newestRecord && typeof newestRecord === 'object' ? newestRecord : {}),
|
|
728
|
+
updatedAt: finiteNumber(newestRecord?.updatedAt, Date.now()),
|
|
729
|
+
v: 2,
|
|
730
|
+
sessions: Object.fromEntries(newestFirst),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
671
734
|
export function buildCompactPressureState(rawState = null, config = {}) {
|
|
735
|
+
rawState = resolvePressureRecord(rawState, config);
|
|
672
736
|
const thresholds = buildCompactThresholds(config);
|
|
673
737
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
674
738
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
@@ -1056,13 +1120,18 @@ export async function readCompactPressureState(projectRoot, config = {}) {
|
|
|
1056
1120
|
// All compact-pressure mutations share one lock on the state file: without it,
|
|
1057
1121
|
// concurrent hook processes (parallel subagents) and same-process flows interleave
|
|
1058
1122
|
// their read-modify-write cycles and silently drop each other's sessionTokens.
|
|
1123
|
+
// Mutations are session-scoped: only the calling session's record is rewritten;
|
|
1124
|
+
// sibling session records pass through untouched.
|
|
1059
1125
|
async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
1060
1126
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1061
1127
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1062
|
-
const
|
|
1128
|
+
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1129
|
+
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1130
|
+
const current = buildCompactPressureState(record, config);
|
|
1063
1131
|
const next = mutator(current);
|
|
1064
1132
|
const normalized = buildCompactPressureState(next, config);
|
|
1065
|
-
|
|
1133
|
+
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1134
|
+
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1066
1135
|
return normalized;
|
|
1067
1136
|
});
|
|
1068
1137
|
}
|
|
@@ -1070,22 +1139,42 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
1070
1139
|
export async function writeCompactPressureState(projectRoot, state, config = {}) {
|
|
1071
1140
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1072
1141
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1142
|
+
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1143
|
+
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1073
1144
|
const normalized = buildCompactPressureState(state, config);
|
|
1074
|
-
|
|
1145
|
+
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1146
|
+
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1075
1147
|
return normalized;
|
|
1076
1148
|
});
|
|
1077
1149
|
}
|
|
1078
1150
|
|
|
1151
|
+
function pressureSessionConfig(payload = {}, config = {}) {
|
|
1152
|
+
const sessionId = normalizeSessionId(payload?.sessionId) ?? normalizeSessionId(config?.sessionId);
|
|
1153
|
+
return sessionId ? { ...config, sessionId } : config;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1079
1156
|
export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
|
|
1080
|
-
return mutateCompactPressureState(
|
|
1157
|
+
return mutateCompactPressureState(
|
|
1158
|
+
projectRoot,
|
|
1159
|
+
(current) => registerPromptPressure(current, payload, config),
|
|
1160
|
+
pressureSessionConfig(payload, config),
|
|
1161
|
+
);
|
|
1081
1162
|
}
|
|
1082
1163
|
|
|
1083
1164
|
export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
|
|
1084
|
-
return mutateCompactPressureState(
|
|
1165
|
+
return mutateCompactPressureState(
|
|
1166
|
+
projectRoot,
|
|
1167
|
+
(current) => registerOutputPressure(current, payload, config),
|
|
1168
|
+
pressureSessionConfig(payload, config),
|
|
1169
|
+
);
|
|
1085
1170
|
}
|
|
1086
1171
|
|
|
1087
1172
|
export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
|
|
1088
|
-
return mutateCompactPressureState(
|
|
1173
|
+
return mutateCompactPressureState(
|
|
1174
|
+
projectRoot,
|
|
1175
|
+
(current) => registerThresholdCompactPlan(current, plan, config),
|
|
1176
|
+
pressureSessionConfig(plan, config),
|
|
1177
|
+
);
|
|
1089
1178
|
}
|
|
1090
1179
|
|
|
1091
1180
|
async function loadRuntimeConfig(projectRoot) {
|
|
@@ -1141,6 +1230,16 @@ async function runCli() {
|
|
|
1141
1230
|
return;
|
|
1142
1231
|
}
|
|
1143
1232
|
|
|
1233
|
+
// Session-scoped bookkeeping: every read/write in this branch targets the calling
|
|
1234
|
+
// session's record, so a sibling session starting in the same project cannot zero
|
|
1235
|
+
// this session's pressure history.
|
|
1236
|
+
const sessionConfig = {
|
|
1237
|
+
...config,
|
|
1238
|
+
sessionId: typeof payload.session_id === 'string' && payload.session_id.trim()
|
|
1239
|
+
? payload.session_id.trim()
|
|
1240
|
+
: undefined,
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1144
1243
|
// Heal a stale counter BEFORE the phase is computed. Auto-compact paths that never
|
|
1145
1244
|
// fire the PreCompact hook leave sessionTokens counting history that was already
|
|
1146
1245
|
// summarised away — that phantom total is how a healthy session got hard-cap blocked.
|
|
@@ -1148,21 +1247,22 @@ async function runCli() {
|
|
|
1148
1247
|
// automatic, so they are the correction signal here.
|
|
1149
1248
|
try {
|
|
1150
1249
|
const synced = await syncCompactPressureStateWithTranscript(
|
|
1151
|
-
await readCompactPressureState(projectRoot,
|
|
1152
|
-
|
|
1250
|
+
await readCompactPressureState(projectRoot, sessionConfig),
|
|
1251
|
+
sessionConfig,
|
|
1153
1252
|
payload.transcript_path,
|
|
1154
1253
|
);
|
|
1155
1254
|
if (synced.reset) {
|
|
1156
|
-
await writeCompactPressureState(projectRoot, synced.state,
|
|
1255
|
+
await writeCompactPressureState(projectRoot, synced.state, sessionConfig);
|
|
1157
1256
|
}
|
|
1158
1257
|
} catch { /* advisory healing only — never block the prompt over it */ }
|
|
1159
1258
|
|
|
1160
1259
|
const nextState = await updateCompactPressureFromPrompt(projectRoot, {
|
|
1161
1260
|
promptText,
|
|
1261
|
+
sessionId: sessionConfig.sessionId,
|
|
1162
1262
|
routingContext: routeState?.routingContext ?? {},
|
|
1163
1263
|
previousContext: routeState?.previousContext ?? null,
|
|
1164
1264
|
routeSummary: routeState?.routeSummary ?? null,
|
|
1165
|
-
},
|
|
1265
|
+
}, sessionConfig);
|
|
1166
1266
|
|
|
1167
1267
|
if (nextState.phase === 'hard') {
|
|
1168
1268
|
process.stdout.write(
|
|
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
|
|
5
5
|
import fsSync from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { withFileLock } from './token-utils.mjs';
|
|
8
9
|
|
|
9
10
|
const LEDGER_VERSION = 1;
|
|
10
11
|
const RESUME_INTENT_VERSION = 1;
|
|
@@ -566,29 +567,38 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
566
567
|
}
|
|
567
568
|
|
|
568
569
|
export async function incrementContinuation(projectRoot, payload = {}, ledger = null, requestKey = null) {
|
|
569
|
-
const
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
570
|
+
const target = ledgerPath(projectRoot, payload);
|
|
571
|
+
// Locked re-read: parallel subagents firing Stop hooks share one ledger file, and a
|
|
572
|
+
// stale-snapshot rewrite would reset each other's continuationCount — the budget would
|
|
573
|
+
// never advance and the gate would keep issuing continuations.
|
|
574
|
+
return withFileLock(target, async () => {
|
|
575
|
+
const current = await readExecutionLedger(projectRoot, payload) || ledger || freshLedger(payload, null, 'unknown');
|
|
576
|
+
// Mirror evaluateCompletion's staleness rule: a count minted by an earlier request must
|
|
577
|
+
// not be carried into the new request's budget, or the cap fires early (evaluate says 0,
|
|
578
|
+
// persist says 7) and the next request inherits a nearly exhausted budget.
|
|
579
|
+
const stale = requestKey
|
|
580
|
+
&& current?.continuationRequestKey
|
|
581
|
+
&& current.continuationRequestKey !== requestKey;
|
|
582
|
+
const next = {
|
|
583
|
+
...current,
|
|
584
|
+
continuationCount: (stale ? 0 : Number(current.continuationCount || 0)) + 1,
|
|
585
|
+
...(requestKey ? { continuationRequestKey: requestKey } : {}),
|
|
586
|
+
lastContinuationAt: Date.now(),
|
|
587
|
+
updatedAt: Date.now(),
|
|
588
|
+
};
|
|
589
|
+
await writeJsonAtomic(target, next);
|
|
590
|
+
return next;
|
|
591
|
+
});
|
|
585
592
|
}
|
|
586
593
|
|
|
587
594
|
export async function markNotified(projectRoot, payload = {}, ledger = null) {
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
595
|
+
const target = ledgerPath(projectRoot, payload);
|
|
596
|
+
return withFileLock(target, async () => {
|
|
597
|
+
const current = await readExecutionLedger(projectRoot, payload) || ledger || freshLedger(payload, null, 'unknown');
|
|
598
|
+
const next = { ...current, notified: true, updatedAt: Date.now() };
|
|
599
|
+
await writeJsonAtomic(target, next);
|
|
600
|
+
return next;
|
|
601
|
+
});
|
|
592
602
|
}
|
|
593
603
|
|
|
594
604
|
async function readStdin() {
|
|
@@ -1189,6 +1189,14 @@ async function main() {
|
|
|
1189
1189
|
|
|
1190
1190
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1191
1191
|
const config = await loadRuntimeConfig(projectRoot);
|
|
1192
|
+
// Session-scoped pressure bookkeeping: output tokens must land in the calling
|
|
1193
|
+
// session's record, never a sibling session's.
|
|
1194
|
+
const sessionConfig = {
|
|
1195
|
+
...config,
|
|
1196
|
+
sessionId: typeof payload.session_id === 'string' && payload.session_id.trim()
|
|
1197
|
+
? payload.session_id.trim()
|
|
1198
|
+
: undefined,
|
|
1199
|
+
};
|
|
1192
1200
|
if (!shouldCompress(config)) {
|
|
1193
1201
|
process.exit(0);
|
|
1194
1202
|
return;
|
|
@@ -1247,7 +1255,7 @@ async function main() {
|
|
|
1247
1255
|
tokensAfter: cached.tokensAfter ?? 0,
|
|
1248
1256
|
savedTokens: cached.savedTokens ?? 0,
|
|
1249
1257
|
exitCode,
|
|
1250
|
-
},
|
|
1258
|
+
}, sessionConfig);
|
|
1251
1259
|
process.stdout.write(String(cached.content));
|
|
1252
1260
|
return;
|
|
1253
1261
|
}
|
|
@@ -1345,7 +1353,7 @@ async function main() {
|
|
|
1345
1353
|
tokensAfter: result.tokensAfter,
|
|
1346
1354
|
savedTokens: result.savedTokens,
|
|
1347
1355
|
exitCode,
|
|
1348
|
-
},
|
|
1356
|
+
}, sessionConfig);
|
|
1349
1357
|
|
|
1350
1358
|
if (config?.tokenPipeline?.promptCache) {
|
|
1351
1359
|
await writePromptCacheEntry(projectRoot, {
|