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 {(() => number) | null} */
174
- let elapsed = null;
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
- elapsed = startHookTimer();
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, `elapsed()`
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, elapsed(), judged, undefined, {
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 (elapsed !== null)
212
- writeSlowHookNotice(hookName, elapsed(), undefined, {
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 wall-clock cost is measured and reported.
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, every prompt and
5
- * every session start: whatever they spend, the user waits. That cost is also
6
- * the hardest kind of bug to notice from inside — a hook that got slow looks
7
- * exactly like an agent that got slow, so it goes unreported for weeks (one
8
- * SessionStart scan blocked startup for 30 SECONDS before anyone traced it back
9
- * here). A hook past the budget therefore says so IN BAND, in the model's
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
- * One threshold, one message, one merge rule, shared by every hook — the
13
- * measurement is worthless if each hook words it differently or picks its own
14
- * bar for "slow".
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
- * What it deliberately does NOT count is ONE-TIME PROVISIONING (see
17
- * {@link excludeProvisioning}). A dependency-install wait or a cold redactor
18
- * spawn is wall-clock the user really waits, but it is not a cost this hook
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 so crossing it
33
- * means something is actually wrong, not that the machine is busy.
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
- * @typedef {{ payloadBytes?: number | null, tool?: string | null }} SlowHookContext
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 known fields, or `""` when
97
- * `context` is absent or carries neither — so a caller with no context to give
98
- * gets the exact same notice text as before this existed.
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
- // Process-wide total of milliseconds spent in one-time provisioning. A running
112
- // total rather than a flag because a single hook run can pay more than one (a
113
- // dependency wait AND a cold daemon spawn), and they may not nest.
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(work, now = Date.now) {
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 function reports the milliseconds elapsed so
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 the result is floored at 0.
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
- * @returns {() => number}
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
- return () =>
155
- Math.max(0, now() - started - (provisioningMs - provisionedBefore));
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 number, since the operator is the one who can file it.
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 payload size / triggering tool, so
167
- * the notice is self-diagnosing rather than requiring the next reader to
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
- "this delay is the sanitizer's, not the model's, and every affected call pays it. " +
182
- `Tell the user, and suggest they report it at ${ISSUE_URL} with the hook name and timing.`
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
- * says "every affected call pays it", which is false here and would send the
192
- * reader hunting a per-call cost that does not exist. What is actionable about a
193
- * slow install is the installer (uv resolves in a fraction of pip's time) and
194
- * the fact that a repeat means the idempotence check is broken — so this asks
195
- * for a report only on the repeat, which is the version of this that is a bug.
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 elapsed = startHookTimer();
415
+ const timer = startHookTimer();
416
416
  try {
417
417
  await runScanCli(opts);
418
418
  } finally {
419
419
  reportSlowHook(
420
420
  HOOK_NAME,
421
- elapsed(),
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 elapsed = startHookTimer();
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
- elapsed(),
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.7",
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 function reports the milliseconds elapsed so
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 the result is floored at 0.
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
- * @returns {() => number}
59
+ * @param {() => number} [cpuNow] injectable CPU clock, for tests
60
+ * @returns {{ wallMs: () => number, cpuMs: () => number }}
50
61
  */
51
- export function startHookTimer(now?: () => number): () => 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 number, since the operator is the one who can file it.
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 payload size / triggering tool, so
61
- * the notice is self-diagnosing rather than requiring the next reader to
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
- * says "every affected call pays it", which is false here and would send the
72
- * reader hunting a per-call cost that does not exist. What is actionable about a
73
- * slow install is the installer (uv resolves in a fraction of pip's time) and
74
- * the fact that a repeat means the idempotence check is broken — so this asks
75
- * for a report only on the repeat, which is the version of this that is a bug.
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 wall-clock cost is measured and reported.
143
- *
144
- * These hooks sit on the critical path of every tool call, every prompt and
145
- * every session start: whatever they spend, the user waits. That cost is also
146
- * the hardest kind of bug to notice from inside — a hook that got slow looks
147
- * exactly like an agent that got slow, so it goes unreported for weeks (one
148
- * SessionStart scan blocked startup for 30 SECONDS before anyone traced it back
149
- * here). A hook past the budget therefore says so IN BAND, in the model's
150
- * context, where it cannot be missed and can be relayed to the operator.
151
- *
152
- * One threshold, one message, one merge rule, shared by every hook — the
153
- * measurement is worthless if each hook words it differently or picks its own
154
- * bar for "slow".
155
- *
156
- * What it deliberately does NOT count is ONE-TIME PROVISIONING (see
157
- * {@link excludeProvisioning}). A dependency-install wait or a cold redactor
158
- * spawn is wall-clock the user really waits, but it is not a cost this hook
159
- * pays per call and it is not a bug worth a report — charging it would make the
160
- * FIRST call of every session cry wolf, which is precisely the alert fatigue
161
- * this notice exists to avoid.
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 so crossing it
172
- * means something is actually wrong, not that the machine is busy.
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
  };