agent-sanitizer 2.50.0 → 2.52.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/README.md +1 -0
- package/claude-hooks/lib/control-plane.mjs +2 -0
- package/claude-hooks/lib/hook-timing.mjs +285 -36
- package/claude-hooks/sanitize-output.mjs +55 -24
- package/package.json +5 -1
- package/types/claude-hooks/lib/hook-timing.d.mts +79 -40
- package/types/claude-hooks/sanitize-output.d.mts +3 -0
package/README.md
CHANGED
|
@@ -289,6 +289,7 @@ singleton, and two copies in one bundle double-fire the inlined CLIs.
|
|
|
289
289
|
| `claude-hooks/lib/reveal` | The Layer-2 sidecar that lets the model re-read what the HTML splice removed |
|
|
290
290
|
| `claude-hooks/lib/secret-annotate` | The cheap deterministic Layer-4 pre-gate checks around the daemon call |
|
|
291
291
|
| `claude-hooks/lib/trace` | The opt-in structured trace channel every layer announces itself on |
|
|
292
|
+
| `claude-hooks/lib/hook-timing` | The shared slow-hook budget, its measured windows and the notice they compose |
|
|
292
293
|
|
|
293
294
|
Only `plugin-hooks` itself is unexported under its own name — it is reachable as
|
|
294
295
|
the bare `claude-hooks` entry above.
|
|
@@ -199,6 +199,7 @@ export async function runJudgeCli(
|
|
|
199
199
|
tool,
|
|
200
200
|
cpuMs: timer.cpuMs(),
|
|
201
201
|
redactorMs: timer.redactorMs(),
|
|
202
|
+
hostMs: timer.hostMs(),
|
|
202
203
|
}),
|
|
203
204
|
event,
|
|
204
205
|
),
|
|
@@ -216,6 +217,7 @@ export async function runJudgeCli(
|
|
|
216
217
|
tool,
|
|
217
218
|
cpuMs: timer.cpuMs(),
|
|
218
219
|
redactorMs: timer.redactorMs(),
|
|
220
|
+
hostMs: timer.hostMs(),
|
|
219
221
|
});
|
|
220
222
|
onError(err, input);
|
|
221
223
|
}
|
|
@@ -9,23 +9,34 @@
|
|
|
9
9
|
* before anyone traced it back here). A hook past the budget therefore says so
|
|
10
10
|
* IN BAND, in the model's context, where it can be relayed to the operator.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* FOUR numbers, because wall-clock alone cannot say whose cost it is: a hook on
|
|
13
13
|
* a contended host waits far longer than it computes (a 1.1 KB payload and a
|
|
14
14
|
* 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
|
|
15
|
-
* So the notice prints, beside the clock, the CPU this process burned
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* So the notice prints, beside the clock, the CPU this process burned, the time
|
|
16
|
+
* it spent inside redactor round trips, and the time it spent inside a HOST
|
|
17
|
+
* EXTENSION it called ({@link chargeHostExtension}) — the redactor daemon and a
|
|
18
|
+
* host callback's subprocess or socket peer are separate processes whose CPU this
|
|
19
|
+
* one cannot see (see {@link processCpuMs}), so the call that waits for each is
|
|
20
|
+
* the only measurable stand-in. The notice GATES on none of them: a hook wedged
|
|
21
|
+
* on a dead redactor socket burns no CPU and is exactly the sanitizer's fault.
|
|
22
|
+
*
|
|
23
|
+
* The host-extension window is what turned "blocked on something outside the
|
|
24
|
+
* sanitizer" — a verdict nobody can act on — into a named callee: a composer's
|
|
25
|
+
* best-effort audit POST to an unreachable sink charged every tool call its full
|
|
26
|
+
* 1.0s connect bound, and the notice could name none of it.
|
|
21
27
|
*
|
|
22
28
|
* ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
|
|
23
29
|
* an install to the hook that merely waited it out would make the FIRST call of
|
|
24
30
|
* every session cry wolf, which is the alert fatigue this notice fights.
|
|
25
31
|
*
|
|
26
32
|
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
27
|
-
* back-import would close a cycle. The one emitter it needs is passed in.
|
|
33
|
+
* back-import would close a cycle. The one emitter it needs is passed in. The
|
|
34
|
+
* node builtins below are not such a dependency — they read one small manifest,
|
|
35
|
+
* once, to name this build's version in a report line.
|
|
28
36
|
*/
|
|
37
|
+
import { readFileSync } from "node:fs";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
29
40
|
|
|
30
41
|
/**
|
|
31
42
|
* Wall-clock a single hook invocation may spend before it is reported as slow.
|
|
@@ -56,6 +67,103 @@ export const SLOW_PROVISION_THRESHOLD_MS = 60000;
|
|
|
56
67
|
const ISSUE_URL =
|
|
57
68
|
"https://github.com/AlexanderMattTurner/agent-sanitizer/issues/new";
|
|
58
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Where this build's own version sits, relative to the directory this module
|
|
72
|
+
* runs from — each shipped artifact puts its manifest at a fixed offset, so the
|
|
73
|
+
* candidates are enumerated rather than searched for:
|
|
74
|
+
*
|
|
75
|
+
* `../../.claude-plugin/plugin.json` the installed Claude Code plugin,
|
|
76
|
+
* whose bundle ships at
|
|
77
|
+
* `plugin/dist/hooks/`
|
|
78
|
+
* `../../plugin/.claude-plugin/plugin.json` a source checkout, where that same
|
|
79
|
+
* manifest is the accurate version
|
|
80
|
+
* and package.json's is the frozen
|
|
81
|
+
* placeholder npm overwrites at
|
|
82
|
+
* publish
|
|
83
|
+
* `../../package.json` the npm package, which ships this
|
|
84
|
+
* module at `claude-hooks/lib/` and
|
|
85
|
+
* carries the published version
|
|
86
|
+
*
|
|
87
|
+
* First hit wins, and each candidate exists only inside the artifact it belongs
|
|
88
|
+
* to, so no foreign manifest is ever a candidate.
|
|
89
|
+
*/
|
|
90
|
+
const VERSION_MANIFESTS = [
|
|
91
|
+
"../../.claude-plugin/plugin.json",
|
|
92
|
+
"../../plugin/.claude-plugin/plugin.json",
|
|
93
|
+
"../../package.json",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** Strict X.Y.Z, the only shape this project's release tooling ever writes. */
|
|
97
|
+
const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* This build's version for the report line below, or null when nothing here can
|
|
101
|
+
* name it — a compiled hook binary whose `import.meta.url` points inside the
|
|
102
|
+
* executable reads no manifest, and the notice then asks the operator to look
|
|
103
|
+
* the version up rather than printing one nothing confirmed.
|
|
104
|
+
* @returns {string | null}
|
|
105
|
+
*/
|
|
106
|
+
function readVersion() {
|
|
107
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
108
|
+
for (const manifest of VERSION_MANIFESTS) {
|
|
109
|
+
const version = readManifest(join(dir, manifest));
|
|
110
|
+
if (version !== null) return version;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The strict semver `path` carries, or null when it carries none.
|
|
117
|
+
*
|
|
118
|
+
* The read and the parse are caught because neither failure is this function's
|
|
119
|
+
* business: every candidate but one is absent in any given artifact, and a
|
|
120
|
+
* manifest a packager corrupted is not a reason for a PERFORMANCE notice to
|
|
121
|
+
* throw inside the hook it is reporting on.
|
|
122
|
+
* @param {string} path
|
|
123
|
+
* @returns {string | null}
|
|
124
|
+
*/
|
|
125
|
+
function readManifest(path) {
|
|
126
|
+
let manifest;
|
|
127
|
+
try {
|
|
128
|
+
manifest = JSON.parse(readFileSync(path, "utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return SEMVER.test(manifest?.version) ? manifest.version : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @type {string | null | undefined} */
|
|
136
|
+
let cachedVersion;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* {@link readVersion}, computed once per process — the notice fires on a
|
|
140
|
+
* vanishing fraction of runs, and every hook imports this module on the hot
|
|
141
|
+
* path, so the manifest is read only once something is being reported.
|
|
142
|
+
* @returns {string | null}
|
|
143
|
+
*/
|
|
144
|
+
export function sanitizerVersion() {
|
|
145
|
+
if (cachedVersion === undefined) cachedVersion = readVersion();
|
|
146
|
+
return cachedVersion;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The clause naming the version an issue report should carry: this build's when
|
|
151
|
+
* it knows it, and otherwise an instruction to look it up — never a guess.
|
|
152
|
+
*
|
|
153
|
+
* Resolves the version HERE rather than in a caller's default argument, which
|
|
154
|
+
* would read the manifest on every healthy run too — the notices call this only
|
|
155
|
+
* once they have decided to report.
|
|
156
|
+
* @param {string | null | undefined} version a caller's override; `undefined`
|
|
157
|
+
* asks this build for its own, `null` says nothing could name it
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
function versionClause(version) {
|
|
161
|
+
const resolved = version === undefined ? sanitizerVersion() : version;
|
|
162
|
+
return resolved
|
|
163
|
+
? `agent-sanitizer ${resolved}`
|
|
164
|
+
: "your agent-sanitizer version";
|
|
165
|
+
}
|
|
166
|
+
|
|
59
167
|
/**
|
|
60
168
|
* Milliseconds as the seconds string every notice below prints.
|
|
61
169
|
*
|
|
@@ -93,15 +201,16 @@ export function formatBytes(bytes) {
|
|
|
93
201
|
* that made this specific latency report take a manual multi-step
|
|
94
202
|
* investigation to characterize (which tool call, how large a payload) before
|
|
95
203
|
* anyone could act on it.
|
|
96
|
-
* `cpuMs` is the run's own processor time
|
|
97
|
-
*
|
|
98
|
-
* when the caller has no way
|
|
99
|
-
* this module reports.
|
|
204
|
+
* `cpuMs` is the run's own processor time, `redactorMs` the wall-clock it spent
|
|
205
|
+
* inside redactor round trips, and `hostMs` the wall-clock it spent inside host
|
|
206
|
+
* extensions (all from {@link startHookTimer}); absent when the caller has no way
|
|
207
|
+
* to measure them, which is what the shell port of this module reports.
|
|
100
208
|
* @typedef {{
|
|
101
209
|
* payloadBytes?: number | null,
|
|
102
210
|
* tool?: string | null,
|
|
103
211
|
* cpuMs?: number | null,
|
|
104
212
|
* redactorMs?: number | null,
|
|
213
|
+
* hostMs?: number | null,
|
|
105
214
|
* }} SlowHookContext
|
|
106
215
|
*/
|
|
107
216
|
|
|
@@ -149,6 +258,20 @@ let provisioningCpuMs = 0;
|
|
|
149
258
|
// measurement available.
|
|
150
259
|
let redactorRoundTripMs = 0;
|
|
151
260
|
|
|
261
|
+
// Process-wide total of wall-clock spent inside host extensions. Same reason as
|
|
262
|
+
// the redactor total above: a callback's cost lands in a subprocess or a socket
|
|
263
|
+
// peer, so this side's wait is the only measurement available. Its CPU is tracked
|
|
264
|
+
// beside it because a callback that computes IN THIS PROCESS shows up in
|
|
265
|
+
// processCpuMs too, and the notice must not charge one second of work to two
|
|
266
|
+
// windows; startHookTimer subtracts this from the hook's own CPU figure.
|
|
267
|
+
let hostExtensionMs = 0;
|
|
268
|
+
let hostExtensionCpuMs = 0;
|
|
269
|
+
// How many host-extension brackets are open. Only the OUTERMOST charges: the
|
|
270
|
+
// package brackets `postText` and `audit` itself AND publishes the charger, so a
|
|
271
|
+
// composer that charges its own work inside one of those callbacks would
|
|
272
|
+
// otherwise add the same interval twice and report a 3s callback as 6s.
|
|
273
|
+
let hostExtensionDepth = 0;
|
|
274
|
+
|
|
152
275
|
/**
|
|
153
276
|
* Run `work` — one redactor round trip — charging its duration to the redactor
|
|
154
277
|
* share, so a run that spent its second inside a redaction call is told apart
|
|
@@ -173,6 +296,84 @@ export async function chargeRedactorRoundTrip(work, now = Date.now) {
|
|
|
173
296
|
}
|
|
174
297
|
}
|
|
175
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Run `work` — one call into a HOST EXTENSION (a composer's `postText`, `audit`
|
|
301
|
+
* or other injected callback) — charging its duration to the host share, so a run
|
|
302
|
+
* that spent its second inside a callback is told apart from one that spent it
|
|
303
|
+
* anywhere else. Charged in a `finally`, since a callback that THROWS is the one
|
|
304
|
+
* that spent the most.
|
|
305
|
+
*
|
|
306
|
+
* Like {@link chargeRedactorRoundTrip} this only attributes; the time stays in
|
|
307
|
+
* the hook's wall-clock, because the user waits for it either way. It says WHERE
|
|
308
|
+
* the time went and declines to say WHOSE: the wait holds the callback's own work
|
|
309
|
+
* AND whatever descheduling the host imposed on it.
|
|
310
|
+
*
|
|
311
|
+
* `work` may be synchronous — a callback that spawns a subprocess and blocks is
|
|
312
|
+
* charged in full, because the whole call runs inside this bracket.
|
|
313
|
+
*
|
|
314
|
+
* Reach this through `claude-hooks/sanitize-output`'s re-export whenever the hook
|
|
315
|
+
* is the bundled copy: importing this subpath separately yields a SECOND module
|
|
316
|
+
* instance whose total no timer reads, and the notice then reports a measured
|
|
317
|
+
* `0.0s` for a window that really burned seconds.
|
|
318
|
+
* @template T
|
|
319
|
+
* @param {() => Promise<T> | T} work
|
|
320
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
321
|
+
* @returns {Promise<T>}
|
|
322
|
+
*/
|
|
323
|
+
export async function chargeHostExtension(
|
|
324
|
+
work,
|
|
325
|
+
now = Date.now,
|
|
326
|
+
cpuNow = processCpuMs,
|
|
327
|
+
) {
|
|
328
|
+
const outermost = hostExtensionDepth === 0;
|
|
329
|
+
const started = now();
|
|
330
|
+
const cpuStarted = cpuNow();
|
|
331
|
+
hostExtensionDepth += 1;
|
|
332
|
+
try {
|
|
333
|
+
return await work();
|
|
334
|
+
} finally {
|
|
335
|
+
hostExtensionDepth -= 1;
|
|
336
|
+
if (outermost) {
|
|
337
|
+
hostExtensionMs += Math.max(0, now() - started);
|
|
338
|
+
hostExtensionCpuMs += Math.max(0, cpuNow() - cpuStarted);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* {@link chargeHostExtension} for a callback that must answer SYNCHRONOUSLY — the
|
|
345
|
+
* `redactNote` seam returns a string, so an async wrapper cannot stand in for it,
|
|
346
|
+
* and a composer that spawns a subprocess there would otherwise leave the wait in
|
|
347
|
+
* the unattributed remainder and its CPU charged to the sanitizer.
|
|
348
|
+
*
|
|
349
|
+
* Nesting and CPU are handled exactly as above, so the two chargers compose: a
|
|
350
|
+
* sync callback invoked inside an async bracket charges nothing twice.
|
|
351
|
+
* @template T
|
|
352
|
+
* @param {() => T} work
|
|
353
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
354
|
+
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
355
|
+
* @returns {T}
|
|
356
|
+
*/
|
|
357
|
+
export function chargeHostExtensionSync(
|
|
358
|
+
work,
|
|
359
|
+
now = Date.now,
|
|
360
|
+
cpuNow = processCpuMs,
|
|
361
|
+
) {
|
|
362
|
+
const outermost = hostExtensionDepth === 0;
|
|
363
|
+
const started = now();
|
|
364
|
+
const cpuStarted = cpuNow();
|
|
365
|
+
hostExtensionDepth += 1;
|
|
366
|
+
try {
|
|
367
|
+
return work();
|
|
368
|
+
} finally {
|
|
369
|
+
hostExtensionDepth -= 1;
|
|
370
|
+
if (outermost) {
|
|
371
|
+
hostExtensionMs += Math.max(0, now() - started);
|
|
372
|
+
hostExtensionCpuMs += Math.max(0, cpuNow() - cpuStarted);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
176
377
|
/**
|
|
177
378
|
* Run `work`, charging its whole duration to provisioning so no timer running
|
|
178
379
|
* across it counts that time. Charged in a `finally`, so a provisioning step
|
|
@@ -212,10 +413,11 @@ export async function excludeProvisioning(
|
|
|
212
413
|
* so far MINUS any provisioning charged in the meantime, and may be called more
|
|
213
414
|
* than once.
|
|
214
415
|
*
|
|
215
|
-
* `wallMs` is what the user waited, `cpuMs` is what this process
|
|
216
|
-
*
|
|
217
|
-
* ({@link chargeRedactorRoundTrip})
|
|
218
|
-
*
|
|
416
|
+
* `wallMs` is what the user waited, `cpuMs` is what this process computed OUTSIDE
|
|
417
|
+
* a host callback, `redactorMs` is what it spent inside redactor round trips
|
|
418
|
+
* ({@link chargeRedactorRoundTrip}) and `hostMs` what it spent inside host
|
|
419
|
+
* extensions ({@link chargeHostExtension}). All four are needed to say where a
|
|
420
|
+
* slow run's time went — see the module header for the report that read a contended
|
|
219
421
|
* host as a sanitizer bug.
|
|
220
422
|
*
|
|
221
423
|
* Every reader counts only what was charged since this timer started, so an
|
|
@@ -225,7 +427,7 @@ export async function excludeProvisioning(
|
|
|
225
427
|
* the timer has measured, so the results are floored at 0.
|
|
226
428
|
* @param {() => number} [now] injectable clock, for tests
|
|
227
429
|
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
228
|
-
* @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number }}
|
|
430
|
+
* @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number, hostMs: () => number }}
|
|
229
431
|
*/
|
|
230
432
|
export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
|
|
231
433
|
const started = now();
|
|
@@ -233,22 +435,33 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
|
|
|
233
435
|
const provisionedBefore = provisioningMs;
|
|
234
436
|
const provisionedCpuBefore = provisioningCpuMs;
|
|
235
437
|
const redactorBefore = redactorRoundTripMs;
|
|
438
|
+
const hostBefore = hostExtensionMs;
|
|
439
|
+
const hostCpuBefore = hostExtensionCpuMs;
|
|
236
440
|
return {
|
|
237
441
|
wallMs: () =>
|
|
238
442
|
Math.max(0, now() - started - (provisioningMs - provisionedBefore)),
|
|
443
|
+
// Host-extension CPU is subtracted alongside provisioning's, so this figure is
|
|
444
|
+
// the SANITIZER's own work: a callback that computes in this process would
|
|
445
|
+
// otherwise be charged to the hook and to the host window both, and the
|
|
446
|
+
// largest-share verdict would name the sanitizer for the composer's cost.
|
|
239
447
|
cpuMs: () =>
|
|
240
448
|
Math.max(
|
|
241
449
|
0,
|
|
242
|
-
cpuNow() -
|
|
450
|
+
cpuNow() -
|
|
451
|
+
cpuStarted -
|
|
452
|
+
(provisioningCpuMs - provisionedCpuBefore) -
|
|
453
|
+
(hostExtensionCpuMs - hostCpuBefore),
|
|
243
454
|
),
|
|
244
455
|
redactorMs: () => Math.max(0, redactorRoundTripMs - redactorBefore),
|
|
456
|
+
hostMs: () => Math.max(0, hostExtensionMs - hostBefore),
|
|
245
457
|
};
|
|
246
458
|
}
|
|
247
459
|
|
|
248
460
|
/**
|
|
249
461
|
* The attribution sentence for a run whose CPU and redactor-round-trip shares
|
|
250
|
-
* are both known: the
|
|
251
|
-
* into — this hook computing, the redactor call, or
|
|
462
|
+
* are both known: the three numbers, then which of the four WINDOWS the time went
|
|
463
|
+
* into — this hook computing, the redactor call, a host extension, or none of
|
|
464
|
+
* them.
|
|
252
465
|
*
|
|
253
466
|
* A window, not a culprit. The round trip is wall-clock measured from this side,
|
|
254
467
|
* so it holds the daemon's scan AND whatever descheduling the host imposed on
|
|
@@ -260,25 +473,46 @@ export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
|
|
|
260
473
|
* say WHOSE, while the other two windows, which no host load can move time into,
|
|
261
474
|
* are named outright.
|
|
262
475
|
*
|
|
263
|
-
* The
|
|
264
|
-
*
|
|
265
|
-
*
|
|
476
|
+
* The host-extension window is named the same way and for the same reason: the
|
|
477
|
+
* callback is the composer's code, so a wait inside it is a cost the COMPOSER
|
|
478
|
+
* owns, and naming it is what lets a reader look at the right repository. A
|
|
479
|
+
* composer's audit POST to an absent sink is the case that motivated it.
|
|
480
|
+
*
|
|
481
|
+
* The CPU, redactor and host shares overlap by the framing this side does
|
|
482
|
+
* mid-round-trip and mid-callback, so they do not sum to the elapsed time; a
|
|
483
|
+
* share that dominates despite the overlap is still the one to act on.
|
|
266
484
|
* @param {number} elapsedMs
|
|
267
485
|
* @param {number} cpuMs
|
|
268
486
|
* @param {number} redactorMs
|
|
487
|
+
* @param {number | undefined} hostMs absent when the caller measures no host
|
|
488
|
+
* callbacks; a caller that does pass 0 when none ran
|
|
269
489
|
* @returns {string}
|
|
270
490
|
*/
|
|
271
|
-
function attributeWait(elapsedMs, cpuMs, redactorMs) {
|
|
272
|
-
|
|
491
|
+
function attributeWait(elapsedMs, cpuMs, redactorMs, hostMs) {
|
|
492
|
+
// `hostMs` absent means the caller cannot measure that window, not that it was
|
|
493
|
+
// empty — a caller that CAN measure passes 0 and gets the zero printed. So an
|
|
494
|
+
// absent one is left out of both the sentence and the remainder, and the
|
|
495
|
+
// verdict's fourth arm names it as one of the unmeasured candidates.
|
|
496
|
+
const measuredHostMs = hostMs ?? 0;
|
|
497
|
+
const otherMs = Math.max(0, elapsedMs - cpuMs - redactorMs - measuredHostMs);
|
|
498
|
+
const largest = Math.max(cpuMs, redactorMs, measuredHostMs, otherMs);
|
|
273
499
|
const verdict =
|
|
274
|
-
redactorMs
|
|
500
|
+
redactorMs === largest
|
|
275
501
|
? "The largest share was spent inside the redactor round trip — the daemon's scan, the host it shares, or both; this hook was not computing it."
|
|
276
|
-
:
|
|
277
|
-
? "The largest share
|
|
278
|
-
:
|
|
502
|
+
: hostMs !== undefined && hostMs === largest
|
|
503
|
+
? "The largest share was spent inside a host extension this hook called — a callback the composer injected, and a cost that composer owns; neither the sanitizer nor the redactor was computing it."
|
|
504
|
+
: cpuMs === largest
|
|
505
|
+
? "The largest share is this hook computing — a per-call cost the sanitizer owns, repeated by every affected call."
|
|
506
|
+
: hostMs === undefined
|
|
507
|
+
? "The largest share is neither the redactor nor this hook computing: it was blocked on a loaded machine, on a host extension this caller does not measure, or on something else outside the sanitizer that it called."
|
|
508
|
+
: "The largest share is none of those three: it was blocked on a loaded machine or on something outside the sanitizer that it called without measuring.";
|
|
509
|
+
const hostClause =
|
|
510
|
+
hostMs === undefined
|
|
511
|
+
? ""
|
|
512
|
+
: ` and ${formatSeconds(hostMs)}s was inside host extensions`;
|
|
279
513
|
return (
|
|
280
|
-
`, of which ${formatSeconds(cpuMs)}s was this hook's own CPU and ` +
|
|
281
|
-
`${formatSeconds(redactorMs)}s was inside redactor round trips. ${verdict}`
|
|
514
|
+
`, of which ${formatSeconds(cpuMs)}s was this hook's own CPU${hostClause === "" ? " and" : ","} ` +
|
|
515
|
+
`${formatSeconds(redactorMs)}s was inside redactor round trips${hostClause}. ${verdict}`
|
|
282
516
|
);
|
|
283
517
|
}
|
|
284
518
|
|
|
@@ -290,7 +524,9 @@ function attributeWait(elapsedMs, cpuMs, redactorMs) {
|
|
|
290
524
|
*
|
|
291
525
|
* With `context.cpuMs` in hand the line says which share of the wait was the
|
|
292
526
|
* sanitizer computing, and with `context.redactorMs` too it names the window the
|
|
293
|
-
* time went into ({@link attributeWait})
|
|
527
|
+
* time went into ({@link attributeWait}), including `context.hostMs`'s host
|
|
528
|
+
* extensions — a caller that measured the first two but has no extensions to
|
|
529
|
+
* charge reports that window as zero, which is a measurement and not a shrug. Without them the line says that it cannot
|
|
294
530
|
* tell, rather than asserting an attribution nothing measured: a wall-clock
|
|
295
531
|
* overrun on a loaded host is the common case, and blaming it on the sanitizer
|
|
296
532
|
* sends the operator hunting a per-call cost that does not exist.
|
|
@@ -305,6 +541,9 @@ function attributeWait(elapsedMs, cpuMs, redactorMs) {
|
|
|
305
541
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
306
542
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
307
543
|
* next reader to reconstruct what was slow by hand
|
|
544
|
+
* @param {string | null} [version] the build to name in the report line;
|
|
545
|
+
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
546
|
+
* read from the plugin manifest it ships beside
|
|
308
547
|
* @returns {string | null}
|
|
309
548
|
*/
|
|
310
549
|
export function slowHookNotice(
|
|
@@ -312,27 +551,35 @@ export function slowHookNotice(
|
|
|
312
551
|
elapsedMs,
|
|
313
552
|
thresholdMs = SLOW_HOOK_THRESHOLD_MS,
|
|
314
553
|
context,
|
|
554
|
+
version,
|
|
315
555
|
) {
|
|
316
556
|
if (elapsedMs <= thresholdMs) return null;
|
|
317
557
|
const cpuMs = context?.cpuMs;
|
|
318
558
|
const redactorMs = context?.redactorMs;
|
|
319
559
|
const attributed =
|
|
320
560
|
typeof cpuMs === "number" && typeof redactorMs === "number";
|
|
561
|
+
// Left UNDEFINED when the caller passed none: a zero would claim a measurement
|
|
562
|
+
// nobody made. Every caller in this package charges the seams and passes the
|
|
563
|
+
// number, including when it is 0.
|
|
564
|
+
const hostMs =
|
|
565
|
+
typeof context?.hostMs === "number" ? context.hostMs : undefined;
|
|
321
566
|
const attribution = attributed
|
|
322
|
-
? attributeWait(elapsedMs, cpuMs, redactorMs)
|
|
567
|
+
? attributeWait(elapsedMs, cpuMs, redactorMs, hostMs)
|
|
323
568
|
: typeof cpuMs === "number"
|
|
324
569
|
? `, and used ${formatSeconds(cpuMs)}s of CPU. ` +
|
|
325
570
|
"Only the CPU share is work every affected call repeats; the rest was spent waiting, on a busy machine or on something this hook called."
|
|
326
571
|
: ". Wall-clock alone cannot separate the sanitizer's own work from a busy machine.";
|
|
327
572
|
const timings = attributed
|
|
328
|
-
?
|
|
573
|
+
? hostMs === undefined
|
|
574
|
+
? "all three timings"
|
|
575
|
+
: "all four timings"
|
|
329
576
|
: typeof cpuMs === "number"
|
|
330
577
|
? "both timings"
|
|
331
578
|
: "timing";
|
|
332
579
|
return (
|
|
333
580
|
`agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
|
|
334
581
|
`${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget${attribution} ` +
|
|
335
|
-
`Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and ${timings}.`
|
|
582
|
+
`Tell the user, and suggest they report it at ${ISSUE_URL} with ${versionClause(version)}, the hook name and ${timings}.`
|
|
336
583
|
);
|
|
337
584
|
}
|
|
338
585
|
|
|
@@ -357,6 +604,7 @@ export function slowHookNotice(
|
|
|
357
604
|
* @param {string} [advice] step-specific speedup advice — the default fits the
|
|
358
605
|
* engine install; the hook-binary download passes its own, because telling a
|
|
359
606
|
* user mid-download that uv would help is advice about the wrong step
|
|
607
|
+
* @param {string | null} [version] see {@link slowHookNotice}
|
|
360
608
|
* @returns {string | null}
|
|
361
609
|
*/
|
|
362
610
|
export function slowProvisionNotice(
|
|
@@ -364,13 +612,14 @@ export function slowProvisionNotice(
|
|
|
364
612
|
elapsedMs,
|
|
365
613
|
thresholdMs = SLOW_PROVISION_THRESHOLD_MS,
|
|
366
614
|
advice = "Installing uv makes it faster",
|
|
615
|
+
version,
|
|
367
616
|
) {
|
|
368
617
|
if (elapsedMs <= thresholdMs) return null;
|
|
369
618
|
return (
|
|
370
619
|
`agent-sanitizer PERFORMANCE: one-time setup (${stepName}) took ` +
|
|
371
620
|
`${formatSeconds(elapsedMs)}s, over its ${formatSeconds(thresholdMs)}s budget — ` +
|
|
372
621
|
"this is paid once per install, not per tool call, so the session is not slow from here on. " +
|
|
373
|
-
`${advice}; if it happens on EVERY new session, report it at ${ISSUE_URL}.`
|
|
622
|
+
`${advice}; if it happens on EVERY new session, report it at ${ISSUE_URL} with ${versionClause(version)}.`
|
|
374
623
|
);
|
|
375
624
|
}
|
|
376
625
|
|
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
* isUntrustedIngress).
|
|
26
26
|
*/
|
|
27
27
|
import { redactViaDaemon, positiveMsOr } from "./lib/redactor-client.mjs";
|
|
28
|
+
import {
|
|
29
|
+
chargeHostExtension,
|
|
30
|
+
chargeHostExtensionSync,
|
|
31
|
+
} from "./lib/hook-timing.mjs";
|
|
32
|
+
// Re-exported for a composer whose own awaited work runs INSIDE this hook's
|
|
33
|
+
// judge (an audit POST, a policy call): charging it needs the same module
|
|
34
|
+
// instance the timer reads, and a bundled composer that imports the package
|
|
35
|
+
// subpath separately gets a second instance whose totals no timer sees.
|
|
36
|
+
export { chargeHostExtension, chargeHostExtensionSync };
|
|
28
37
|
import {
|
|
29
38
|
isMain,
|
|
30
39
|
lazyImport,
|
|
@@ -284,8 +293,13 @@ export async function sanitizeText(
|
|
|
284
293
|
if (!secrets) return null;
|
|
285
294
|
// The note is derived from the PRE-redaction text: the caller's reason for
|
|
286
295
|
// annotating (which variable, which provenance) is exactly what redaction
|
|
287
|
-
// is about to remove.
|
|
288
|
-
|
|
296
|
+
// is about to remove. Charged like the other seams — this one must answer
|
|
297
|
+
// synchronously, so a composer that blocks here (a `spawnSync` lookup)
|
|
298
|
+
// would otherwise leave its wait unattributed and its CPU on the hook.
|
|
299
|
+
const redactNote = ext.redactNote;
|
|
300
|
+
const note = redactNote
|
|
301
|
+
? chargeHostExtensionSync(() => redactNote(content))
|
|
302
|
+
: undefined;
|
|
289
303
|
return note
|
|
290
304
|
? { text: secrets.text, found: secrets.found, note }
|
|
291
305
|
: { text: secrets.text, found: secrets.found };
|
|
@@ -298,16 +312,18 @@ export async function sanitizeText(
|
|
|
298
312
|
// The one place the seam's shape is normalized, so nothing downstream has to
|
|
299
313
|
// branch on an absent `notes` and the banner composer sees one shape.
|
|
300
314
|
const result = { ...seamResult, notes: seamResult.notes ?? [] };
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
// Charged to the host-extension window: the callback is the composer's code and
|
|
316
|
+
// may block on a subprocess or a socket, neither of which this process's CPU
|
|
317
|
+
// figure can see, so an uncharged wait would land in the slow-hook notice's
|
|
318
|
+
// unattributed remainder and name nobody.
|
|
319
|
+
const postText = ext.postText;
|
|
320
|
+
if (!postText) return result;
|
|
321
|
+
return applyPostText(
|
|
322
|
+
result,
|
|
323
|
+
await chargeHostExtension(() =>
|
|
324
|
+
postText(result.cleaned, { toolName, webIngress, deadline }),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
311
327
|
}
|
|
312
328
|
|
|
313
329
|
/**
|
|
@@ -837,8 +853,17 @@ registerFaultPolicy(HOOK_NAME, {
|
|
|
837
853
|
*/
|
|
838
854
|
export async function evaluateToolOutput(input, ext = {}) {
|
|
839
855
|
// Best-effort, like the default sink: a host callback that throws must not be
|
|
840
|
-
// the thing that suppresses a tool output (see bestEffortTrace).
|
|
841
|
-
|
|
856
|
+
// the thing that suppresses a tool output (see bestEffortTrace). A COMPOSER's
|
|
857
|
+
// sink is charged to the host window — it may write over a socket this process
|
|
858
|
+
// cannot see the cost of — while the package's own sink is not, since that
|
|
859
|
+
// file write is the sanitizer's own work.
|
|
860
|
+
const hostTrace = ext.trace;
|
|
861
|
+
const emitTrace = bestEffortTrace(
|
|
862
|
+
hostTrace
|
|
863
|
+
? (event, fields) =>
|
|
864
|
+
chargeHostExtensionSync(() => hostTrace(event, fields))
|
|
865
|
+
: trace,
|
|
866
|
+
);
|
|
842
867
|
/**
|
|
843
868
|
* @param {string} outcome noop | clean | flagged | modified
|
|
844
869
|
* @param {{ mutated_output?: unknown, additional_context?: string } | null} fields
|
|
@@ -1044,16 +1069,22 @@ export async function judgeSanitizeOutput(event, ext = {}) {
|
|
|
1044
1069
|
// with silent holes is worse than a suppressed tool output.
|
|
1045
1070
|
if (ext.audit && event.response !== null && event.response !== undefined) {
|
|
1046
1071
|
const modified = fields !== null && Object.hasOwn(fields, "mutated_output");
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1072
|
+
// Charged to the host-extension window for the same reason as `postText`
|
|
1073
|
+
// above: a recorder that POSTs to an absent sink waits out its own connect
|
|
1074
|
+
// bound, and the notice must be able to name that window.
|
|
1075
|
+
const audit = ext.audit;
|
|
1076
|
+
await chargeHostExtension(() =>
|
|
1077
|
+
audit({
|
|
1078
|
+
tool: event.tool,
|
|
1079
|
+
// The session identity travels in `meta`, not alongside the tool fields,
|
|
1080
|
+
// so a recorder filing one trail per session cannot reach it unless it is
|
|
1081
|
+
// lifted here.
|
|
1082
|
+
session_id: event.meta?.session_id,
|
|
1083
|
+
modified,
|
|
1084
|
+
output: modified ? fields?.mutated_output : event.response,
|
|
1085
|
+
context: fields?.additional_context,
|
|
1086
|
+
}),
|
|
1087
|
+
);
|
|
1057
1088
|
}
|
|
1058
1089
|
/** @type {import("agent-control-plane-core").Verdict} */
|
|
1059
1090
|
const verdict = { decision: Decision.ALLOW };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.52.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -196,6 +196,10 @@
|
|
|
196
196
|
"./claude-hooks/lib/trace": {
|
|
197
197
|
"types": "./types/claude-hooks/lib/trace.d.mts",
|
|
198
198
|
"default": "./claude-hooks/lib/trace.mjs"
|
|
199
|
+
},
|
|
200
|
+
"./claude-hooks/lib/hook-timing": {
|
|
201
|
+
"types": "./types/claude-hooks/lib/hook-timing.d.mts",
|
|
202
|
+
"default": "./claude-hooks/lib/hook-timing.mjs"
|
|
199
203
|
}
|
|
200
204
|
},
|
|
201
205
|
"files": [
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link readVersion}, computed once per process — the notice fires on a
|
|
3
|
+
* vanishing fraction of runs, and every hook imports this module on the hot
|
|
4
|
+
* path, so the manifest is read only once something is being reported.
|
|
5
|
+
* @returns {string | null}
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizerVersion(): string | null;
|
|
1
8
|
/**
|
|
2
9
|
* Milliseconds as the seconds string every notice below prints.
|
|
3
10
|
*
|
|
@@ -37,6 +44,46 @@ export function formatBytes(bytes: number): string;
|
|
|
37
44
|
* @returns {Promise<T>}
|
|
38
45
|
*/
|
|
39
46
|
export function chargeRedactorRoundTrip<T>(work: () => Promise<T>, now?: () => number): Promise<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Run `work` — one call into a HOST EXTENSION (a composer's `postText`, `audit`
|
|
49
|
+
* or other injected callback) — charging its duration to the host share, so a run
|
|
50
|
+
* that spent its second inside a callback is told apart from one that spent it
|
|
51
|
+
* anywhere else. Charged in a `finally`, since a callback that THROWS is the one
|
|
52
|
+
* that spent the most.
|
|
53
|
+
*
|
|
54
|
+
* Like {@link chargeRedactorRoundTrip} this only attributes; the time stays in
|
|
55
|
+
* the hook's wall-clock, because the user waits for it either way. It says WHERE
|
|
56
|
+
* the time went and declines to say WHOSE: the wait holds the callback's own work
|
|
57
|
+
* AND whatever descheduling the host imposed on it.
|
|
58
|
+
*
|
|
59
|
+
* `work` may be synchronous — a callback that spawns a subprocess and blocks is
|
|
60
|
+
* charged in full, because the whole call runs inside this bracket.
|
|
61
|
+
*
|
|
62
|
+
* Reach this through `claude-hooks/sanitize-output`'s re-export whenever the hook
|
|
63
|
+
* is the bundled copy: importing this subpath separately yields a SECOND module
|
|
64
|
+
* instance whose total no timer reads, and the notice then reports a measured
|
|
65
|
+
* `0.0s` for a window that really burned seconds.
|
|
66
|
+
* @template T
|
|
67
|
+
* @param {() => Promise<T> | T} work
|
|
68
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
69
|
+
* @returns {Promise<T>}
|
|
70
|
+
*/
|
|
71
|
+
export function chargeHostExtension<T>(work: () => Promise<T> | T, now?: () => number, cpuNow?: typeof processCpuMs): Promise<T>;
|
|
72
|
+
/**
|
|
73
|
+
* {@link chargeHostExtension} for a callback that must answer SYNCHRONOUSLY — the
|
|
74
|
+
* `redactNote` seam returns a string, so an async wrapper cannot stand in for it,
|
|
75
|
+
* and a composer that spawns a subprocess there would otherwise leave the wait in
|
|
76
|
+
* the unattributed remainder and its CPU charged to the sanitizer.
|
|
77
|
+
*
|
|
78
|
+
* Nesting and CPU are handled exactly as above, so the two chargers compose: a
|
|
79
|
+
* sync callback invoked inside an async bracket charges nothing twice.
|
|
80
|
+
* @template T
|
|
81
|
+
* @param {() => T} work
|
|
82
|
+
* @param {() => number} [now] injectable clock, for tests
|
|
83
|
+
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
84
|
+
* @returns {T}
|
|
85
|
+
*/
|
|
86
|
+
export function chargeHostExtensionSync<T>(work: () => T, now?: () => number, cpuNow?: () => number): T;
|
|
40
87
|
/**
|
|
41
88
|
* Run `work`, charging its whole duration to provisioning so no timer running
|
|
42
89
|
* across it counts that time. Charged in a `finally`, so a provisioning step
|
|
@@ -62,10 +109,11 @@ export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => numbe
|
|
|
62
109
|
* so far MINUS any provisioning charged in the meantime, and may be called more
|
|
63
110
|
* than once.
|
|
64
111
|
*
|
|
65
|
-
* `wallMs` is what the user waited, `cpuMs` is what this process
|
|
66
|
-
*
|
|
67
|
-
* ({@link chargeRedactorRoundTrip})
|
|
68
|
-
*
|
|
112
|
+
* `wallMs` is what the user waited, `cpuMs` is what this process computed OUTSIDE
|
|
113
|
+
* a host callback, `redactorMs` is what it spent inside redactor round trips
|
|
114
|
+
* ({@link chargeRedactorRoundTrip}) and `hostMs` what it spent inside host
|
|
115
|
+
* extensions ({@link chargeHostExtension}). All four are needed to say where a
|
|
116
|
+
* slow run's time went — see the module header for the report that read a contended
|
|
69
117
|
* host as a sanitizer bug.
|
|
70
118
|
*
|
|
71
119
|
* Every reader counts only what was charged since this timer started, so an
|
|
@@ -75,12 +123,13 @@ export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => numbe
|
|
|
75
123
|
* the timer has measured, so the results are floored at 0.
|
|
76
124
|
* @param {() => number} [now] injectable clock, for tests
|
|
77
125
|
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
78
|
-
* @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number }}
|
|
126
|
+
* @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number, hostMs: () => number }}
|
|
79
127
|
*/
|
|
80
128
|
export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
81
129
|
wallMs: () => number;
|
|
82
130
|
cpuMs: () => number;
|
|
83
131
|
redactorMs: () => number;
|
|
132
|
+
hostMs: () => number;
|
|
84
133
|
};
|
|
85
134
|
/**
|
|
86
135
|
* The model-facing line for a hook that overran the budget, or null when it did
|
|
@@ -90,7 +139,9 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
|
90
139
|
*
|
|
91
140
|
* With `context.cpuMs` in hand the line says which share of the wait was the
|
|
92
141
|
* sanitizer computing, and with `context.redactorMs` too it names the window the
|
|
93
|
-
* time went into ({@link attributeWait})
|
|
142
|
+
* time went into ({@link attributeWait}), including `context.hostMs`'s host
|
|
143
|
+
* extensions — a caller that measured the first two but has no extensions to
|
|
144
|
+
* charge reports that window as zero, which is a measurement and not a shrug. Without them the line says that it cannot
|
|
94
145
|
* tell, rather than asserting an attribution nothing measured: a wall-clock
|
|
95
146
|
* overrun on a loaded host is the common case, and blaming it on the sanitizer
|
|
96
147
|
* sends the operator hunting a per-call cost that does not exist.
|
|
@@ -105,9 +156,12 @@ export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
|
105
156
|
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
106
157
|
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
107
158
|
* next reader to reconstruct what was slow by hand
|
|
159
|
+
* @param {string | null} [version] the build to name in the report line;
|
|
160
|
+
* omitted asks {@link sanitizerVersion}, and the shell port passes its own,
|
|
161
|
+
* read from the plugin manifest it ships beside
|
|
108
162
|
* @returns {string | null}
|
|
109
163
|
*/
|
|
110
|
-
export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext): string | null;
|
|
164
|
+
export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext, version?: string | null): string | null;
|
|
111
165
|
/**
|
|
112
166
|
* The line for a ONE-TIME provisioning step that overran
|
|
113
167
|
* {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
|
|
@@ -129,9 +183,10 @@ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?
|
|
|
129
183
|
* @param {string} [advice] step-specific speedup advice — the default fits the
|
|
130
184
|
* engine install; the hook-binary download passes its own, because telling a
|
|
131
185
|
* user mid-download that uv would help is advice about the wrong step
|
|
186
|
+
* @param {string | null} [version] see {@link slowHookNotice}
|
|
132
187
|
* @returns {string | null}
|
|
133
188
|
*/
|
|
134
|
-
export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string): string | null;
|
|
189
|
+
export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string, version?: string | null): string | null;
|
|
135
190
|
/**
|
|
136
191
|
* Write the slow-hook notice to stderr and return it, or return null when the
|
|
137
192
|
* run was within budget (writing nothing, so the quiet path stays quiet).
|
|
@@ -184,34 +239,6 @@ export function withSlowHookNotice<V extends {
|
|
|
184
239
|
* @returns {boolean} whether a notice was emitted
|
|
185
240
|
*/
|
|
186
241
|
export function reportSlowHook(hookName: string, elapsedMs: number, hookEventName: string, emit: (event: string, fields: Record<string, unknown>) => void, writeErr?: (chunk: string) => void, context?: SlowHookContext): boolean;
|
|
187
|
-
/**
|
|
188
|
-
* The one place a hook's own cost is measured and reported — one threshold, one
|
|
189
|
-
* message, one merge rule, shared by every hook.
|
|
190
|
-
*
|
|
191
|
-
* These hooks sit on the critical path of every tool call, prompt and session
|
|
192
|
-
* start: whatever they spend, the user waits. A slow hook is also the hardest
|
|
193
|
-
* bug to notice from inside — it looks exactly like a slow agent, so it goes
|
|
194
|
-
* unreported for weeks (one SessionStart scan blocked startup for 30 SECONDS
|
|
195
|
-
* before anyone traced it back here). A hook past the budget therefore says so
|
|
196
|
-
* IN BAND, in the model's context, where it can be relayed to the operator.
|
|
197
|
-
*
|
|
198
|
-
* THREE numbers, because wall-clock alone cannot say whose cost it is: a hook on
|
|
199
|
-
* a contended host waits far longer than it computes (a 1.1 KB payload and a
|
|
200
|
-
* 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
|
|
201
|
-
* So the notice prints, beside the clock, the CPU this process burned and the
|
|
202
|
-
* time it spent inside redactor round trips — the daemon is a separate,
|
|
203
|
-
* long-lived process whose CPU this one cannot see (see {@link processCpuMs}),
|
|
204
|
-
* so the call that waits for it is the only measurable stand-in. The notice
|
|
205
|
-
* GATES on none of them: a hook wedged on a dead redactor socket burns no CPU
|
|
206
|
-
* and is exactly the sanitizer's fault.
|
|
207
|
-
*
|
|
208
|
-
* ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
|
|
209
|
-
* an install to the hook that merely waited it out would make the FIRST call of
|
|
210
|
-
* every session cry wolf, which is the alert fatigue this notice fights.
|
|
211
|
-
*
|
|
212
|
-
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
213
|
-
* back-import would close a cycle. The one emitter it needs is passed in.
|
|
214
|
-
*/
|
|
215
242
|
/**
|
|
216
243
|
* Wall-clock a single hook invocation may spend before it is reported as slow.
|
|
217
244
|
*
|
|
@@ -241,14 +268,26 @@ export const SLOW_PROVISION_THRESHOLD_MS: 60000;
|
|
|
241
268
|
* that made this specific latency report take a manual multi-step
|
|
242
269
|
* investigation to characterize (which tool call, how large a payload) before
|
|
243
270
|
* anyone could act on it.
|
|
244
|
-
* `cpuMs` is the run's own processor time
|
|
245
|
-
*
|
|
246
|
-
* when the caller has no way
|
|
247
|
-
* this module reports.
|
|
271
|
+
* `cpuMs` is the run's own processor time, `redactorMs` the wall-clock it spent
|
|
272
|
+
* inside redactor round trips, and `hostMs` the wall-clock it spent inside host
|
|
273
|
+
* extensions (all from {@link startHookTimer}); absent when the caller has no way
|
|
274
|
+
* to measure them, which is what the shell port of this module reports.
|
|
248
275
|
*/
|
|
249
276
|
export type SlowHookContext = {
|
|
250
277
|
payloadBytes?: number | null;
|
|
251
278
|
tool?: string | null;
|
|
252
279
|
cpuMs?: number | null;
|
|
253
280
|
redactorMs?: number | null;
|
|
281
|
+
hostMs?: number | null;
|
|
254
282
|
};
|
|
283
|
+
/**
|
|
284
|
+
* This process's own user+system processor time so far, in milliseconds.
|
|
285
|
+
*
|
|
286
|
+
* `process.cpuUsage()` is RUSAGE_SELF: it counts what this node process
|
|
287
|
+
* computed and excludes both idle waiting and any child process. That is
|
|
288
|
+
* exactly the split the notice needs — a hook blocked on a socket, a lock or a
|
|
289
|
+
* loaded scheduler adds wall-clock here and no CPU.
|
|
290
|
+
* @returns {number}
|
|
291
|
+
*/
|
|
292
|
+
declare function processCpuMs(): number;
|
|
293
|
+
export {};
|
|
@@ -351,3 +351,6 @@ export type SanitizeExtensions = {
|
|
|
351
351
|
*/
|
|
352
352
|
remedy?: string | undefined;
|
|
353
353
|
};
|
|
354
|
+
import { chargeHostExtension } from "./lib/hook-timing.mjs";
|
|
355
|
+
import { chargeHostExtensionSync } from "./lib/hook-timing.mjs";
|
|
356
|
+
export { chargeHostExtension, chargeHostExtensionSync };
|