@ngockhoale/ukit 2.4.2 → 2.5.0
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 +57 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +51 -112
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +430 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +183 -3
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
- package/templates/AGENTS.md +8 -0
- package/templates/PROJECT_IMPORTANT.md +9 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TASK-028: the one async lock utility for hook processes. Every hook lock used to
|
|
3
|
+
// carry its own inline mkdir/owner/backoff copy (auto-allow-bash, verification-guard,
|
|
4
|
+
// token-utils withFileLock, src/core/fileOps) with slightly divergent policies; this
|
|
5
|
+
// module is the shared protocol-compatible implementation for hook-side callers:
|
|
6
|
+
//
|
|
7
|
+
// - Acquisition is an atomic `mkdir` of `<target>.lock`; the owner is stamped into
|
|
8
|
+
// `owner` (pid + random token + ts) so stale reclaim can be validated instead of
|
|
9
|
+
// guessed. Same lock path and owner format as token-utils withFileLock, so hook
|
|
10
|
+
// processes and runtime/CLI processes serialize against each other.
|
|
11
|
+
// - Reclaim happens only after the stale threshold AND only when the recorded holder
|
|
12
|
+
// is provably gone (dead pid; a same-pid dir must also have no live in-process
|
|
13
|
+
// holder). A stale-LOOKING lock owned by a live process is never stolen.
|
|
14
|
+
// - Waiting polls with a jittered ASYNC backoff — never a blocking shared-memory
|
|
15
|
+
// wait, so the unref'd hook self-deadline timer keeps firing while a contended
|
|
16
|
+
// lock is being polled.
|
|
17
|
+
// - The acquisition budget is `deadlineMs`: callers derive it from the remaining
|
|
18
|
+
// hook deadline minus a cleanup reserve with lockBudgetMs (capped at a short
|
|
19
|
+
// slice), so a contended lock can never hold a hook near its own deadline.
|
|
20
|
+
// - Timeout/abort NEVER run the callback: the outcome is a typed envelope
|
|
21
|
+
// `{ ok: false, reason: 'busy' | 'aborted' }` and the caller picks its policy.
|
|
22
|
+
// Sensitive mutations (permission settings) stay fail-closed on lock failure;
|
|
23
|
+
// never write state "just unlocked" because a budget expired.
|
|
24
|
+
// - An acquisition only counts once the owner stamp is durably on disk. A failed
|
|
25
|
+
// stamp undoes the mkdir and fails closed (no callback) instead of running under an
|
|
26
|
+
// ownerless lock: a contender reads a missing owner as reclaimable after `staleMs`,
|
|
27
|
+
// so an unstamped live holder could have its lock deleted mid-critical-section.
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
import fs from 'node:fs/promises';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
// Protocol-compatible with token-utils withFileLock.
|
|
33
|
+
export const LOCK_STALE_MS = 10_000;
|
|
34
|
+
// Reserve of the hook deadline kept free for release/cleanup I/O after the wait.
|
|
35
|
+
export const LOCK_RESERVE_MS = 500;
|
|
36
|
+
// Short configured acquisition slice: a contended lock gives up here, far inside a
|
|
37
|
+
// typical hook deadline, instead of riding the deadline out.
|
|
38
|
+
export const LOCK_MAX_SLICE_MS = 1_200;
|
|
39
|
+
|
|
40
|
+
const BACKOFF_MIN_MS = 3;
|
|
41
|
+
const BACKOFF_SPREAD_MS = 9;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive the lock acquisition budget from a hook's wall-clock deadline: the time
|
|
45
|
+
* remaining before that deadline, minus a reserve for release/cleanup, capped at the
|
|
46
|
+
* short configured slice (maxWaitMs). Always >= 0 — a budget smaller than the reserve
|
|
47
|
+
* budgets zero (single attempt, no waiting).
|
|
48
|
+
* @param {{ hookDeadlineMs?: number, startedAt?: number, reserveMs?: number, maxWaitMs?: number }} options
|
|
49
|
+
* @returns {number} budget in milliseconds
|
|
50
|
+
*/
|
|
51
|
+
export function lockBudgetMs({
|
|
52
|
+
hookDeadlineMs,
|
|
53
|
+
startedAt = Date.now(),
|
|
54
|
+
reserveMs = LOCK_RESERVE_MS,
|
|
55
|
+
maxWaitMs = LOCK_MAX_SLICE_MS,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const slice = Number.isFinite(maxWaitMs) && maxWaitMs >= 0 ? maxWaitMs : LOCK_MAX_SLICE_MS;
|
|
58
|
+
if (!Number.isFinite(hookDeadlineMs) || hookDeadlineMs < 0) {
|
|
59
|
+
return slice;
|
|
60
|
+
}
|
|
61
|
+
const elapsed = Math.max(0, Date.now() - startedAt);
|
|
62
|
+
const reserve = Number.isFinite(reserveMs) && reserveMs >= 0 ? reserveMs : LOCK_RESERVE_MS;
|
|
63
|
+
const remaining = Math.max(0, hookDeadlineMs - elapsed - reserve);
|
|
64
|
+
return Math.min(remaining, slice);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lockBackoffDelayMs() {
|
|
68
|
+
return BACKOFF_MIN_MS + Math.floor(Math.random() * BACKOFF_SPREAD_MS);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Resolves true when the sleep elapsed fully, false when the signal aborted first —
|
|
72
|
+
// an abort cancels the pending timer immediately instead of waiting out the slice.
|
|
73
|
+
function sleepWithAbort(ms, signal) {
|
|
74
|
+
if (signal?.aborted) return Promise.resolve(false);
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let timer = null;
|
|
77
|
+
const onAbort = () => {
|
|
78
|
+
if (timer) clearTimeout(timer);
|
|
79
|
+
resolve(false);
|
|
80
|
+
};
|
|
81
|
+
timer = setTimeout(() => {
|
|
82
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
83
|
+
resolve(true);
|
|
84
|
+
}, ms);
|
|
85
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isPidAlive(pid) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// EPERM: the process exists but belongs to another user — still alive.
|
|
95
|
+
return error?.code === 'EPERM';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readLockOwner(lockPath) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = JSON.parse(await fs.readFile(path.join(lockPath, 'owner'), 'utf8'));
|
|
102
|
+
const pid = Number(raw?.pid);
|
|
103
|
+
return Number.isInteger(pid) && pid > 0
|
|
104
|
+
? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
|
|
105
|
+
: null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const RECLAIM_FILE = 'reclaim';
|
|
112
|
+
|
|
113
|
+
function sameLockOwner(left, right) {
|
|
114
|
+
if (!left || !right) return left === right;
|
|
115
|
+
return left.pid === right.pid && left.token === right.token;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readReclaimOwner(lockPath) {
|
|
119
|
+
try {
|
|
120
|
+
const raw = JSON.parse(await fs.readFile(path.join(lockPath, RECLAIM_FILE), 'utf8'));
|
|
121
|
+
const pid = Number(raw?.pid);
|
|
122
|
+
return Number.isInteger(pid) && pid > 0
|
|
123
|
+
? { pid, token: typeof raw?.token === 'string' ? raw.token : null }
|
|
124
|
+
: null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Stale observers must claim the existing lock before removing it. The claim lives
|
|
131
|
+
// inside the old generation, so a second observer cannot remove that generation while
|
|
132
|
+
// the first observer is between its owner check and rm(). This closes the TOCTOU race
|
|
133
|
+
// where a delayed stale observer deleted a freshly acquired successor lock.
|
|
134
|
+
async function claimReclaim(lockPath, ownerToken, staleMs) {
|
|
135
|
+
const reclaimPath = path.join(lockPath, RECLAIM_FILE);
|
|
136
|
+
try {
|
|
137
|
+
const handle = await fs.open(reclaimPath, 'wx');
|
|
138
|
+
try {
|
|
139
|
+
await handle.writeFile(
|
|
140
|
+
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
|
|
141
|
+
'utf8',
|
|
142
|
+
);
|
|
143
|
+
} finally {
|
|
144
|
+
await handle.close();
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error?.code !== 'EEXIST') return false;
|
|
149
|
+
|
|
150
|
+
// A killed reclaimer can leave its claim behind. Only clear an old claim whose
|
|
151
|
+
// recorded process is gone; a live claim remains the exclusive reclaim authority.
|
|
152
|
+
try {
|
|
153
|
+
const stat = await fs.stat(reclaimPath);
|
|
154
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
155
|
+
const claim = await readReclaimOwner(lockPath);
|
|
156
|
+
if (!claim || !isPidAlive(claim.pid)) {
|
|
157
|
+
await fs.rm(reclaimPath, { force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// The lock or claim vanished; the caller's normal retry handles it.
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function releaseReclaimClaim(lockPath, ownerToken) {
|
|
168
|
+
try {
|
|
169
|
+
const claim = await readReclaimOwner(lockPath);
|
|
170
|
+
if (claim?.token === ownerToken) {
|
|
171
|
+
await fs.rm(path.join(lockPath, RECLAIM_FILE), { force: true });
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// best-effort claim release; the stale-claim path handles an interrupted cleanup
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function quarantineReclaim(lockPath, owner, ownerToken) {
|
|
179
|
+
const quarantinePath = `${lockPath}.reclaim-${ownerToken}`;
|
|
180
|
+
try {
|
|
181
|
+
// The claim serializes stale observers. Re-read before the atomic rename so an
|
|
182
|
+
// observer never detaches a generation different from the one it validated.
|
|
183
|
+
const current = await readLockOwner(lockPath);
|
|
184
|
+
if (!sameLockOwner(current, owner)) {
|
|
185
|
+
await releaseReclaimClaim(lockPath, ownerToken);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
// Rename is atomic within the lock's parent directory: the old generation is
|
|
189
|
+
// detached as one filesystem operation, so a successor created at lockPath can
|
|
190
|
+
// never be reached by cleanup of this quarantined generation.
|
|
191
|
+
await fs.rename(lockPath, quarantinePath);
|
|
192
|
+
await fs.rm(quarantinePath, { recursive: true, force: true });
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
// Never recursively remove lockPath here. If rename lost a race or failed, the
|
|
196
|
+
// original path belongs to whoever currently holds it; a later poll can retry.
|
|
197
|
+
try {
|
|
198
|
+
await fs.rm(quarantinePath, { recursive: true, force: true });
|
|
199
|
+
} catch {
|
|
200
|
+
// best-effort cleanup of only this acquisition's quarantine path
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// In-process holder registry: same-pid holders are parallel async flows whose liveness
|
|
207
|
+
// a pid probe cannot prove, so the module tracks them itself (same as withFileLock).
|
|
208
|
+
const inProcessLockHolders = new Map();
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Serialize a mutation of a shared state file across hook processes and concurrent
|
|
212
|
+
* async flows. Protocol-compatible with token-utils withFileLock (`<file>.lock`
|
|
213
|
+
* directory + `owner` file). Unlike withFileLock this NEVER runs the callback
|
|
214
|
+
* unlocked: an expired budget or an aborted signal resolves with a typed outcome and
|
|
215
|
+
* the caller decides the fail-safe policy.
|
|
216
|
+
* @param {string} filePath - state file the mutation targets (lock lives beside it)
|
|
217
|
+
* @param {{
|
|
218
|
+
* signal?: AbortSignal,
|
|
219
|
+
* deadlineMs?: number,
|
|
220
|
+
* staleMs?: number,
|
|
221
|
+
* }} [options]
|
|
222
|
+
* @param {() => Promise<*>} fn - critical section
|
|
223
|
+
* @returns {Promise<{ ok: true, value: * } | { ok: false, reason: 'busy' | 'aborted', waitedMs: number }>}
|
|
224
|
+
* fn result wrapped on success, or a typed busy/abort outcome (fn never ran)
|
|
225
|
+
*/
|
|
226
|
+
export async function withAsyncLock(filePath, { signal, deadlineMs = LOCK_MAX_SLICE_MS, staleMs = LOCK_STALE_MS } = {}, fn) {
|
|
227
|
+
if (typeof fn !== 'function') {
|
|
228
|
+
throw new TypeError('withAsyncLock(filePath, options, fn) requires a callback');
|
|
229
|
+
}
|
|
230
|
+
if (signal?.aborted) {
|
|
231
|
+
return { ok: false, reason: 'aborted', waitedMs: 0 };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const lockPath = `${filePath}.lock`;
|
|
235
|
+
const budget = Number.isFinite(deadlineMs) && deadlineMs >= 0 ? deadlineMs : LOCK_MAX_SLICE_MS;
|
|
236
|
+
const stale = Number.isFinite(staleMs) && staleMs >= 0 ? staleMs : LOCK_STALE_MS;
|
|
237
|
+
const startedAt = Date.now();
|
|
238
|
+
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
239
|
+
// True only when THIS acquisition holds a lock it can prove it owns: the atomic
|
|
240
|
+
// mkdir succeeded AND the owner stamp is on disk. Release is verified against it.
|
|
241
|
+
let owned = false;
|
|
242
|
+
|
|
243
|
+
while (true) {
|
|
244
|
+
try {
|
|
245
|
+
// The lock parent must exist before the atomic acquire — a first-ever run in a
|
|
246
|
+
// fresh project would otherwise fail mkdir with ENOENT.
|
|
247
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
248
|
+
await fs.mkdir(lockPath); // atomic acquire — EEXIST means another holder exists
|
|
249
|
+
inProcessLockHolders.set(lockPath, ownerToken);
|
|
250
|
+
try {
|
|
251
|
+
await fs.writeFile(
|
|
252
|
+
path.join(lockPath, 'owner'),
|
|
253
|
+
`${JSON.stringify({ pid: process.pid, token: ownerToken, ts: Date.now() })}\n`,
|
|
254
|
+
'utf8',
|
|
255
|
+
);
|
|
256
|
+
} catch {
|
|
257
|
+
// Fail closed: an unstamped lock is not ours to enter. Running the callback
|
|
258
|
+
// anyway would leave an ownerless directory, and every other process reads a
|
|
259
|
+
// missing owner as reclaimable once `staleMs` passes — deleting a LIVE holder's
|
|
260
|
+
// lock mid-critical-section and letting two mutations interleave. Undo the
|
|
261
|
+
// acquire and report the typed busy outcome callers already handle.
|
|
262
|
+
try {
|
|
263
|
+
const current = await readLockOwner(lockPath);
|
|
264
|
+
// Never remove a directory some other holder has since stamped (only
|
|
265
|
+
// possible if this one was reclaimed in the window above).
|
|
266
|
+
if (!current || current.token === ownerToken) {
|
|
267
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// best-effort undo; a leftover dir is unheld and reclaimed by the next waiter
|
|
271
|
+
}
|
|
272
|
+
if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
|
|
273
|
+
return { ok: false, reason: 'busy', waitedMs: Date.now() - startedAt };
|
|
274
|
+
}
|
|
275
|
+
owned = true;
|
|
276
|
+
break;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Someone holds the lock. Reclaim it only after the stale threshold AND only when
|
|
282
|
+
// the holder is provably gone — stealing a live holder reintroduces the exact
|
|
283
|
+
// interleaved-write race this lock exists to prevent.
|
|
284
|
+
let reclaimed = false;
|
|
285
|
+
try {
|
|
286
|
+
const stat = await fs.stat(lockPath);
|
|
287
|
+
if (Date.now() - stat.mtimeMs > stale) {
|
|
288
|
+
const owner = await readLockOwner(lockPath);
|
|
289
|
+
const liveInProcess = inProcessLockHolders.has(lockPath);
|
|
290
|
+
const reclaimable = !owner || owner.pid === process.pid
|
|
291
|
+
? !liveInProcess
|
|
292
|
+
: !isPidAlive(owner.pid);
|
|
293
|
+
if (reclaimable && await claimReclaim(lockPath, ownerToken, stale)) {
|
|
294
|
+
// Detach and clean only the generation that was validated. The atomic rename
|
|
295
|
+
// makes this safe even when another process acquires lockPath immediately
|
|
296
|
+
// after the stale generation is removed.
|
|
297
|
+
reclaimed = await quarantineReclaim(lockPath, owner, ownerToken);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
reclaimed = false; // lock vanished between mkdir and stat — plain retry handles it
|
|
302
|
+
}
|
|
303
|
+
if (reclaimed) continue;
|
|
304
|
+
|
|
305
|
+
const waitedMs = Date.now() - startedAt;
|
|
306
|
+
if (waitedMs >= budget) {
|
|
307
|
+
// Fail closed: the caller's policy decides what a busy lock means. The callback
|
|
308
|
+
// is NOT invoked unlocked.
|
|
309
|
+
return { ok: false, reason: 'busy', waitedMs };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Jittered async backoff, clamped so a sleep never overshoots the budget.
|
|
313
|
+
const remaining = budget - (Date.now() - startedAt);
|
|
314
|
+
const sleptFully = await sleepWithAbort(Math.min(lockBackoffDelayMs(), Math.max(1, remaining)), signal);
|
|
315
|
+
if (!sleptFully) {
|
|
316
|
+
return { ok: false, reason: 'aborted', waitedMs: Date.now() - startedAt };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
return { ok: true, value: await fn() };
|
|
322
|
+
} finally {
|
|
323
|
+
// `owned` is only true when the mkdir AND the owner stamp both succeeded, so a
|
|
324
|
+
// failed stamp never reaches the critical section and never needs releasing here.
|
|
325
|
+
if (owned) {
|
|
326
|
+
try {
|
|
327
|
+
// Remove the lock only if THIS acquisition still owns it: after a stale
|
|
328
|
+
// reclaim another holder may already own the dir, and deleting it would
|
|
329
|
+
// unlock their critical section for a third waiter.
|
|
330
|
+
const current = await readLockOwner(lockPath);
|
|
331
|
+
if (current && current.token === ownerToken) {
|
|
332
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// best-effort release; a leaked dir is reclaimed by the next waiter
|
|
336
|
+
}
|
|
337
|
+
if (inProcessLockHolders.get(lockPath) === ownerToken) inProcessLockHolders.delete(lockPath);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
4
5
|
import {
|
|
@@ -11,6 +12,9 @@ import {
|
|
|
11
12
|
withFileLock,
|
|
12
13
|
writeJson,
|
|
13
14
|
} from './token-utils.mjs';
|
|
15
|
+
// Sibling runtime module: installs copy the whole runtime dir, so the negotiated-capacity
|
|
16
|
+
// resolver is always present next to this file.
|
|
17
|
+
import { resolveContextCapTokens } from './context-capacity.mjs';
|
|
14
18
|
|
|
15
19
|
const DEFAULT_MAX_PROMPT_ENTRIES = 12;
|
|
16
20
|
const DEFAULT_MAX_OUTPUT_ENTRIES = 12;
|
|
@@ -444,30 +448,64 @@ function computeEstimatedTotalTokens({
|
|
|
444
448
|
return baselineTokens + estimatedContextTokens + windowTokens + sessionExcess;
|
|
445
449
|
}
|
|
446
450
|
|
|
451
|
+
// Live capacity evidence published by context-window-guard.sh — the only hook that sees the
|
|
452
|
+
// real serving model. This record is how negotiated capacity reaches the shared
|
|
453
|
+
// threshold/gate path; without it a verified 200k route would still compact at 240k.
|
|
454
|
+
export const CAPACITY_RECORD_SEGMENTS = ['.ukit', 'storage', 'cache', 'context-capacity.json'];
|
|
455
|
+
// Shipped tuning as a ratio of the absolute cap (150_000 soft of a 500_000 cap), so a
|
|
456
|
+
// smaller verified window moves the advisory phases down with the cap they precede.
|
|
457
|
+
export const SOFT_TO_CAP_RATIO = 0.3;
|
|
458
|
+
|
|
459
|
+
export function readContextCapacityRecord(projectRoot) {
|
|
460
|
+
const root = projectRoot
|
|
461
|
+
|| process.env.CLAUDE_PROJECT_DIR
|
|
462
|
+
|| process.env.PROJECT_ROOT
|
|
463
|
+
|| process.cwd();
|
|
464
|
+
try {
|
|
465
|
+
const parsed = JSON.parse(fsSync.readFileSync(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
|
|
466
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Negotiated capacity: env override > config override > the model the guard last observed >
|
|
473
|
+
// conservative fallback. Exported so the source twin and diagnostics read the same number.
|
|
474
|
+
export function resolveCompactCapacity(config = {}) {
|
|
475
|
+
const record = readContextCapacityRecord(config?.projectRoot);
|
|
476
|
+
return resolveContextCapTokens({
|
|
477
|
+
env: process.env,
|
|
478
|
+
config,
|
|
479
|
+
modelMetadata: { model: record?.model },
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
447
483
|
export function buildCompactThresholds(config = {}) {
|
|
448
|
-
const
|
|
484
|
+
const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
|
|
485
|
+
const shippedHardCap = positiveInteger(config?.compact?.hardCapTokens, 500_000);
|
|
486
|
+
// Only tighten once a record exists: an install with no evidence yet (and every caller
|
|
487
|
+
// that passes no project root) keeps the shipped tuning unchanged.
|
|
488
|
+
const record = readContextCapacityRecord(config?.projectRoot);
|
|
489
|
+
const negotiated = record ? resolveCompactCapacity(config) : null;
|
|
490
|
+
const hardCapTokens = negotiated
|
|
491
|
+
? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
|
|
492
|
+
: shippedHardCap;
|
|
493
|
+
// An explicit operator tokenThreshold is honored as-is; otherwise the advisory phase is
|
|
494
|
+
// derived from the cap it precedes, so a soft phase can never sit above the cap (150k of
|
|
495
|
+
// advisory over a negotiated 100k cap would simply never fire).
|
|
496
|
+
const softThreshold = explicitSoftThreshold > 0
|
|
497
|
+
? Math.max(1, explicitSoftThreshold)
|
|
498
|
+
: Math.max(1, Math.round(hardCapTokens * SOFT_TO_CAP_RATIO));
|
|
449
499
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
450
500
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
451
|
-
// Deliberately NOT coupled to hardThreshold: this is an absolute ceiling, so raising
|
|
452
|
-
// the advisory tokenThreshold must never silently raise the cap along with it.
|
|
453
|
-
//
|
|
454
|
-
// Must stay BELOW the model's real context window or the cap is unreachable: the API
|
|
455
|
-
// rejects the request with "input exceeds the context window" long before an estimate
|
|
456
|
-
// climbing toward a higher number ever trips this. 500_000 is half of a 1M window, which
|
|
457
|
-
// leaves ample headroom for the response plus the estimator's own undercount. On any
|
|
458
|
-
// smaller model this default sits ABOVE the window, so the gate can never fire and is
|
|
459
|
-
// dead code in exactly the situation it exists to prevent — set compact.hardCapTokens to
|
|
460
|
-
// 100_000 (200k model) or 128_000 (256k model) for those. Both
|
|
461
|
-
// env.CLAUDE_CODE_AUTO_COMPACT_WINDOW and omp's compaction.thresholdTokens are rendered
|
|
462
|
-
// from this number at install time (70% of it), so the client auto-compacts before the
|
|
463
|
-
// gate blocks tools without anyone hand-syncing a second value.
|
|
464
|
-
const hardCapTokens = positiveInteger(config?.compact?.hardCapTokens, 500_000);
|
|
465
501
|
|
|
466
502
|
return {
|
|
467
503
|
softThreshold,
|
|
468
504
|
hardThreshold,
|
|
469
505
|
baselineTokens,
|
|
470
506
|
hardCapTokens,
|
|
507
|
+
capacitySource: negotiated?.capacity?.source ?? 'shipped',
|
|
508
|
+
capacityTokens: negotiated?.capacity?.tokens ?? null,
|
|
471
509
|
};
|
|
472
510
|
}
|
|
473
511
|
|
|
@@ -1175,14 +1213,21 @@ export async function readCompactPressureState(projectRoot, config = {}) {
|
|
|
1175
1213
|
// their read-modify-write cycles and silently drop each other's sessionTokens.
|
|
1176
1214
|
// Mutations are session-scoped: only the calling session's record is rewritten;
|
|
1177
1215
|
// sibling session records pass through untouched.
|
|
1216
|
+
// Every async entry point already knows the project root; carry it into the config so the
|
|
1217
|
+
// negotiated-capacity lookup reads THIS project's record rather than guessing from cwd.
|
|
1218
|
+
function scopeCapacityConfig(config, projectRoot) {
|
|
1219
|
+
return projectRoot ? { ...config, projectRoot } : config;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1178
1222
|
async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
1179
1223
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1224
|
+
const scopedConfig = scopeCapacityConfig(config, projectRoot);
|
|
1180
1225
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1181
1226
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1182
1227
|
const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1183
|
-
const current = buildCompactPressureState(record,
|
|
1228
|
+
const current = buildCompactPressureState(record, scopedConfig);
|
|
1184
1229
|
const next = mutator(current);
|
|
1185
|
-
const normalized = buildCompactPressureState(next,
|
|
1230
|
+
const normalized = buildCompactPressureState(next, scopedConfig);
|
|
1186
1231
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1187
1232
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1188
1233
|
return normalized;
|
|
@@ -1191,10 +1236,11 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
|
|
|
1191
1236
|
|
|
1192
1237
|
export async function writeCompactPressureState(projectRoot, state, config = {}) {
|
|
1193
1238
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
1239
|
+
const scopedConfig = scopeCapacityConfig(config, projectRoot);
|
|
1194
1240
|
return withFileLock(runtimePaths.compactPressurePath, async () => {
|
|
1195
1241
|
const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
|
|
1196
1242
|
const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
|
|
1197
|
-
const normalized = buildCompactPressureState(state,
|
|
1243
|
+
const normalized = buildCompactPressureState(state, scopedConfig);
|
|
1198
1244
|
sessions[id] = { ...normalized, updatedAt: Date.now() };
|
|
1199
1245
|
await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
|
|
1200
1246
|
return normalized;
|
|
@@ -1209,24 +1255,24 @@ function pressureSessionConfig(payload = {}, config = {}) {
|
|
|
1209
1255
|
export async function updateCompactPressureFromPrompt(projectRoot, payload, config = {}) {
|
|
1210
1256
|
return mutateCompactPressureState(
|
|
1211
1257
|
projectRoot,
|
|
1212
|
-
(current) => registerPromptPressure(current, payload, config),
|
|
1213
|
-
pressureSessionConfig(payload, config),
|
|
1258
|
+
(current) => registerPromptPressure(current, payload, scopeCapacityConfig(config, projectRoot)),
|
|
1259
|
+
pressureSessionConfig(payload, scopeCapacityConfig(config, projectRoot)),
|
|
1214
1260
|
);
|
|
1215
1261
|
}
|
|
1216
1262
|
|
|
1217
1263
|
export async function updateCompactPressureFromOutput(projectRoot, payload, config = {}) {
|
|
1218
1264
|
return mutateCompactPressureState(
|
|
1219
1265
|
projectRoot,
|
|
1220
|
-
(current) => registerOutputPressure(current, payload, config),
|
|
1221
|
-
pressureSessionConfig(payload, config),
|
|
1266
|
+
(current) => registerOutputPressure(current, payload, scopeCapacityConfig(config, projectRoot)),
|
|
1267
|
+
pressureSessionConfig(payload, scopeCapacityConfig(config, projectRoot)),
|
|
1222
1268
|
);
|
|
1223
1269
|
}
|
|
1224
1270
|
|
|
1225
1271
|
export async function writeThresholdCompactPlan(projectRoot, plan, config = {}) {
|
|
1226
1272
|
return mutateCompactPressureState(
|
|
1227
1273
|
projectRoot,
|
|
1228
|
-
(current) => registerThresholdCompactPlan(current, plan, config),
|
|
1229
|
-
pressureSessionConfig(plan, config),
|
|
1274
|
+
(current) => registerThresholdCompactPlan(current, plan, scopeCapacityConfig(config, projectRoot)),
|
|
1275
|
+
pressureSessionConfig(plan, scopeCapacityConfig(config, projectRoot)),
|
|
1230
1276
|
);
|
|
1231
1277
|
}
|
|
1232
1278
|
|
|
@@ -1288,6 +1334,9 @@ async function runCli() {
|
|
|
1288
1334
|
// this session's pressure history.
|
|
1289
1335
|
const sessionConfig = {
|
|
1290
1336
|
...config,
|
|
1337
|
+
// Thread the resolved project root so the negotiated-capacity lookup reads THIS
|
|
1338
|
+
// project's record instead of guessing from cwd.
|
|
1339
|
+
projectRoot,
|
|
1291
1340
|
sessionId: typeof payload.session_id === 'string' && payload.session_id.trim()
|
|
1292
1341
|
? payload.session_id.trim()
|
|
1293
1342
|
: undefined,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Negotiated context capacity (H22) — UKit must never assume one fixed model window.
|
|
2
|
+
//
|
|
3
|
+
// The advisory context guard and compact thresholds used to hard-code a 500k cap. That
|
|
4
|
+
// number was tuned for a 1M-class window: on a smaller model the cap sat ABOVE the real
|
|
5
|
+
// window, so the gate was dead code in exactly the situation it exists to prevent. This
|
|
6
|
+
// module instead NEGOTIATES the capacity from the best evidence available, in a fixed,
|
|
7
|
+
// documented precedence, and reports where the number came from so diagnostics can show
|
|
8
|
+
// it:
|
|
9
|
+
//
|
|
10
|
+
// 1. env.UKIT_CONTEXT_WINDOW_TOKENS — operator override for this session/route.
|
|
11
|
+
// 2. config.compact.contextWindowTokens — operator override in .ukit/storage/config.json.
|
|
12
|
+
// 3. verified model metadata — exact-prefix lookup in the verified window table.
|
|
13
|
+
// 4. conservative default — smallest verified window, source `default`.
|
|
14
|
+
//
|
|
15
|
+
// Rules this module enforces:
|
|
16
|
+
// - Never infer capacity from a provider name alone. A model id must match a verified
|
|
17
|
+
// table entry by prefix (so dated suffixes like `-20250929` still match); anything
|
|
18
|
+
// else — including an explicit `verified: false` — uses the conservative fallback.
|
|
19
|
+
// - Malformed values skip ONE source and fall through to the next; they never poison
|
|
20
|
+
// the resolution and never throw.
|
|
21
|
+
// - Verified capacities are clamped to [MIN_CAPACITY_TOKENS, MAX_CAPACITY_TOKENS] so a
|
|
22
|
+
// typo cannot produce a zero-token session or an unbounded one.
|
|
23
|
+
|
|
24
|
+
export const CAPACITY_ENV_VAR = 'UKIT_CONTEXT_WINDOW_TOKENS';
|
|
25
|
+
// Unverified fallback CAP: what the guard assumes when nothing is known about the route.
|
|
26
|
+
// Deliberately BELOW every verified window (smallest is gpt-4o, 128k), so an unknown route
|
|
27
|
+
// is warned before its provider limit, not after — the old 500k sat above a 128k/200k
|
|
28
|
+
// window and gave such a route no warning at all until the API rejected the request. A
|
|
29
|
+
// known bigger-but-unlisted route is raised via UKIT_CONTEXT_WINDOW_TOKENS.
|
|
30
|
+
export const DEFAULT_CAPACITY_TOKENS = 100_000;
|
|
31
|
+
// The shipped operator ceiling (compact.hardCapTokens) — only ever an UPPER bound; negotiated
|
|
32
|
+
// capacity can tighten it but never raise it.
|
|
33
|
+
export const SHIPPED_HARD_CAP_TOKENS = 500_000;
|
|
34
|
+
// A verified window is only ever usable as an estimate: the response itself plus the
|
|
35
|
+
// char/token estimator's own undercount must fit too, so only half the window becomes
|
|
36
|
+
// the advisory cap (500k cap from a 1M window — the original tuning, made explicit).
|
|
37
|
+
export const CAPACITY_TO_CAP_RATIO = 0.5;
|
|
38
|
+
export const MIN_CAPACITY_TOKENS = 1_000;
|
|
39
|
+
export const MAX_CAPACITY_TOKENS = 2_000_000;
|
|
40
|
+
|
|
41
|
+
// Verified model context windows. Longest-prefix match on the lowercased model id; a
|
|
42
|
+
// bare provider prefix ("gpt", "claude", "gemini") is deliberately NOT a table entry.
|
|
43
|
+
const VERIFIED_MODEL_CONTEXT_WINDOWS = [
|
|
44
|
+
['claude-opus-4-1', 200_000],
|
|
45
|
+
['claude-opus-4', 200_000],
|
|
46
|
+
['claude-sonnet-4-5', 200_000],
|
|
47
|
+
['claude-sonnet-4', 200_000],
|
|
48
|
+
['claude-haiku-4-5', 200_000],
|
|
49
|
+
['claude-haiku-4', 200_000],
|
|
50
|
+
['claude-3-7-sonnet', 200_000],
|
|
51
|
+
['claude-3-5-sonnet', 200_000],
|
|
52
|
+
['claude-3-5-haiku', 200_000],
|
|
53
|
+
['claude-3-opus', 200_000],
|
|
54
|
+
['claude-3-haiku', 200_000],
|
|
55
|
+
['gpt-4.1', 1_000_000],
|
|
56
|
+
['gpt-4o', 128_000],
|
|
57
|
+
['o4-mini', 200_000],
|
|
58
|
+
['o3', 200_000],
|
|
59
|
+
['gemini-2.5-pro', 1_000_000],
|
|
60
|
+
['gemini-2.5-flash', 1_000_000],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// Env vars are always strings: only strict digit strings count as evidence.
|
|
64
|
+
// Anything else (floats, scientific notation, signed/garbage strings) returns null so
|
|
65
|
+
// the resolver falls through to the next source.
|
|
66
|
+
function parseEnvTokenCount(value) {
|
|
67
|
+
if (typeof value !== 'string') return null;
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
70
|
+
const parsed = Number(trimmed);
|
|
71
|
+
return parsed > 0 ? parsed : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Config values are typed JSON: mirror compact-threshold.mjs's number-only contract so a
|
|
75
|
+
// string that happens to look numeric can never silently become a real ceiling.
|
|
76
|
+
function parseConfigTokenCount(value) {
|
|
77
|
+
return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0
|
|
78
|
+
? value
|
|
79
|
+
: null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clampCapacity(tokens) {
|
|
83
|
+
return Math.min(MAX_CAPACITY_TOKENS, Math.max(MIN_CAPACITY_TOKENS, tokens));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function lookupVerifiedModelWindow(model) {
|
|
87
|
+
if (typeof model !== 'string') return null;
|
|
88
|
+
const normalized = model.trim().toLowerCase();
|
|
89
|
+
if (!normalized) return null;
|
|
90
|
+
let best = null;
|
|
91
|
+
for (const entry of VERIFIED_MODEL_CONTEXT_WINDOWS) {
|
|
92
|
+
const [prefix, windowTokens] = entry;
|
|
93
|
+
if (normalized.startsWith(prefix) && (!best || prefix.length > best[0].length)) {
|
|
94
|
+
best = entry;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return best ? best[1] : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolve the model context capacity from the best available evidence.
|
|
101
|
+
// Returns `{tokens, source, confidence}`:
|
|
102
|
+
// source — 'env' | 'config' | 'model' | 'default'
|
|
103
|
+
// confidence — 'verified' for the first three, 'fallback' for the default.
|
|
104
|
+
export function resolveContextCapacity({ env = {}, config = {}, modelMetadata = {} } = {}) {
|
|
105
|
+
const fromEnv = parseEnvTokenCount(env?.[CAPACITY_ENV_VAR]);
|
|
106
|
+
if (fromEnv !== null) {
|
|
107
|
+
return { tokens: clampCapacity(fromEnv), source: 'env', confidence: 'verified' };
|
|
108
|
+
}
|
|
109
|
+
const fromConfig = parseConfigTokenCount(config?.compact?.contextWindowTokens);
|
|
110
|
+
if (fromConfig !== null) {
|
|
111
|
+
return { tokens: clampCapacity(fromConfig), source: 'config', confidence: 'verified' };
|
|
112
|
+
}
|
|
113
|
+
// An explicit `verified: false` on the route metadata means the caller already knows
|
|
114
|
+
// this route is unverified — do not even try the table.
|
|
115
|
+
if (modelMetadata?.verified === false) {
|
|
116
|
+
return { tokens: DEFAULT_CAPACITY_TOKENS, source: 'default', confidence: 'fallback' };
|
|
117
|
+
}
|
|
118
|
+
const fromModel = lookupVerifiedModelWindow(modelMetadata?.model);
|
|
119
|
+
if (fromModel !== null) {
|
|
120
|
+
return { tokens: fromModel, source: 'model', confidence: 'verified' };
|
|
121
|
+
}
|
|
122
|
+
return { tokens: DEFAULT_CAPACITY_TOKENS, source: 'default', confidence: 'fallback' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Compose the advisory cap for the context guard from the negotiated capacity plus the
|
|
126
|
+
// operator's absolute ceiling (compact.hardCapTokens). The derived cap never exceeds the
|
|
127
|
+
// operator ceiling and never lands at zero.
|
|
128
|
+
//
|
|
129
|
+
// An explicit compact.hardCapTokens IS a capacity declaration (an operator who wrote
|
|
130
|
+
// 120_000 for their 200k model knows their window), so the conservative fallback only
|
|
131
|
+
// applies when NOTHING is declared — no env, no contextWindowTokens, no hardCapTokens and
|
|
132
|
+
// no verified model. Anything verified is halved (CAPACITY_TO_CAP_RATIO).
|
|
133
|
+
export function resolveContextCapTokens({ env, config, modelMetadata } = {}) {
|
|
134
|
+
const capacity = resolveContextCapacity({ env, config, modelMetadata });
|
|
135
|
+
const operatorHardCap = parseConfigTokenCount(config?.compact?.hardCapTokens);
|
|
136
|
+
const ceiling = operatorHardCap ?? SHIPPED_HARD_CAP_TOKENS;
|
|
137
|
+
const derived = capacity.source === 'default'
|
|
138
|
+
? (operatorHardCap ?? capacity.tokens)
|
|
139
|
+
: Math.round(capacity.tokens * CAPACITY_TO_CAP_RATIO);
|
|
140
|
+
return {
|
|
141
|
+
capacity,
|
|
142
|
+
capTokens: Math.max(1, Math.min(ceiling, derived)),
|
|
143
|
+
};
|
|
144
|
+
}
|