/** * {@link readVersion}, computed once per process — the notice fires on a * vanishing fraction of runs, and every hook imports this module on the hot * path, so the manifest is read only once something is being reported. * @returns {string | null} */ export function sanitizerVersion(): string | null; /** * Milliseconds as the seconds string every notice below prints. * * Rounds tenths half-UP from an exact integer count of hundredths, rather than * `(ms / 1000).toFixed(1)`: the shell port of this module * (plugin/scripts/lib/hook-timing.sh) has to produce the byte-identical string * with integer arithmetic, and `toFixed` rounds the underlying double — so 1150 * would print "1.1" here (1.15 is below its decimal value as a double) and "1.2" * there. `ms / 100` lands exactly on a half only when `ms` ends in 50, and every * such quotient is dyadic, so this rounding is exact for every input. * @param {number} ms * @returns {string} */ export function formatSeconds(ms: number): string; /** * Bytes as a human-scaled string (B / KB / MB, one decimal past B) for the * slow-hook notice's payload clause. No shell-parity constraint applies here — * unlike {@link formatSeconds}, the shell port never has a payload size to * print (see plugin/scripts/lib/hook-timing.sh's header). * @param {number} bytes * @returns {string} */ export function formatBytes(bytes: number): string; /** * Run `work` — one redactor round trip — charging its duration to the redactor * share, so a run that spent its second inside a redaction call is told apart * from one that spent it anywhere else. Charged in a `finally`, since a round * trip that THROWS (a stall that hit its deadline) is the one that spent the * most. * * Unlike {@link excludeProvisioning} this only attributes; the time stays in the * hook's wall-clock, because a slow redaction is a per-call cost the user waits * for. * @template T * @param {() => Promise} work * @param {() => number} [now] injectable clock, for tests * @returns {Promise} */ export function chargeRedactorRoundTrip(work: () => Promise, now?: () => number): Promise; /** * Run `work` — one call into a HOST EXTENSION (a composer's `postText`, `audit` * or other injected callback) — charging its duration to the host share, so a run * that spent its second inside a callback is told apart from one that spent it * anywhere else. Charged in a `finally`, since a callback that THROWS is the one * that spent the most. * * Like {@link chargeRedactorRoundTrip} this only attributes; the time stays in * the hook's wall-clock, because the user waits for it either way. It says WHERE * the time went and declines to say WHOSE: the wait holds the callback's own work * AND whatever descheduling the host imposed on it. * * `work` may be synchronous — a callback that spawns a subprocess and blocks is * charged in full, because the whole call runs inside this bracket. * * Reach this through `claude-hooks/sanitize-output`'s re-export whenever the hook * is the bundled copy: importing this subpath separately yields a SECOND module * instance whose total no timer reads, and the notice then reports a measured * `0.0s` for a window that really burned seconds. * @template T * @param {() => Promise | T} work * @param {() => number} [now] injectable clock, for tests * @returns {Promise} */ export function chargeHostExtension(work: () => Promise | T, now?: () => number, cpuNow?: typeof processCpuMs): Promise; /** * {@link chargeHostExtension} for a callback that must answer SYNCHRONOUSLY — the * `redactNote` seam returns a string, so an async wrapper cannot stand in for it, * and a composer that spawns a subprocess there would otherwise leave the wait in * the unattributed remainder and its CPU charged to the sanitizer. * * Nesting and CPU are handled exactly as above, so the two chargers compose: a * sync callback invoked inside an async bracket charges nothing twice. * @template T * @param {() => T} work * @param {() => number} [now] injectable clock, for tests * @param {() => number} [cpuNow] injectable CPU clock, for tests * @returns {T} */ export function chargeHostExtensionSync(work: () => T, now?: () => number, cpuNow?: () => number): T; /** * Run `work`, charging its whole duration to provisioning so no timer running * across it counts that time. Charged in a `finally`, so a provisioning step * that FAILS is still excluded — the wait happened either way, and a hook that * then fails is reported through its fault posture, not as "slow". * * Its CPU is charged too, not just its wall-clock: the lazy dependency import * this wraps is real in-process work, so leaving it in would hand the first * call of every session a CPU figure it did not spend. * * Wrap only genuinely one-time, per-session setup: waiting out a dependency * install, waiting for a cold redactor daemon to bind. Never wrap the hook's * actual work — that is exactly what this measurement is for. * @template T * @param {() => Promise} work * @param {() => number} [now] injectable clock, for tests * @param {() => number} [cpuNow] injectable CPU clock, for tests * @returns {Promise} */ export function excludeProvisioning(work: () => Promise, now?: () => number, cpuNow?: () => number): Promise; /** * Run `work`, charging its duration to provisioning when a SESSION-LEVEL * provisioning step was in flight for the whole of it — a neighbouring hook's * dependency install, which saturates the machine this hook is only sharing. * {@link excludeProvisioning} discounts the wait this process performs itself; * this discounts the one it merely runs alongside. * * `setupAlive` is asked twice, before and after, and only a step alive at BOTH * ends is charged. A step that started or finished mid-run leaves a window * nothing here can apportion, and splitting it by guess would discount the * hook's own work — so that run is measured in full and reports honestly. * * Wall-clock only, where {@link excludeProvisioning} charges CPU too: the * install runs in ANOTHER process, so none of it lands in this one's CPU figure, * and charging CPU here would discount the hook's own computing. * * Bounded by {@link CONCURRENT_PROVISION_CEILING_MS}, which is what stops the * discount from hiding a wedged run: a window past the ceiling is charged to * nobody but the hook, however busy the machine was, because at that magnitude * the hook is the thing that is broken. * @template T * @param {() => Promise} work * @param {() => boolean} setupAlive whether a session-level provisioning step * is running right now; the caller owns the evidence (a cold-start marker and * its PID), since this module reads no files of its own * @param {() => number} [now] injectable clock, for tests * @returns {Promise} */ export function excludeConcurrentProvisioning(work: () => Promise, setupAlive: () => boolean, now?: () => number): Promise; /** * Start measuring; each reader on the returned object reports what has elapsed * so far MINUS any provisioning charged in the meantime, and may be called more * than once. * * `wallMs` is what the user waited, `cpuMs` is what this process computed OUTSIDE * a host callback, `redactorMs` is what it spent inside redactor round trips * ({@link chargeRedactorRoundTrip}) and `hostMs` what it spent inside host * extensions ({@link chargeHostExtension}). All four are needed to say where a * slow run's time went — see the module header for the report that read a contended * host as a sanitizer bug. * * Every reader counts only what was charged since this timer started, so an * earlier run's cold start cannot pay down a later run's real cost, and an * earlier run's round trip cannot be blamed on this one. A provisioning window * that straddles the timer's start would otherwise be able to subtract more than * the timer has measured, so the results are floored at 0. * @param {() => number} [now] injectable clock, for tests * @param {() => number} [cpuNow] injectable CPU clock, for tests * @returns {{ wallMs: () => number, cpuMs: () => number, redactorMs: () => number, hostMs: () => number }} */ export function startHookTimer(now?: () => number, cpuNow?: () => number): { wallMs: () => number; cpuMs: () => number; redactorMs: () => number; hostMs: () => number; }; /** * The model-facing line for a hook that overran the budget, or null when it did * not. Addressed to the model because the model is the only party that reliably * reads this channel — stderr from a non-blocking hook is easy to miss — and it * is asked to relay the numbers, since the operator is the one who can file it. * * With `context.cpuMs` in hand the line says which share of the wait was the * sanitizer computing, and with `context.redactorMs` too it names the window the * time went into ({@link attributeWait}), including `context.hostMs`'s host * extensions — a caller that measured the first two but has no extensions to * charge reports that window as zero, which is a measurement and not a shrug. Without them the line says that it cannot * tell, rather than asserting an attribution nothing measured: a wall-clock * overrun on a loaded host is the common case, and blaming it on the sanitizer * sends the operator hunting a per-call cost that does not exist. * * A CPU figure alone cannot pick a cause, so with only that the clause names * candidates and commits to none. A hook that blocks on a dead socket inside a * HOST extension spends no CPU and adds no machine load, so naming either as the * cause would be a second wrong guess. * @param {string} hookName * @param {number} elapsedMs * @param {number} [thresholdMs] * @param {SlowHookContext} [context] known CPU time / payload size / * triggering tool, so the notice is self-diagnosing rather than requiring the * next reader to reconstruct what was slow by hand * @param {string | null} [version] the build to name in the report line; * omitted asks {@link sanitizerVersion}, and the shell port passes its own, * read from the plugin manifest it ships beside * @returns {string | null} */ export function slowHookNotice(hookName: string, elapsedMs: number, thresholdMs?: number, context?: SlowHookContext, version?: string | null): string | null; /** * The line for a ONE-TIME provisioning step that overran * {@link SLOW_PROVISION_THRESHOLD_MS}, or null when it did not. * * Deliberately NOT {@link slowHookNotice} with a bigger threshold: that message * splits the wait into a per-call share and machine contention, and neither * reading is the one to take away here. What is actionable about a slow install * is the installer (uv resolves in a fraction of pip's time) and the fact that a * repeat means the idempotence check is broken — so this asks for a report only * on the repeat, which is the version of this that is a bug. * * The one caller is the shell provisioner, whose port of this module * (plugin/scripts/lib/hook-timing.sh) must emit this exact string; that port and * this definition are pinned to each other by a contract test rather than left * as two independently-worded copies. * @param {string} stepName * @param {number} elapsedMs * @param {number} [thresholdMs] * @param {string} [advice] step-specific speedup advice — the default fits the * engine install; the hook-binary download passes its own, because telling a * user mid-download that uv would help is advice about the wrong step * @param {string | null} [version] see {@link slowHookNotice} * @returns {string | null} */ export function slowProvisionNotice(stepName: string, elapsedMs: number, thresholdMs?: number, advice?: string, version?: string | null): string | null; /** * Write the slow-hook notice to stderr and return it, or return null when the * run was within budget (writing nothing, so the quiet path stays quiet). * * The one place the notice reaches stderr: every reporter below needs the * transcript copy, and a hook whose run ENDED IN AN ERROR has nothing but this — * its verdict is the fail-closed one its `onError` composed, and diluting that * message with a performance aside would bury the fault. A judge that spent * thirty seconds and then threw is exactly the case the timing exists to name, * so the error path measures and reports; it just reports on the human channel. * @param {string} hookName * @param {number} elapsedMs * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests * @param {SlowHookContext} [context] see {@link slowHookNotice} * @returns {string | null} */ export function writeSlowHookNotice(hookName: string, elapsedMs: number, writeErr?: (chunk: string) => void, context?: SlowHookContext): string | null; /** * `verdict` with the slow-hook notice folded into its `additional_context`, or * the verdict untouched when the run was within budget. Also writes the notice * to stderr, so the timing survives in the transcript even for a hook whose * verdict carries no context channel to the model. * * Appended, never substituted: the context slot is how a hook reports a REDACTED * secret or a stripped payload, and a timing note must not displace that. * @template {{ additional_context?: string }} V * @param {string} hookName * @param {number} elapsedMs * @param {V} verdict * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests * @param {SlowHookContext} [context] see {@link slowHookNotice} * @returns {V} */ export function withSlowHookNotice(hookName: string, elapsedMs: number, verdict: V, writeErr?: (chunk: string) => void, context?: SlowHookContext): V; /** * Report a slow run for a hook that answers with a bare `hookSpecificOutput` * envelope rather than a control-plane verdict — SessionStart, which has no * verdict channel at all. A within-budget run emits nothing, so the quiet path * stays quiet (and the hook's silent-success contract is unchanged). * @param {string} hookName * @param {number} elapsedMs * @param {string} hookEventName * @param {(event: string, fields: Record) => void} emit the * stdout envelope writer (hook-io's emitHookResponse); passed in rather than * imported so this module stays dependency-free — see the module doc * @param {(chunk: string) => void} [writeErr] injectable stderr sink, for tests * @param {SlowHookContext} [context] see {@link slowHookNotice} * @returns {boolean} whether a notice was emitted */ export function reportSlowHook(hookName: string, elapsedMs: number, hookEventName: string, emit: (event: string, fields: Record) => void, writeErr?: (chunk: string) => void, context?: SlowHookContext): boolean; /** * Wall-clock a single hook invocation may spend before it is reported as slow. * * A second is far above anything these hooks do when healthy (Layer 1 is a few * regex passes; the redactor daemon answers in tens of milliseconds once warm) * and far below the point where a human is merely impatient. Crossing it means * the user waited that long, which is worth saying either way; whether the * sanitizer or a busy machine spent it is what the CPU figure answers. */ export const SLOW_HOOK_THRESHOLD_MS: 1000; /** * Wall-clock a ONE-TIME provisioning step may spend before it is reported. * * Two orders of magnitude above {@link SLOW_HOOK_THRESHOLD_MS}, because it * measures something categorically different: a dependency install that a * session pays once, not a cost every tool call repeats. A cold `uv` install of * the redactor engine is seconds and a cold `pip` one can be tens of them, so a * budget anywhere near a second would report every first session — the alert * fatigue this whole module exists to avoid. Past a minute, something is * actually wrong (a serial pip resolve, a wedged mirror, or an idempotence bug * re-provisioning every session), which is worth saying out loud. */ export const SLOW_PROVISION_THRESHOLD_MS: 60000; /** * The longest window {@link excludeConcurrentProvisioning} will discount. * * Ten times the hook budget, because the discount's whole premise is that the * hook's work is small and the machine is busy: an instruction scan is tens of * milliseconds of work, so a wait this far past its budget is not a busy box any * more, whatever else is installing. Past the ceiling the run is measured in * full and reports — the founding case of this module is a SessionStart scan * that blocked startup for 30 SECONDS, and a cold-start install running * alongside it must not be what buys that silence. */ export const CONCURRENT_PROVISION_CEILING_MS: number; /** * Debugging context a caller may already have in hand when a hook overruns its * budget, so the notice names WHAT was slow instead of just HOW slow — the gap * that made this specific latency report take a manual multi-step * investigation to characterize (which tool call, how large a payload) before * anyone could act on it. * `cpuMs` is the run's own processor time, `redactorMs` the wall-clock it spent * inside redactor round trips, and `hostMs` the wall-clock it spent inside host * extensions (all from {@link startHookTimer}); absent when the caller has no way * to measure them, which is what the shell port of this module reports. */ export type SlowHookContext = { payloadBytes?: number | null; tool?: string | null; cpuMs?: number | null; redactorMs?: number | null; hostMs?: number | null; }; /** * This process's own user+system processor time so far, in milliseconds. * * `process.cpuUsage()` is RUSAGE_SELF: it counts what this node process * computed and excludes both idle waiting and any child process. That is * exactly the split the notice needs — a hook blocked on a socket, a lock or a * loaded scheduler adds wall-clock here and no CPU. * @returns {number} */ declare function processCpuMs(): number; export {};