@ngockhoale/ukit 2.3.6 → 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 +82 -0
- package/package.json +2 -2
- package/src/core/compact/threshold.js +113 -13
- package/src/core/fileOps.js +69 -0
- package/src/core/output/index.js +16 -11
- package/src/core/uninstall.js +53 -4
- 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 +128 -17
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +30 -20
- package/templates/.claude/ukit/runtime/output-compression.mjs +29 -19
- package/templates/.claude/ukit/runtime/reinject-context.mjs +30 -4
- package/templates/.claude/ukit/runtime/token-utils.mjs +83 -2
|
@@ -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
|
-
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
compressLine,
|
|
9
9
|
estimateTokenCount,
|
|
10
10
|
readJson,
|
|
11
|
+
withFileLock,
|
|
12
|
+
writeJson,
|
|
11
13
|
} from './token-utils.mjs';
|
|
12
14
|
|
|
13
15
|
const DEFAULT_MAX_PROMPT_ENTRIES = 12;
|
|
@@ -666,7 +668,71 @@ export function resolveThresholdCompactBudget({
|
|
|
666
668
|
};
|
|
667
669
|
}
|
|
668
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
|
+
|
|
669
734
|
export function buildCompactPressureState(rawState = null, config = {}) {
|
|
735
|
+
rawState = resolvePressureRecord(rawState, config);
|
|
670
736
|
const thresholds = buildCompactThresholds(config);
|
|
671
737
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
672
738
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
@@ -1051,30 +1117,64 @@ export async function readCompactPressureState(projectRoot, config = {}) {
|
|
|
1051
1117
|
return buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
|
|
1052
1118
|
}
|
|
1053
1119
|
|
|
1120
|
+
// All compact-pressure mutations share one lock on the state file: without it,
|
|
1121
|
+
// concurrent hook processes (parallel subagents) and same-process flows interleave
|
|
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.
|
|
1125
|
+
async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
1126
|
+
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1127
|
+
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1128
|
+
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1129
|
+
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1130
|
+
const current = buildCompactPressureState(record, config);
|
|
1131
|
+
const next = mutator(current);
|
|
1132
|
+
const normalized = buildCompactPressureState(next, config);
|
|
1133
|
+
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1134
|
+
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1135
|
+
return normalized;
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1054
1139
|
export async function writeCompactPressureState(projectRoot, state, config = {}) {
|
|
1055
1140
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1141
|
+
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1142
|
+
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1143
|
+
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1144
|
+
const normalized = buildCompactPressureState(state, config);
|
|
1145
|
+
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1146
|
+
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1147
|
+
return normalized;
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function pressureSessionConfig(payload = {}, config = {}) {
|
|
1152
|
+
const sessionId = normalizeSessionId(payload?.sessionId) ?? normalizeSessionId(config?.sessionId);
|
|
1153
|
+
return sessionId ? { ...config, sessionId } : config;
|
|
1060
1154
|
}
|
|
1061
1155
|
|
|
1062
1156
|
export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1157
|
+
return mutateCompactPressureState(
|
|
1158
|
+
projectRoot,
|
|
1159
|
+
(current) => registerPromptPressure(current, payload, config),
|
|
1160
|
+
pressureSessionConfig(payload, config),
|
|
1161
|
+
);
|
|
1066
1162
|
}
|
|
1067
1163
|
|
|
1068
1164
|
export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1165
|
+
return mutateCompactPressureState(
|
|
1166
|
+
projectRoot,
|
|
1167
|
+
(current) => registerOutputPressure(current, payload, config),
|
|
1168
|
+
pressureSessionConfig(payload, config),
|
|
1169
|
+
);
|
|
1072
1170
|
}
|
|
1073
1171
|
|
|
1074
1172
|
export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1173
|
+
return mutateCompactPressureState(
|
|
1174
|
+
projectRoot,
|
|
1175
|
+
(current) => registerThresholdCompactPlan(current, plan, config),
|
|
1176
|
+
pressureSessionConfig(plan, config),
|
|
1177
|
+
);
|
|
1078
1178
|
}
|
|
1079
1179
|
|
|
1080
1180
|
async function loadRuntimeConfig(projectRoot) {
|
|
@@ -1130,6 +1230,16 @@ async function runCli() {
|
|
|
1130
1230
|
return;
|
|
1131
1231
|
}
|
|
1132
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
|
+
|
|
1133
1243
|
// Heal a stale counter BEFORE the phase is computed. Auto-compact paths that never
|
|
1134
1244
|
// fire the PreCompact hook leave sessionTokens counting history that was already
|
|
1135
1245
|
// summarised away — that phantom total is how a healthy session got hard-cap blocked.
|
|
@@ -1137,21 +1247,22 @@ async function runCli() {
|
|
|
1137
1247
|
// automatic, so they are the correction signal here.
|
|
1138
1248
|
try {
|
|
1139
1249
|
const synced = await syncCompactPressureStateWithTranscript(
|
|
1140
|
-
await readCompactPressureState(projectRoot,
|
|
1141
|
-
|
|
1250
|
+
await readCompactPressureState(projectRoot, sessionConfig),
|
|
1251
|
+
sessionConfig,
|
|
1142
1252
|
payload.transcript_path,
|
|
1143
1253
|
);
|
|
1144
1254
|
if (synced.reset) {
|
|
1145
|
-
await writeCompactPressureState(projectRoot, synced.state,
|
|
1255
|
+
await writeCompactPressureState(projectRoot, synced.state, sessionConfig);
|
|
1146
1256
|
}
|
|
1147
1257
|
} catch { /* advisory healing only — never block the prompt over it */ }
|
|
1148
1258
|
|
|
1149
1259
|
const nextState = await updateCompactPressureFromPrompt(projectRoot, {
|
|
1150
1260
|
promptText,
|
|
1261
|
+
sessionId: sessionConfig.sessionId,
|
|
1151
1262
|
routingContext: routeState?.routingContext ?? {},
|
|
1152
1263
|
previousContext: routeState?.previousContext ?? null,
|
|
1153
1264
|
routeSummary: routeState?.routeSummary ?? null,
|
|
1154
|
-
},
|
|
1265
|
+
}, sessionConfig);
|
|
1155
1266
|
|
|
1156
1267
|
if (nextState.phase === 'hard') {
|
|
1157
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() {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
readJson,
|
|
11
11
|
readPromptCacheEntry,
|
|
12
12
|
recordCompaction,
|
|
13
|
+
withFileLock,
|
|
14
|
+
writeJson,
|
|
13
15
|
writePromptCacheEntry,
|
|
14
16
|
} from './token-utils.mjs';
|
|
15
17
|
import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
|
|
@@ -1113,26 +1115,26 @@ function normalizeOutputHistoryDocument(raw) {
|
|
|
1113
1115
|
};
|
|
1114
1116
|
}
|
|
1115
1117
|
|
|
1116
|
-
async function writeJson(filePath, value) {
|
|
1117
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
1118
|
-
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
1118
|
async function appendOutputHistory(projectRoot, entry) {
|
|
1122
1119
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1120
|
+
// The read-merge-write cycle runs under the state-file lock: concurrent tool-output
|
|
1121
|
+
// captures (parallel subagents fire hooks in separate processes) otherwise read the
|
|
1122
|
+
// same old history and overwrite each other, leaving only one survivor entry.
|
|
1123
|
+
return withFileLock(runtimePaths.outputHistoryPath, async () => {
|
|
1124
|
+
const current = normalizeOutputHistoryDocument(await readJson(runtimePaths.outputHistoryPath, { entries: [] }));
|
|
1125
|
+
const normalizedEntry = normalizeOutputHistoryEntry(entry);
|
|
1126
|
+
if (!normalizedEntry) return current;
|
|
1127
|
+
const normalizedKey = buildOutputHistoryDedupeKey(normalizedEntry);
|
|
1128
|
+
const dedupedExistingEntries = current.entries.filter((candidate) => !(
|
|
1129
|
+
buildOutputHistoryDedupeKey(candidate) === normalizedKey
|
|
1130
|
+
));
|
|
1131
|
+
|
|
1132
|
+
const nextDocument = normalizeOutputHistoryDocument({
|
|
1133
|
+
entries: [normalizedEntry, ...dedupedExistingEntries],
|
|
1134
|
+
});
|
|
1135
|
+
await writeJson(runtimePaths.outputHistoryPath, nextDocument);
|
|
1136
|
+
return nextDocument;
|
|
1133
1137
|
});
|
|
1134
|
-
await writeJson(runtimePaths.outputHistoryPath, nextDocument);
|
|
1135
|
-
return nextDocument;
|
|
1136
1138
|
}
|
|
1137
1139
|
|
|
1138
1140
|
async function findOutputHistoryEntry(projectRoot, command, summary) {
|
|
@@ -1187,6 +1189,14 @@ async function main() {
|
|
|
1187
1189
|
|
|
1188
1190
|
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1189
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
|
+
};
|
|
1190
1200
|
if (!shouldCompress(config)) {
|
|
1191
1201
|
process.exit(0);
|
|
1192
1202
|
return;
|
|
@@ -1245,7 +1255,7 @@ async function main() {
|
|
|
1245
1255
|
tokensAfter: cached.tokensAfter ?? 0,
|
|
1246
1256
|
savedTokens: cached.savedTokens ?? 0,
|
|
1247
1257
|
exitCode,
|
|
1248
|
-
},
|
|
1258
|
+
}, sessionConfig);
|
|
1249
1259
|
process.stdout.write(String(cached.content));
|
|
1250
1260
|
return;
|
|
1251
1261
|
}
|
|
@@ -1343,7 +1353,7 @@ async function main() {
|
|
|
1343
1353
|
tokensAfter: result.tokensAfter,
|
|
1344
1354
|
savedTokens: result.savedTokens,
|
|
1345
1355
|
exitCode,
|
|
1346
|
-
},
|
|
1356
|
+
}, sessionConfig);
|
|
1347
1357
|
|
|
1348
1358
|
if (config?.tokenPipeline?.promptCache) {
|
|
1349
1359
|
await writePromptCacheEntry(projectRoot, {
|
|
@@ -43,6 +43,30 @@ async function main() {
|
|
|
43
43
|
? process.env.CLAUDE_PROJECT_DIR
|
|
44
44
|
: process.cwd();
|
|
45
45
|
const config = await loadRuntimeConfig(projectRoot);
|
|
46
|
+
// Session-scoped pressure bookkeeping: this PreCompact run must zero ONLY the calling
|
|
47
|
+
// session's record. Reading stdin here is safe — the harness payload is modest and this
|
|
48
|
+
// process owns it exclusively.
|
|
49
|
+
const rawHookInput = await (async () => {
|
|
50
|
+
if (process.stdin.isTTY) return '';
|
|
51
|
+
const chunks = [];
|
|
52
|
+
for await (const chunk of process.stdin) {
|
|
53
|
+
chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
54
|
+
}
|
|
55
|
+
return chunks.join('');
|
|
56
|
+
})();
|
|
57
|
+
const hookPayload = (() => {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(rawHookInput || '{}');
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
const sessionConfig = {
|
|
65
|
+
...config,
|
|
66
|
+
sessionId: typeof hookPayload.session_id === 'string' && hookPayload.session_id.trim()
|
|
67
|
+
? hookPayload.session_id.trim()
|
|
68
|
+
: undefined,
|
|
69
|
+
};
|
|
46
70
|
const state = await loadFreshRouteState(projectRoot);
|
|
47
71
|
const compressEnabled = shouldCompress(config);
|
|
48
72
|
const header = '=== PROJECT CONTEXT (post-compaction) ===';
|
|
@@ -67,15 +91,16 @@ async function main() {
|
|
|
67
91
|
|
|
68
92
|
const rawBodyLines = [...rawStaticLines, ...dynamicLines];
|
|
69
93
|
const bodyLines = [...staticLines, ...dynamicLines];
|
|
70
|
-
const pressureState = await readCompactPressureState(projectRoot,
|
|
94
|
+
const pressureState = await readCompactPressureState(projectRoot, sessionConfig);
|
|
71
95
|
|
|
72
96
|
// Reset the hard-cap tracker as early as possible: this hook fires on every real
|
|
73
97
|
// compaction, and it is the only signal that context actually shrank. It must happen
|
|
74
98
|
// before the promptCache early-return below, otherwise a cache hit would skip it and
|
|
75
99
|
// leave context-hardcap-gate.sh permanently tripped. Downstream writes
|
|
76
100
|
// (writeThresholdCompactPlan) re-read from disk, so they build on the zeroed state and
|
|
77
|
-
// keep their own stats increments intact.
|
|
78
|
-
|
|
101
|
+
// keep their own stats increments intact. Session-scoped: sibling sessions keep their
|
|
102
|
+
// own records.
|
|
103
|
+
await writeCompactPressureState(projectRoot, resetCompactPressureState(pressureState, sessionConfig), sessionConfig);
|
|
79
104
|
const anchorLines = dynamicLines.filter((line) => (
|
|
80
105
|
line.startsWith('- Recent routed lane:')
|
|
81
106
|
|| line.startsWith('- Current approach:')
|
|
@@ -153,7 +178,8 @@ async function main() {
|
|
|
153
178
|
tokensBefore: compactedBody.tokensBefore,
|
|
154
179
|
tokensAfter: compactedBody.tokensAfter,
|
|
155
180
|
savedTokens: compactedBody.savedTokens,
|
|
156
|
-
|
|
181
|
+
sessionId: sessionConfig.sessionId,
|
|
182
|
+
}, sessionConfig);
|
|
157
183
|
}
|
|
158
184
|
|
|
159
185
|
const compactSource = thresholdPlan.active
|
|
@@ -54,9 +54,90 @@ export async function readJson(filePath, fallback = null) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
async function writeJson(filePath, value) {
|
|
57
|
+
export async function writeJson(filePath, value) {
|
|
58
|
+
// tmp + rename: parallel hook processes must never observe a half-written state file.
|
|
58
59
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
59
|
-
|
|
60
|
+
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
61
|
+
try {
|
|
62
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
63
|
+
await fs.rename(tempPath, filePath);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
try {
|
|
66
|
+
await fs.rm(tempPath, { force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore cleanup errors
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LOCK_STALE_MS = 10_000;
|
|
75
|
+
const LOCK_MAX_WAIT_MS = 5_000;
|
|
76
|
+
|
|
77
|
+
function lockBackoffDelayMs() {
|
|
78
|
+
return 3 + Math.floor(Math.random() * 9);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sleep(ms) {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Serialize read-modify-write mutations of a shared state file — across processes
|
|
87
|
+
* (hook invocations run as separate node processes) and across concurrent async
|
|
88
|
+
* flows in one process (parallel subagents). The lock is a directory created next
|
|
89
|
+
* to the target file: `mkdir` is atomic, so exactly one caller can create it.
|
|
90
|
+
* A crashed holder is reclaimed once the directory's mtime exceeds staleMs.
|
|
91
|
+
* Liveness wins over strictness: if the lock cannot be acquired within maxWaitMs
|
|
92
|
+
* the callback runs anyway (the pre-lock behaviour) — these state files are
|
|
93
|
+
* advisory caches, and losing an update beats freezing a hook mid-flight.
|
|
94
|
+
* Protocol-compatible with src/core/fileOps.js withFileLock (same `<file>.lock`
|
|
95
|
+
* path), so CLI processes and hook processes serialize against each other.
|
|
96
|
+
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
97
|
+
* @param {() => Promise<*>} fn - critical section; its result is returned
|
|
98
|
+
* @returns {Promise<*>} whatever fn resolves with
|
|
99
|
+
*/
|
|
100
|
+
export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxWaitMs = LOCK_MAX_WAIT_MS } = {}) {
|
|
101
|
+
const lockPath = `${filePath}.lock`;
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
let locked = false;
|
|
104
|
+
|
|
105
|
+
while (!locked) {
|
|
106
|
+
try {
|
|
107
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
108
|
+
await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
|
|
109
|
+
locked = true;
|
|
110
|
+
break;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Someone holds the lock. Reclaim it when it looks abandoned; otherwise back off.
|
|
116
|
+
try {
|
|
117
|
+
const stat = await fs.stat(lockPath);
|
|
118
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
119
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
120
|
+
continue; // the slot is free now — retry immediately
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
continue; // lock vanished between mkdir and stat — retry immediately
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
|
|
127
|
+
await sleep(lockBackoffDelayMs());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return await fn();
|
|
132
|
+
} finally {
|
|
133
|
+
if (locked) {
|
|
134
|
+
try {
|
|
135
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
136
|
+
} catch {
|
|
137
|
+
// best-effort release; a stale lock is reclaimed by the next waiter
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
60
141
|
}
|
|
61
142
|
|
|
62
143
|
export function buildCompactMachineKey(prefix, payload = {}) {
|