agent-sanitizer 2.43.7 → 2.43.9
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.
|
@@ -170,8 +170,8 @@ export async function runJudgeCli(
|
|
|
170
170
|
// on the success path hides exactly the case where the hook is both slow and
|
|
171
171
|
// broken. Null until stdin arrives — a read that throws measured nothing, and
|
|
172
172
|
// an invented number is worse than none.
|
|
173
|
-
/** @type {
|
|
174
|
-
let
|
|
173
|
+
/** @type {ReturnType<typeof startHookTimer> | null} */
|
|
174
|
+
let timer = null;
|
|
175
175
|
/** @type {number | null} */
|
|
176
176
|
let payloadBytes = null;
|
|
177
177
|
/** @type {string | null} */
|
|
@@ -185,18 +185,19 @@ export async function runJudgeCli(
|
|
|
185
185
|
// Timed from HERE, not from process start: the wait for the harness to hand
|
|
186
186
|
// over stdin is not this hook's cost, and blaming it for one would send
|
|
187
187
|
// operators chasing a bug report that is not theirs to fix.
|
|
188
|
-
|
|
188
|
+
timer = startHookTimer();
|
|
189
189
|
const { claudeAdapter: adapter } = controlPlane();
|
|
190
190
|
const event = adapter.parse(transformInput(input));
|
|
191
191
|
tool = event.tool ?? null;
|
|
192
|
-
// Awaited into its own binding first: as an inline argument,
|
|
192
|
+
// Awaited into its own binding first: as an inline argument, the timer read
|
|
193
193
|
// would be evaluated BEFORE the judge it is supposed to be timing.
|
|
194
194
|
const judged = await judge(event);
|
|
195
195
|
const out = nativeStdout(
|
|
196
196
|
adapter.render(
|
|
197
|
-
withSlowHookNotice(hookName,
|
|
197
|
+
withSlowHookNotice(hookName, timer.wallMs(), judged, undefined, {
|
|
198
198
|
payloadBytes,
|
|
199
199
|
tool,
|
|
200
|
+
cpuMs: timer.cpuMs(),
|
|
200
201
|
}),
|
|
201
202
|
event,
|
|
202
203
|
),
|
|
@@ -208,10 +209,11 @@ export async function runJudgeCli(
|
|
|
208
209
|
// must act on first, and the timing is context for it. stderr only — the
|
|
209
210
|
// model-facing channel here belongs to onError's fail-closed message, and a
|
|
210
211
|
// performance aside must not dilute a "this output was never vetted".
|
|
211
|
-
if (
|
|
212
|
-
writeSlowHookNotice(hookName,
|
|
212
|
+
if (timer !== null)
|
|
213
|
+
writeSlowHookNotice(hookName, timer.wallMs(), undefined, {
|
|
213
214
|
payloadBytes,
|
|
214
215
|
tool,
|
|
216
|
+
cpuMs: timer.cpuMs(),
|
|
215
217
|
});
|
|
216
218
|
onError(err, input);
|
|
217
219
|
}
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one place a hook's own
|
|
2
|
+
* The one place a hook's own cost is measured and reported — one threshold, one
|
|
3
|
+
* message, one merge rule, shared by every hook.
|
|
3
4
|
*
|
|
4
|
-
* These hooks sit on the critical path of every tool call,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* context, where it cannot be missed and can be relayed to the operator.
|
|
5
|
+
* These hooks sit on the critical path of every tool call, prompt and session
|
|
6
|
+
* start: whatever they spend, the user waits. A slow hook is also the hardest
|
|
7
|
+
* bug to notice from inside — it looks exactly like a slow agent, so it goes
|
|
8
|
+
* unreported for weeks (one SessionStart scan blocked startup for 30 SECONDS
|
|
9
|
+
* before anyone traced it back here). A hook past the budget therefore says so
|
|
10
|
+
* IN BAND, in the model's context, where it can be relayed to the operator.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* TWO numbers, because wall-clock alone cannot say whose cost it is: a hook on
|
|
13
|
+
* a contended host waits far longer than it computes (a 1.1 KB payload and a
|
|
14
|
+
* 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
|
|
15
|
+
* So the notice prints CPU beside the clock — the share every affected call
|
|
16
|
+
* repeats — and never GATES on it: a hook wedged on a dead redactor socket
|
|
17
|
+
* burns no CPU and is exactly the sanitizer's fault.
|
|
15
18
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* pays per call and it is not a bug worth a report — charging it would make the
|
|
20
|
-
* FIRST call of every session cry wolf, which is precisely the alert fatigue
|
|
21
|
-
* this notice exists to avoid.
|
|
19
|
+
* ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
|
|
20
|
+
* an install to the hook that merely waited it out would make the FIRST call of
|
|
21
|
+
* every session cry wolf, which is the alert fatigue this notice fights.
|
|
22
22
|
*
|
|
23
23
|
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
24
24
|
* back-import would close a cycle. The one emitter it needs is passed in.
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
*
|
|
30
30
|
* A second is far above anything these hooks do when healthy (Layer 1 is a few
|
|
31
31
|
* regex passes; the redactor daemon answers in tens of milliseconds once warm)
|
|
32
|
-
* and far below the point where a human is merely impatient
|
|
33
|
-
*
|
|
32
|
+
* and far below the point where a human is merely impatient. Crossing it means
|
|
33
|
+
* the user waited that long, which is worth saying either way; whether the
|
|
34
|
+
* sanitizer or a busy machine spent it is what the CPU figure answers.
|
|
34
35
|
*/
|
|
35
36
|
export const SLOW_HOOK_THRESHOLD_MS = 1000;
|
|
36
37
|
|
|
@@ -89,13 +90,22 @@ export function formatBytes(bytes) {
|
|
|
89
90
|
* that made this specific latency report take a manual multi-step
|
|
90
91
|
* investigation to characterize (which tool call, how large a payload) before
|
|
91
92
|
* anyone could act on it.
|
|
92
|
-
*
|
|
93
|
+
* `cpuMs` is the run's own processor time (see {@link startHookTimer}); absent
|
|
94
|
+
* when the caller has no way to measure it, which is what the shell port of
|
|
95
|
+
* this module reports.
|
|
96
|
+
* @typedef {{
|
|
97
|
+
* payloadBytes?: number | null,
|
|
98
|
+
* tool?: string | null,
|
|
99
|
+
* cpuMs?: number | null,
|
|
100
|
+
* }} SlowHookContext
|
|
93
101
|
*/
|
|
94
102
|
|
|
95
103
|
/**
|
|
96
|
-
* The parenthetical clause naming `context`'s
|
|
97
|
-
*
|
|
98
|
-
*
|
|
104
|
+
* The parenthetical clause naming `context`'s payload size and tool, or `""`
|
|
105
|
+
* when neither is known — so a caller with nothing to name gets the same notice
|
|
106
|
+
* text as one that passes no context at all. `cpuMs` is deliberately not here:
|
|
107
|
+
* it needs the sentence {@link slowHookNotice} gives it, not a bare number in a
|
|
108
|
+
* list of what was slow.
|
|
99
109
|
* @param {SlowHookContext | undefined} [context]
|
|
100
110
|
* @returns {string}
|
|
101
111
|
*/
|
|
@@ -108,10 +118,25 @@ function formatContextSuffix(context) {
|
|
|
108
118
|
return parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
/**
|
|
122
|
+
* This process's own user+system processor time so far, in milliseconds.
|
|
123
|
+
*
|
|
124
|
+
* `process.cpuUsage()` is RUSAGE_SELF: it counts what this node process
|
|
125
|
+
* computed and excludes both idle waiting and any child process. That is
|
|
126
|
+
* exactly the split the notice needs — a hook blocked on a socket, a lock or a
|
|
127
|
+
* loaded scheduler adds wall-clock here and no CPU.
|
|
128
|
+
* @returns {number}
|
|
129
|
+
*/
|
|
130
|
+
function processCpuMs() {
|
|
131
|
+
const { user, system } = process.cpuUsage();
|
|
132
|
+
return (user + system) / 1000;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Process-wide totals of wall-clock and CPU spent in one-time provisioning. A
|
|
136
|
+
// running total rather than a flag because a single hook run can pay more than
|
|
137
|
+
// one (a dependency wait AND a cold daemon spawn), and they may not nest.
|
|
114
138
|
let provisioningMs = 0;
|
|
139
|
+
let provisioningCpuMs = 0;
|
|
115
140
|
|
|
116
141
|
/**
|
|
117
142
|
* Run `work`, charging its whole duration to provisioning so no timer running
|
|
@@ -119,53 +144,89 @@ let provisioningMs = 0;
|
|
|
119
144
|
* that FAILS is still excluded — the wait happened either way, and a hook that
|
|
120
145
|
* then fails is reported through its fault posture, not as "slow".
|
|
121
146
|
*
|
|
147
|
+
* Its CPU is charged too, not just its wall-clock: the lazy dependency import
|
|
148
|
+
* this wraps is real in-process work, so leaving it in would hand the first
|
|
149
|
+
* call of every session a CPU figure it did not spend.
|
|
150
|
+
*
|
|
122
151
|
* Wrap only genuinely one-time, per-session setup: waiting out a dependency
|
|
123
152
|
* install, waiting for a cold redactor daemon to bind. Never wrap the hook's
|
|
124
153
|
* actual work — that is exactly what this measurement is for.
|
|
125
154
|
* @template T
|
|
126
155
|
* @param {() => Promise<T>} work
|
|
127
156
|
* @param {() => number} [now] injectable clock, for tests
|
|
157
|
+
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
128
158
|
* @returns {Promise<T>}
|
|
129
159
|
*/
|
|
130
|
-
export async function excludeProvisioning(
|
|
160
|
+
export async function excludeProvisioning(
|
|
161
|
+
work,
|
|
162
|
+
now = Date.now,
|
|
163
|
+
cpuNow = processCpuMs,
|
|
164
|
+
) {
|
|
131
165
|
const started = now();
|
|
166
|
+
const cpuStarted = cpuNow();
|
|
132
167
|
try {
|
|
133
168
|
return await work();
|
|
134
169
|
} finally {
|
|
135
170
|
provisioningMs += Math.max(0, now() - started);
|
|
171
|
+
provisioningCpuMs += Math.max(0, cpuNow() - cpuStarted);
|
|
136
172
|
}
|
|
137
173
|
}
|
|
138
174
|
|
|
139
175
|
/**
|
|
140
|
-
* Start measuring; the returned
|
|
141
|
-
* far MINUS any provisioning charged in the meantime, and may be called more
|
|
176
|
+
* Start measuring; each reader on the returned object reports what has elapsed
|
|
177
|
+
* so far MINUS any provisioning charged in the meantime, and may be called more
|
|
142
178
|
* than once.
|
|
143
179
|
*
|
|
180
|
+
* `wallMs` is what the user waited and `cpuMs` is what this process actually
|
|
181
|
+
* computed. Both are needed to say whose cost a slow run is — see the module
|
|
182
|
+
* header for the report that read a contended host as a sanitizer bug.
|
|
183
|
+
*
|
|
144
184
|
* Only provisioning charged since this timer started is subtracted, so an
|
|
145
185
|
* earlier run's cold start cannot pay down a later run's real cost. A
|
|
146
186
|
* provisioning window that straddles the timer's start would otherwise be able
|
|
147
|
-
* to subtract more than the timer has measured, so
|
|
187
|
+
* to subtract more than the timer has measured, so both results are floored
|
|
188
|
+
* at 0.
|
|
148
189
|
* @param {() => number} [now] injectable clock, for tests
|
|
149
|
-
* @
|
|
190
|
+
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
191
|
+
* @returns {{ wallMs: () => number, cpuMs: () => number }}
|
|
150
192
|
*/
|
|
151
|
-
export function startHookTimer(now = Date.now) {
|
|
193
|
+
export function startHookTimer(now = Date.now, cpuNow = processCpuMs) {
|
|
152
194
|
const started = now();
|
|
195
|
+
const cpuStarted = cpuNow();
|
|
153
196
|
const provisionedBefore = provisioningMs;
|
|
154
|
-
|
|
155
|
-
|
|
197
|
+
const provisionedCpuBefore = provisioningCpuMs;
|
|
198
|
+
return {
|
|
199
|
+
wallMs: () =>
|
|
200
|
+
Math.max(0, now() - started - (provisioningMs - provisionedBefore)),
|
|
201
|
+
cpuMs: () =>
|
|
202
|
+
Math.max(
|
|
203
|
+
0,
|
|
204
|
+
cpuNow() - cpuStarted - (provisioningCpuMs - provisionedCpuBefore),
|
|
205
|
+
),
|
|
206
|
+
};
|
|
156
207
|
}
|
|
157
208
|
|
|
158
209
|
/**
|
|
159
210
|
* The model-facing line for a hook that overran the budget, or null when it did
|
|
160
211
|
* not. Addressed to the model because the model is the only party that reliably
|
|
161
212
|
* reads this channel — stderr from a non-blocking hook is easy to miss — and it
|
|
162
|
-
* is asked to relay the
|
|
213
|
+
* is asked to relay the numbers, since the operator is the one who can file it.
|
|
214
|
+
*
|
|
215
|
+
* With `context.cpuMs` in hand the line says which share of the wait was the
|
|
216
|
+
* sanitizer computing. Without it the line says that it cannot tell, rather
|
|
217
|
+
* than asserting an attribution nothing measured: a wall-clock overrun on a
|
|
218
|
+
* loaded host is the common case, and blaming it on the sanitizer sends the
|
|
219
|
+
* operator hunting a per-call cost that does not exist.
|
|
220
|
+
*
|
|
221
|
+
* The wait clause names candidates and picks none, for the same reason. A hook
|
|
222
|
+
* that blocks on a dead socket inside a HOST extension spends no CPU and no
|
|
223
|
+
* machine load, so naming either as the cause would be a second wrong guess.
|
|
163
224
|
* @param {string} hookName
|
|
164
225
|
* @param {number} elapsedMs
|
|
165
226
|
* @param {number} [thresholdMs]
|
|
166
|
-
* @param {SlowHookContext} [context] known
|
|
167
|
-
* the notice is self-diagnosing rather than requiring the
|
|
168
|
-
* reconstruct what was slow by hand
|
|
227
|
+
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
228
|
+
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
229
|
+
* next reader to reconstruct what was slow by hand
|
|
169
230
|
* @returns {string | null}
|
|
170
231
|
*/
|
|
171
232
|
export function slowHookNotice(
|
|
@@ -175,11 +236,17 @@ export function slowHookNotice(
|
|
|
175
236
|
context,
|
|
176
237
|
) {
|
|
177
238
|
if (elapsedMs <= thresholdMs) return null;
|
|
239
|
+
const cpuMs = context?.cpuMs;
|
|
240
|
+
const attribution =
|
|
241
|
+
typeof cpuMs === "number"
|
|
242
|
+
? `, and used ${formatSeconds(cpuMs)}s of CPU. ` +
|
|
243
|
+
"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."
|
|
244
|
+
: ". Wall-clock alone cannot separate the sanitizer's own work from a busy machine.";
|
|
178
245
|
return (
|
|
179
246
|
`agent-sanitizer PERFORMANCE: the ${hookName} hook took ` +
|
|
180
|
-
`${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget
|
|
181
|
-
|
|
182
|
-
|
|
247
|
+
`${formatSeconds(elapsedMs)}s${formatContextSuffix(context)}, over its ${formatSeconds(thresholdMs)}s budget${attribution} ` +
|
|
248
|
+
`Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and ` +
|
|
249
|
+
`${typeof cpuMs === "number" ? "both timings" : "timing"}.`
|
|
183
250
|
);
|
|
184
251
|
}
|
|
185
252
|
|
|
@@ -188,11 +255,11 @@ export function slowHookNotice(
|
|
|
188
255
|
* {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
|
|
189
256
|
*
|
|
190
257
|
* Deliberately NOT {@link slowHookNotice} with a bigger threshold: that message
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
258
|
+
* splits the wait into a per-call share and machine contention, and neither
|
|
259
|
+
* reading is the one to take away here. What is actionable about a slow install
|
|
260
|
+
* is the installer (uv resolves in a fraction of pip's time) and the fact that a
|
|
261
|
+
* repeat means the idempotence check is broken — so this asks for a report only
|
|
262
|
+
* on the repeat, which is the version of this that is a bug.
|
|
196
263
|
*
|
|
197
264
|
* The one caller is the shell provisioner, whose port of this module
|
|
198
265
|
* (plugin/scripts/lib/hook-timing.sh) must emit this exact string; that port and
|
|
@@ -294,6 +361,7 @@ export function withSlowHookNotice(
|
|
|
294
361
|
* stdout envelope writer (hook-io's emitHookResponse); passed in rather than
|
|
295
362
|
* imported so this module stays dependency-free — see the module doc
|
|
296
363
|
* @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
|
|
364
|
+
* @param {SlowHookContext} [context] see {@link slowHookNotice}
|
|
297
365
|
* @returns {boolean} whether a notice was emitted
|
|
298
366
|
*/
|
|
299
367
|
export function reportSlowHook(
|
|
@@ -302,8 +370,9 @@ export function reportSlowHook(
|
|
|
302
370
|
hookEventName,
|
|
303
371
|
emit,
|
|
304
372
|
writeErr = (chunk) => process.stderr.write(chunk),
|
|
373
|
+
context,
|
|
305
374
|
) {
|
|
306
|
-
const notice = writeSlowHookNotice(hookName, elapsedMs, writeErr);
|
|
375
|
+
const notice = writeSlowHookNotice(hookName, elapsedMs, writeErr, context);
|
|
307
376
|
if (notice === null) return false;
|
|
308
377
|
emit(hookEventName, { additionalContext: notice });
|
|
309
378
|
return true;
|
|
@@ -412,15 +412,17 @@ export async function cliMain(opts = {}) {
|
|
|
412
412
|
// inside — it reads as "Claude is slow to start". Timing the whole body and
|
|
413
413
|
// reporting an overrun in band is what turned a 30-second scan from a rumor
|
|
414
414
|
// into a bug report (see lib/hook-timing.mjs).
|
|
415
|
-
const
|
|
415
|
+
const timer = startHookTimer();
|
|
416
416
|
try {
|
|
417
417
|
await runScanCli(opts);
|
|
418
418
|
} finally {
|
|
419
419
|
reportSlowHook(
|
|
420
420
|
HOOK_NAME,
|
|
421
|
-
|
|
421
|
+
timer.wallMs(),
|
|
422
422
|
HookEvent.SESSION_START,
|
|
423
423
|
emitHookResponse,
|
|
424
|
+
undefined,
|
|
425
|
+
{ cpuMs: timer.cpuMs() },
|
|
424
426
|
);
|
|
425
427
|
}
|
|
426
428
|
}
|
|
@@ -257,7 +257,7 @@ export { HOOK_NAME };
|
|
|
257
257
|
* @returns {Promise<void>}
|
|
258
258
|
*/
|
|
259
259
|
export async function cliMain({ trace: sink = trace } = {}) {
|
|
260
|
-
const
|
|
260
|
+
const timer = startHookTimer();
|
|
261
261
|
const emitTrace = bestEffortTrace(sink);
|
|
262
262
|
try {
|
|
263
263
|
const payload = await readStdinJson();
|
|
@@ -310,9 +310,11 @@ export async function cliMain({ trace: sink = trace } = {}) {
|
|
|
310
310
|
} finally {
|
|
311
311
|
reportSlowHook(
|
|
312
312
|
HOOK_NAME,
|
|
313
|
-
|
|
313
|
+
timer.wallMs(),
|
|
314
314
|
HookEvent.INSTRUCTIONS_LOADED,
|
|
315
315
|
emitHookResponse,
|
|
316
|
+
undefined,
|
|
317
|
+
{ cpuMs: timer.cpuMs() },
|
|
316
318
|
);
|
|
317
319
|
}
|
|
318
320
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.43.
|
|
3
|
+
"version": "2.43.9",
|
|
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": {
|
|
@@ -27,39 +27,63 @@ export function formatBytes(bytes: number): string;
|
|
|
27
27
|
* that FAILS is still excluded — the wait happened either way, and a hook that
|
|
28
28
|
* then fails is reported through its fault posture, not as "slow".
|
|
29
29
|
*
|
|
30
|
+
* Its CPU is charged too, not just its wall-clock: the lazy dependency import
|
|
31
|
+
* this wraps is real in-process work, so leaving it in would hand the first
|
|
32
|
+
* call of every session a CPU figure it did not spend.
|
|
33
|
+
*
|
|
30
34
|
* Wrap only genuinely one-time, per-session setup: waiting out a dependency
|
|
31
35
|
* install, waiting for a cold redactor daemon to bind. Never wrap the hook's
|
|
32
36
|
* actual work — that is exactly what this measurement is for.
|
|
33
37
|
* @template T
|
|
34
38
|
* @param {() => Promise<T>} work
|
|
35
39
|
* @param {() => number} [now] injectable clock, for tests
|
|
40
|
+
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
36
41
|
* @returns {Promise<T>}
|
|
37
42
|
*/
|
|
38
|
-
export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => number): Promise<T>;
|
|
43
|
+
export function excludeProvisioning<T>(work: () => Promise<T>, now?: () => number, cpuNow?: () => number): Promise<T>;
|
|
39
44
|
/**
|
|
40
|
-
* Start measuring; the returned
|
|
41
|
-
* far MINUS any provisioning charged in the meantime, and may be called more
|
|
45
|
+
* Start measuring; each reader on the returned object reports what has elapsed
|
|
46
|
+
* so far MINUS any provisioning charged in the meantime, and may be called more
|
|
42
47
|
* than once.
|
|
43
48
|
*
|
|
49
|
+
* `wallMs` is what the user waited and `cpuMs` is what this process actually
|
|
50
|
+
* computed. Both are needed to say whose cost a slow run is — see the module
|
|
51
|
+
* header for the report that read a contended host as a sanitizer bug.
|
|
52
|
+
*
|
|
44
53
|
* Only provisioning charged since this timer started is subtracted, so an
|
|
45
54
|
* earlier run's cold start cannot pay down a later run's real cost. A
|
|
46
55
|
* provisioning window that straddles the timer's start would otherwise be able
|
|
47
|
-
* to subtract more than the timer has measured, so
|
|
56
|
+
* to subtract more than the timer has measured, so both results are floored
|
|
57
|
+
* at 0.
|
|
48
58
|
* @param {() => number} [now] injectable clock, for tests
|
|
49
|
-
* @
|
|
59
|
+
* @param {() => number} [cpuNow] injectable CPU clock, for tests
|
|
60
|
+
* @returns {{ wallMs: () => number, cpuMs: () => number }}
|
|
50
61
|
*/
|
|
51
|
-
export function startHookTimer(now?: () => number
|
|
62
|
+
export function startHookTimer(now?: () => number, cpuNow?: () => number): {
|
|
63
|
+
wallMs: () => number;
|
|
64
|
+
cpuMs: () => number;
|
|
65
|
+
};
|
|
52
66
|
/**
|
|
53
67
|
* The model-facing line for a hook that overran the budget, or null when it did
|
|
54
68
|
* not. Addressed to the model because the model is the only party that reliably
|
|
55
69
|
* reads this channel — stderr from a non-blocking hook is easy to miss — and it
|
|
56
|
-
* is asked to relay the
|
|
70
|
+
* is asked to relay the numbers, since the operator is the one who can file it.
|
|
71
|
+
*
|
|
72
|
+
* With `context.cpuMs` in hand the line says which share of the wait was the
|
|
73
|
+
* sanitizer computing. Without it the line says that it cannot tell, rather
|
|
74
|
+
* than asserting an attribution nothing measured: a wall-clock overrun on a
|
|
75
|
+
* loaded host is the common case, and blaming it on the sanitizer sends the
|
|
76
|
+
* operator hunting a per-call cost that does not exist.
|
|
77
|
+
*
|
|
78
|
+
* The wait clause names candidates and picks none, for the same reason. A hook
|
|
79
|
+
* that blocks on a dead socket inside a HOST extension spends no CPU and no
|
|
80
|
+
* machine load, so naming either as the cause would be a second wrong guess.
|
|
57
81
|
* @param {string} hookName
|
|
58
82
|
* @param {number} elapsedMs
|
|
59
83
|
* @param {number} [thresholdMs]
|
|
60
|
-
* @param {SlowHookContext} [context] known
|
|
61
|
-
* the notice is self-diagnosing rather than requiring the
|
|
62
|
-
* reconstruct what was slow by hand
|
|
84
|
+
* @param {SlowHookContext} [context] known CPU time / payload size /
|
|
85
|
+
* triggering tool, so the notice is self-diagnosing rather than requiring the
|
|
86
|
+
* next reader to reconstruct what was slow by hand
|
|
63
87
|
* @returns {string | null}
|
|
64
88
|
*/
|
|
65
89
|
export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext): string | null;
|
|
@@ -68,11 +92,11 @@ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?
|
|
|
68
92
|
* {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not.
|
|
69
93
|
*
|
|
70
94
|
* Deliberately NOT {@link slowHookNotice} with a bigger threshold: that message
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
95
|
+
* splits the wait into a per-call share and machine contention, and neither
|
|
96
|
+
* reading is the one to take away here. What is actionable about a slow install
|
|
97
|
+
* is the installer (uv resolves in a fraction of pip's time) and the fact that a
|
|
98
|
+
* repeat means the idempotence check is broken — so this asks for a report only
|
|
99
|
+
* on the repeat, which is the version of this that is a bug.
|
|
76
100
|
*
|
|
77
101
|
* The one caller is the shell provisioner, whose port of this module
|
|
78
102
|
* (plugin/scripts/lib/hook-timing.sh) must emit this exact string; that port and
|
|
@@ -135,30 +159,31 @@ export function withSlowHookNotice<V extends {
|
|
|
135
159
|
* stdout envelope writer (hook-io's emitHookResponse); passed in rather than
|
|
136
160
|
* imported so this module stays dependency-free — see the module doc
|
|
137
161
|
* @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests
|
|
162
|
+
* @param {SlowHookContext} [context] see {@link slowHookNotice}
|
|
138
163
|
* @returns {boolean} whether a notice was emitted
|
|
139
164
|
*/
|
|
140
|
-
export function reportSlowHook(hookName: string, elapsedMs: number, hookEventName: string, emit: (event: string, fields: Record<string, unknown>) => void, writeErr?: (chunk: string) => void): boolean;
|
|
165
|
+
export function reportSlowHook(hookName: string, elapsedMs: number, hookEventName: string, emit: (event: string, fields: Record<string, unknown>) => void, writeErr?: (chunk: string) => void, context?: SlowHookContext): boolean;
|
|
141
166
|
/**
|
|
142
|
-
* The one place a hook's own
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* SessionStart scan blocked startup for 30 SECONDS
|
|
149
|
-
* here). A hook past the budget therefore says so
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* this notice
|
|
167
|
+
* The one place a hook's own cost is measured and reported — one threshold, one
|
|
168
|
+
* message, one merge rule, shared by every hook.
|
|
169
|
+
*
|
|
170
|
+
* These hooks sit on the critical path of every tool call, prompt and session
|
|
171
|
+
* start: whatever they spend, the user waits. A slow hook is also the hardest
|
|
172
|
+
* bug to notice from inside — it looks exactly like a slow agent, so it goes
|
|
173
|
+
* unreported for weeks (one SessionStart scan blocked startup for 30 SECONDS
|
|
174
|
+
* before anyone traced it back here). A hook past the budget therefore says so
|
|
175
|
+
* IN BAND, in the model's context, where it can be relayed to the operator.
|
|
176
|
+
*
|
|
177
|
+
* TWO numbers, because wall-clock alone cannot say whose cost it is: a hook on
|
|
178
|
+
* a contended host waits far longer than it computes (a 1.1 KB payload and a
|
|
179
|
+
* 235 KB one both reported 7.2s on a loaded 2-vCPU box, against 0.3s of work).
|
|
180
|
+
* So the notice prints CPU beside the clock — the share every affected call
|
|
181
|
+
* repeats — and never GATES on it: a hook wedged on a dead redactor socket
|
|
182
|
+
* burns no CPU and is exactly the sanitizer's fault.
|
|
183
|
+
*
|
|
184
|
+
* ONE-TIME PROVISIONING is excluded (see {@link excludeProvisioning}): charging
|
|
185
|
+
* an install to the hook that merely waited it out would make the FIRST call of
|
|
186
|
+
* every session cry wolf, which is the alert fatigue this notice fights.
|
|
162
187
|
*
|
|
163
188
|
* Dependency-free on purpose: everything imports this, including hook-io, so a
|
|
164
189
|
* back-import would close a cycle. The one emitter it needs is passed in.
|
|
@@ -168,8 +193,9 @@ export function reportSlowHook(hookName: string, elapsedMs: number, hookEventNam
|
|
|
168
193
|
*
|
|
169
194
|
* A second is far above anything these hooks do when healthy (Layer 1 is a few
|
|
170
195
|
* regex passes; the redactor daemon answers in tens of milliseconds once warm)
|
|
171
|
-
* and far below the point where a human is merely impatient
|
|
172
|
-
*
|
|
196
|
+
* and far below the point where a human is merely impatient. Crossing it means
|
|
197
|
+
* the user waited that long, which is worth saying either way; whether the
|
|
198
|
+
* sanitizer or a busy machine spent it is what the CPU figure answers.
|
|
173
199
|
*/
|
|
174
200
|
export const SLOW_HOOK_THRESHOLD_MS: 1000;
|
|
175
201
|
/**
|
|
@@ -191,8 +217,12 @@ export const SLOW_PROVISION_THRESHOLD_MS: 60000;
|
|
|
191
217
|
* that made this specific latency report take a manual multi-step
|
|
192
218
|
* investigation to characterize (which tool call, how large a payload) before
|
|
193
219
|
* anyone could act on it.
|
|
220
|
+
* `cpuMs` is the run's own processor time (see {@link startHookTimer}); absent
|
|
221
|
+
* when the caller has no way to measure it, which is what the shell port of
|
|
222
|
+
* this module reports.
|
|
194
223
|
*/
|
|
195
224
|
export type SlowHookContext = {
|
|
196
225
|
payloadBytes?: number | null;
|
|
197
226
|
tool?: string | null;
|
|
227
|
+
cpuMs?: number | null;
|
|
198
228
|
};
|