@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.3.8 - 2026-09-10
|
|
6
|
+
|
|
7
|
+
Freeze-sweep wave 4: every fix in this release targets the "agent silently stops working"
|
|
8
|
+
class — lost permission rules that turn into human-waiting prompts, a session start that
|
|
9
|
+
wipes a sibling session's near-cap warnings, torn state files, and lost concurrency
|
|
10
|
+
bookkeeping. All found by verified reproducers and fixed with regression coverage.
|
|
11
|
+
|
|
12
|
+
**P1 — SessionStart wiped a sibling session's compact-pressure state.** The SessionStart
|
|
13
|
+
reset hook deleted the whole project-wide `compact-pressure.json`, so starting a second
|
|
14
|
+
session in the same project zeroed the first session's pressure history: its near-cap
|
|
15
|
+
warnings stopped firing, it ran into the real context cap, and it stalled. Pressure state
|
|
16
|
+
is now a session-keyed document (`{"v":2,"sessions":{<session_id>:{…}}}`) with the newest
|
|
17
|
+
record's flat fields still projected at the top level for raw readers. Every writer and
|
|
18
|
+
reader threads the hook payload's `session_id` (compact-threshold CLI, output-compression,
|
|
19
|
+
reinject-context PreCompact, context-hardcap-gate); a brand-new session id starts clean
|
|
20
|
+
and never inherits a sibling's totals; the reset hook removes only the calling session's
|
|
21
|
+
record; sessions are pruned to the 8 most recent; a legacy flat file upgrades in place.
|
|
22
|
+
Ships in both `src/core/compact/threshold.js` and the runtime mirror.
|
|
23
|
+
|
|
24
|
+
**P1 — concurrent auto-allow invocations lost Bash permission rules.** The hook's
|
|
25
|
+
read-modify-write of `settings.local.json` and `permission-usage.json` was unlocked, and
|
|
26
|
+
30 concurrent invocations kept only 26/30 rules and 23/30 usage records — a lost rule
|
|
27
|
+
means the next run of that command prompts for permission again, i.e. the agent sits
|
|
28
|
+
waiting for a human click. Both files are now written inside one mkdir lock on
|
|
29
|
+
`<permission-usage.json>.lock` (protocol-compatible with `withFileLock`, fail-open ≤5s,
|
|
30
|
+
parent directory created before locking) with atomic tmp+rename writes, so Claude Code
|
|
31
|
+
can never observe a torn settings file either — a torn parse would drop the entire allow
|
|
32
|
+
list and prompt on every command. `auto-prune-bash.sh` now shares the same lock and
|
|
33
|
+
atomic writes.
|
|
34
|
+
|
|
35
|
+
**P2 — index cache writes lost entries under concurrency.** `cache-utils.mjs`
|
|
36
|
+
read-modify-write cycles were unlocked: 20 concurrent writers kept 2 entries. All cache
|
|
37
|
+
reads-modify-writes (write + touch paths) now run under `withFileLock` with atomic
|
|
38
|
+
compact writes (cache files stay single-line JSON by contract).
|
|
39
|
+
|
|
40
|
+
**P2 — verification-progress and execution-ledger counters lost concurrent updates.**
|
|
41
|
+
`verification-guard.sh` now records attempts through a locked re-read + merge (sync
|
|
42
|
+
`Atomics.wait` backoff so the hook's `process.exit` flow is untouched) and writes
|
|
43
|
+
atomically; `execution-ledger.mjs`'s continuation/notified counters re-read inside
|
|
44
|
+
`withFileLock` so parallel subagent Stop hooks cannot reset each other's budgets.
|
|
45
|
+
|
|
46
|
+
Full suite: 81 files, 1,373 tests green, including two shuffled runs.
|
|
47
|
+
|
|
5
48
|
## 2.3.7 - 2026-09-10
|
|
6
49
|
|
|
7
50
|
Bug-sweep wave 3: a data-loss escape in uninstall, cross-process races on shared runtime
|
package/package.json
CHANGED
|
@@ -553,7 +553,72 @@ export function resolveThresholdCompactBudget({
|
|
|
553
553
|
};
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
// ---- session-scoped pressure document ---------------------------------------
|
|
557
|
+
// compact-pressure.json holds ONE record per session id. A session starting must never
|
|
558
|
+
// zero another live session's pressure bookkeeping (its hard-cap gate and compact
|
|
559
|
+
// advisories), and a brand-new session must not inherit a sibling's totals. The newest
|
|
560
|
+
// record's flat fields stay projected at the document's top level so readers that predate
|
|
561
|
+
// this shape (raw JSON readers) still see a coherent state.
|
|
562
|
+
// Mirrors templates/.claude/ukit/runtime/compact-threshold.mjs — keep in lockstep.
|
|
563
|
+
const PRESSURE_SESSIONS_MAX = 8;
|
|
564
|
+
|
|
565
|
+
function normalizeSessionId(value) {
|
|
566
|
+
const id = typeof value === 'string' ? value.trim() : '';
|
|
567
|
+
return id || null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function readPressureDocument(raw) {
|
|
571
|
+
if (raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object') {
|
|
572
|
+
const sessions = {};
|
|
573
|
+
for (const [id, record] of Object.entries(raw.sessions)) {
|
|
574
|
+
if (id && record && typeof record === 'object') {
|
|
575
|
+
sessions[id] = record;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return sessions;
|
|
579
|
+
}
|
|
580
|
+
// Legacy single-record file: preserve the in-flight session under a shared bucket.
|
|
581
|
+
return raw && typeof raw === 'object' ? { default: raw } : {};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function pickPressureSession(sessions, sessionId) {
|
|
585
|
+
if (sessionId) {
|
|
586
|
+
// A session id that has no record yet starts clean — it must not inherit another
|
|
587
|
+
// session's totals, and the other way round nothing here touches that session.
|
|
588
|
+
return { id: sessionId, record: sessions[sessionId] ?? null };
|
|
589
|
+
}
|
|
590
|
+
const entries = Object.entries(sessions);
|
|
591
|
+
if (!entries.length) {
|
|
592
|
+
return { id: 'default', record: null };
|
|
593
|
+
}
|
|
594
|
+
const newest = entries
|
|
595
|
+
.sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))[0];
|
|
596
|
+
return { id: newest[0], record: newest[1] };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function resolvePressureRecord(raw, config) {
|
|
600
|
+
if (!raw || typeof raw !== 'object' || !raw.sessions || typeof raw.sessions !== 'object') {
|
|
601
|
+
return raw; // flat legacy state — normalized as-is below
|
|
602
|
+
}
|
|
603
|
+
const { record } = pickPressureSession(readPressureDocument(raw), normalizeSessionId(config?.sessionId));
|
|
604
|
+
return record;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function projectPressureDocument(sessions) {
|
|
608
|
+
const newestFirst = Object.entries(sessions)
|
|
609
|
+
.sort(([, left], [, right]) => finiteNumber(right?.updatedAt, 0) - finiteNumber(left?.updatedAt, 0))
|
|
610
|
+
.slice(0, PRESSURE_SESSIONS_MAX);
|
|
611
|
+
const newestRecord = newestFirst[0]?.[1] ?? null;
|
|
612
|
+
return {
|
|
613
|
+
...(newestRecord && typeof newestRecord === 'object' ? newestRecord : {}),
|
|
614
|
+
updatedAt: finiteNumber(newestRecord?.updatedAt, Date.now()),
|
|
615
|
+
v: 2,
|
|
616
|
+
sessions: Object.fromEntries(newestFirst),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
556
620
|
export function buildCompactPressureState(rawState = null, config = {}) {
|
|
621
|
+
rawState = resolvePressureRecord(rawState, config);
|
|
557
622
|
const thresholds = buildCompactThresholds(config);
|
|
558
623
|
const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
|
|
559
624
|
const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
|
|
@@ -940,13 +1005,18 @@ export async function readCompactPressureState(projectRoot, config = {}) {
|
|
|
940
1005
|
// All compact-pressure mutations share one lock on the state file: without it,
|
|
941
1006
|
// concurrent hook processes (parallel subagents) and same-process flows interleave
|
|
942
1007
|
// their read-modify-write cycles and silently drop each other's sessionTokens.
|
|
1008
|
+
// Mutations are session-scoped: only the calling session's record is rewritten;
|
|
1009
|
+
// sibling session records pass through untouched.
|
|
943
1010
|
async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
944
1011
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
945
1012
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
946
|
-
const
|
|
1013
|
+
const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
|
|
1014
|
+
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1015
|
+
const current = buildCompactPressureState(record, config);
|
|
947
1016
|
const next = mutator(current);
|
|
948
1017
|
const normalized = buildCompactPressureState(next, config);
|
|
949
|
-
|
|
1018
|
+
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1019
|
+
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
950
1020
|
return normalized;
|
|
951
1021
|
});
|
|
952
1022
|
}
|
|
@@ -954,20 +1024,40 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
954
1024
|
export async function writeCompactPressureState(projectRoot, state, config = {}) {
|
|
955
1025
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
956
1026
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1027
|
+
const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
|
|
1028
|
+
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
957
1029
|
const normalized = buildCompactPressureState(state, config);
|
|
958
|
-
|
|
1030
|
+
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1031
|
+
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
959
1032
|
return normalized;
|
|
960
1033
|
});
|
|
961
1034
|
}
|
|
962
1035
|
|
|
1036
|
+
function pressureSessionConfig(payload = {}, config = {}) {
|
|
1037
|
+
const sessionId = normalizeSessionId(payload?.sessionId) ?? normalizeSessionId(config?.sessionId);
|
|
1038
|
+
return sessionId ? { ...config, sessionId } : config;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
963
1041
|
export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
|
|
964
|
-
return mutateCompactPressureState(
|
|
1042
|
+
return mutateCompactPressureState(
|
|
1043
|
+
projectRoot,
|
|
1044
|
+
(current) => registerPromptPressure(current, payload, config),
|
|
1045
|
+
pressureSessionConfig(payload, config),
|
|
1046
|
+
);
|
|
965
1047
|
}
|
|
966
1048
|
|
|
967
1049
|
export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
|
|
968
|
-
return mutateCompactPressureState(
|
|
1050
|
+
return mutateCompactPressureState(
|
|
1051
|
+
projectRoot,
|
|
1052
|
+
(current) => registerOutputPressure(current, payload, config),
|
|
1053
|
+
pressureSessionConfig(payload, config),
|
|
1054
|
+
);
|
|
969
1055
|
}
|
|
970
1056
|
|
|
971
1057
|
export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
|
|
972
|
-
return mutateCompactPressureState(
|
|
1058
|
+
return mutateCompactPressureState(
|
|
1059
|
+
projectRoot,
|
|
1060
|
+
(current) => registerThresholdCompactPlan(current, plan, config),
|
|
1061
|
+
pressureSessionConfig(plan, config),
|
|
1062
|
+
);
|
|
973
1063
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: auto-add Bash(<binary>:*) allow rules for frequently used commands.
|
|
3
|
-
#
|
|
3
|
+
# Settings/usage updates run as ONE locked, atomic read-modify-write (see node block).
|
|
4
4
|
|
|
5
5
|
INPUT=$(cat)
|
|
6
6
|
# jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
|
|
@@ -61,30 +61,22 @@ RULE="Bash(${BIN}:*)"
|
|
|
61
61
|
NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
62
62
|
MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
usage.managedRules[rule] = { firstSeen: prev.firstSeen || nowUtc, lastSeen: nowUtc };
|
|
81
|
-
fs.writeFileSync(usagePath, JSON.stringify(usage, null, 2) + "\n");
|
|
82
|
-
} catch {}
|
|
83
|
-
' "$USAGE_FILE" "$RULE" "$NOW_UTC" >/dev/null 2>&1 || true
|
|
84
|
-
exit 0
|
|
85
|
-
fi
|
|
86
|
-
|
|
87
|
-
# Slow path: rule is new, need full Node.js processing
|
|
64
|
+
# Single locked read-modify-write for settings.local.json + permission-usage.json.
|
|
65
|
+
#
|
|
66
|
+
# Why the lock: parallel hook invocations (main session + subagents) each used to read a
|
|
67
|
+
# stale snapshot and rewrite the whole file, silently dropping each other's allow rules
|
|
68
|
+
# (verified: 30 concurrent invocations kept 26/30 rules and 23/30 usage records). A lost
|
|
69
|
+
# rule means the NEXT invocation of that command prompts for permission again — the agent
|
|
70
|
+
# stalls waiting for a human click. The lock protocol below is protocol-compatible with
|
|
71
|
+
# runtime token-utils withFileLock / src/core/fileOps.js (mkdir-based `<file>.lock`),
|
|
72
|
+
# with the usage file as the single serialization point for both files this hook writes.
|
|
73
|
+
#
|
|
74
|
+
# Why atomic writes: Claude Code re-reads settings.local.json to decide permissions.
|
|
75
|
+
# A torn/partial write would fail JSON parsing and drop the ENTIRE allow list — every
|
|
76
|
+
# command would prompt. tmp+rename makes readers see old-or-new, never half.
|
|
77
|
+
#
|
|
78
|
+
# Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
|
|
79
|
+
# runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
|
|
88
80
|
node -e '
|
|
89
81
|
const fs = require("fs");
|
|
90
82
|
const path = require("path");
|
|
@@ -105,66 +97,118 @@ function readJson(filePath, fallback) {
|
|
|
105
97
|
}
|
|
106
98
|
}
|
|
107
99
|
|
|
100
|
+
function writeJsonAtomic(filePath, value) {
|
|
101
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
102
|
+
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
103
|
+
try {
|
|
104
|
+
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + "\n");
|
|
105
|
+
fs.renameSync(tempPath, filePath);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
try {
|
|
108
|
+
fs.rmSync(tempPath, { force: true });
|
|
109
|
+
} catch {}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
108
114
|
function appendAudit(event, payload) {
|
|
109
115
|
fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
async function withLock(lockPath, fn) {
|
|
119
|
+
const staleMs = 10000;
|
|
120
|
+
const maxWaitMs = 5000;
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
let held = false;
|
|
123
|
+
while (!held) {
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(lockPath);
|
|
126
|
+
held = true;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (!error || error.code !== "EEXIST") return fn();
|
|
129
|
+
try {
|
|
130
|
+
const stat = fs.statSync(lockPath);
|
|
131
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
132
|
+
try {
|
|
133
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
134
|
+
continue;
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
if (Date.now() - startedAt > maxWaitMs) break;
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
return await fn();
|
|
144
|
+
} finally {
|
|
145
|
+
if (held) {
|
|
146
|
+
try {
|
|
147
|
+
fs.rmdirSync(lockPath);
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
123
151
|
}
|
|
124
152
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
153
|
+
// The lock directory lives beside the usage file, so its parent must exist BEFORE the
|
|
154
|
+
// lock is taken — a first-ever run in a fresh project would otherwise fail mkdir with
|
|
155
|
+
// ENOENT, fail open, and race.
|
|
156
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
157
|
+
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
|
|
129
158
|
|
|
130
|
-
|
|
131
|
-
|
|
159
|
+
withLock(`${usagePath}.lock`, () => {
|
|
160
|
+
const settings = readJson(settingsPath, {});
|
|
161
|
+
if (!settings.permissions || typeof settings.permissions !== "object") {
|
|
162
|
+
settings.permissions = {};
|
|
163
|
+
}
|
|
164
|
+
if (!Array.isArray(settings.permissions.allow)) {
|
|
165
|
+
settings.permissions.allow = [];
|
|
166
|
+
}
|
|
132
167
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
168
|
+
const usage = readJson(usagePath, {});
|
|
169
|
+
if (!usage.managedRules || typeof usage.managedRules !== "object") {
|
|
170
|
+
usage.managedRules = {};
|
|
171
|
+
}
|
|
138
172
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
firstSeen: prevMeta.firstSeen || nowUtc,
|
|
142
|
-
lastSeen: nowUtc,
|
|
143
|
-
};
|
|
173
|
+
const allow = settings.permissions.allow;
|
|
174
|
+
let settingsChanged = false;
|
|
144
175
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
176
|
+
if (!allow.includes(rule)) {
|
|
177
|
+
allow.push(rule);
|
|
178
|
+
settingsChanged = true;
|
|
179
|
+
appendAudit("auto_add", { rule });
|
|
180
|
+
}
|
|
150
181
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
182
|
+
const prevMeta = usage.managedRules[rule] || {};
|
|
183
|
+
usage.managedRules[rule] = {
|
|
184
|
+
firstSeen: prevMeta.firstSeen || nowUtc,
|
|
185
|
+
lastSeen: nowUtc,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const managedSorted = Object.entries(usage.managedRules).sort((a, b) => {
|
|
189
|
+
const ta = Date.parse(a[1]?.lastSeen || "") || 0;
|
|
190
|
+
const tb = Date.parse(b[1]?.lastSeen || "") || 0;
|
|
191
|
+
return tb - ta;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (managedSorted.length > maxRules) {
|
|
195
|
+
const evict = managedSorted.slice(maxRules);
|
|
196
|
+
for (const [evictRule] of evict) {
|
|
197
|
+
delete usage.managedRules[evictRule];
|
|
198
|
+
const idx = allow.indexOf(evictRule);
|
|
199
|
+
if (idx !== -1) {
|
|
200
|
+
allow.splice(idx, 1);
|
|
201
|
+
settingsChanged = true;
|
|
202
|
+
}
|
|
203
|
+
appendAudit("auto_prune_cap", { rule: evictRule, maxRules });
|
|
159
204
|
}
|
|
160
|
-
appendAudit("auto_prune_cap", { rule: evictRule, maxRules });
|
|
161
205
|
}
|
|
162
|
-
}
|
|
163
206
|
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
207
|
+
if (settingsChanged) {
|
|
208
|
+
writeJsonAtomic(settingsPath, settings);
|
|
209
|
+
}
|
|
210
|
+
writeJsonAtomic(usagePath, usage);
|
|
211
|
+
}).catch(() => {});
|
|
168
212
|
' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$RULE" "$NOW_UTC" "$MAX_RULES" >/dev/null 2>&1 || true
|
|
169
213
|
|
|
170
214
|
exit 0
|
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
let
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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,6 +87,15 @@ function readRunCursor() {
|
|
|
87
87
|
|
|
88
88
|
(async () => {
|
|
89
89
|
const config = readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), {}) || {};
|
|
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
|
+
};
|
|
90
99
|
if (config?.compact?.hardCapBlock === false) {
|
|
91
100
|
process.exit(0);
|
|
92
101
|
return;
|
|
@@ -111,14 +120,14 @@ function readRunCursor() {
|
|
|
111
120
|
// bricks a healthy session at this cap. The transcript's own compact_boundary entries
|
|
112
121
|
// are the reliable record of real compactions, so sync before deciding, and persist the
|
|
113
122
|
// reset so it re-arms exactly once per boundary instead of disabling the gate.
|
|
114
|
-
let state = mod.buildCompactPressureState(rawState,
|
|
123
|
+
let state = mod.buildCompactPressureState(rawState, sessionConfig);
|
|
115
124
|
try {
|
|
116
|
-
const synced = await mod.syncCompactPressureStateWithTranscript(
|
|
125
|
+
const synced = await mod.syncCompactPressureStateWithTranscript(state, sessionConfig, payload.transcript_path);
|
|
117
126
|
if (synced.reset) {
|
|
118
|
-
state = await mod.writeCompactPressureState(projectRoot, synced.state,
|
|
127
|
+
state = await mod.writeCompactPressureState(projectRoot, synced.state, sessionConfig);
|
|
119
128
|
}
|
|
120
129
|
} catch {
|
|
121
|
-
state = mod.buildCompactPressureState(rawState,
|
|
130
|
+
state = mod.buildCompactPressureState(rawState, sessionConfig);
|
|
122
131
|
}
|
|
123
132
|
const thresholds = mod.buildCompactThresholds(config);
|
|
124
133
|
|