@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,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
|
|
|
@@ -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
|
|