@sema-agent/core 7.32.1 → 7.33.1

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.
@@ -1,14 +1,20 @@
1
1
  import type { StreamFn } from "../internal/llm.js";
2
2
  /**
3
- * Tiered timeouts (1.38.1, design/17 §5B). The task-level `timeoutSec` only bounds the *whole* run;
4
- * it can't tell apart a gateway that never accepts the connection from one that accepts it, opens the
5
- * SSE stream, then hangs forever without emitting a token (the most common gateway-stall symptom).
6
- * Two finer levels close that gap, each retryable as a transient `network` failure:
3
+ * Tiered timeouts (design/17 §5B; re-cut along the segments of a call in 7.33.0). The task-level
4
+ * `timeoutSec` only bounds the *whole* run; it cannot tell apart a gateway that never completes the
5
+ * handshake, one that accepts the request and then holds the response headers while it works, one
6
+ * that opens the stream and never emits a token, and one that goes quiet mid-answer. Four clocks,
7
+ * one per segment, each named for the segment it bounds:
7
8
  *
8
- * - **connect**: `fetch` hasn't returned response headers in time.
9
- * - **first-token**: the SSE stream is open but no content delta has arrived (see brain read loops).
9
+ * - **connect**: TCP + TLS establishment inside `fetch` — the transport's own clock (undici's 10s by
10
+ * default), tuned on the process dispatcher the host installs; not a knob here (`transport.ts`).
11
+ * - **first-byte**: the `fetch()` call → response headers. A stall here is this process cancelling
12
+ * its own request at a deadline it chose, and is announced as such.
13
+ * - **first-token**: headers → the first content delta (a thinking delta counts).
14
+ * - **idle**: silence between deltas once output has started.
10
15
  *
11
- * Both default to `undefined` (disabled) so existing behavior is unchanged.
16
+ * Every field resolves through the same chain: construction config (an explicit `0` is OFF) >
17
+ * per-call advisory (`StreamOptions.stallTimeouts`) > the engine default constant below.
12
18
  */
13
19
  /**
14
20
  * The largest delay a host timer can actually wait for. Past this `setTimeout` does not schedule far in
@@ -28,55 +34,117 @@ export declare const MAX_TIMER_DELAY_MS = 2147483647;
28
34
  */
29
35
  export declare function resolveStallTimeoutMs(value: number | undefined, knob: string): number | undefined;
30
36
  /**
31
- * The engine's ADVISORY per-call stall watchdog values (`StreamOptions.stallTimeouts`), supplied on
32
- * every provider call. They are a GUARDRAIL, not part of any budget lane: `BrainTimeoutConfig` defaults
33
- * every watchdog to OFF, so without these an in-flight call that goes silent is bounded by nothing
34
- * finer than the hour-scale {@link BRAIN_CALL_GUARDRAIL_DEFAULT_MS} (a measured production frame sat
35
- * 680s with ZERO deltas). A silent stream is never worth more than these bounds, whether or not the
36
- * task declared a time limit. All three surface as the brain's existing retryable `network` stall
37
- * class (design/124 tier C retries a zero-content cut, so a transient gateway hang still recovers). 量纲:
38
- * - connect 30s: bounds the fetch→response-HEADERS wait — the one segment every post-headers
39
- * watchdog is blind to. On a streaming API the model's time is spent AFTER headers, so 30s with
40
- * none at all is a hung gateway, not a slow model.
37
+ * The engine's DEFAULT watchdog values, one per segment of a model call — the third and last level of
38
+ * the chain every brain reads (`BrainTimeoutConfig` field > per-call advisory `StreamOptions.stallTimeouts`
39
+ * > these). The Runner supplies these same numbers as the advisory on every call, so a Brain supplied by
40
+ * the deployment (BYOM) is told what the engine would arm; a first-party brain arms them itself when
41
+ * neither a config field nor an advisory names the segment. A silent call is therefore always bounded by
42
+ * something finer than the hour-scale {@link BRAIN_CALL_GUARDRAIL_DEFAULT_MS} (a measured production
43
+ * frame once sat 680s with ZERO deltas) unless a deployment switched the watchdog for that segment off
44
+ * by name — an explicit `0` is the only spelling of OFF. The TCP + TLS connect has no row here: it is
45
+ * the transport's own clock (`transport.ts`), not a segment the engine can observe. 量纲:
46
+ * - first-byte 300s: the `fetch()` call → response headers. A self-hosted gateway that runs its
47
+ * prefill BEFORE writing headers is silent here for minutes and healthy; the 30s "connect" clock
48
+ * this replaces cut that prefill and re-sent the same body ten times. 300s is the reference
49
+ * implementation's window for a non-first-party route; the window grows with the request body
50
+ * ({@link firstByteWindow}) and the one retry is widened ({@link FIRST_BYTE_RETRY_WINDOW_FACTOR}).
41
51
  * - first-token 600s: bounds the headers→first-delta wait, NOT the model's thought (the first
42
- * THINKING delta already counts as the first token, design/17 O5 — see
43
- * {@link BrainTimeoutConfig.firstTokenTimeoutMs}), so a slow reasoner is safe. A minutes-scale
44
- * bound would be FALSE for a real class of backend: a self-hosted server working through a long
45
- * prefill emits nothing at all for 2–3 minutes, and cancelling it burns the prefill and re-sends
46
- * a request that burns it again — a guardrail whose reason is false for healthy traffic is not a
47
- * guardrail. 600s is the same order of magnitude as the reference implementation's own request
48
- * timeout, and the shapes this bound exists for (a gateway that accepted the request and then
49
- * died) are still caught, just later; the task's own hard abort remains the outer bound.
50
- * HONEST CEILING (measured): this watchdog is not the only clock over a
51
- * byte-silent stream, so 600s is the bound this engine ASKS FOR, not the silence a deployment is
52
- * necessarily granted. On Node's own `fetch` — the default transport for both adapters, and the
53
- * one a deployment gets by supplying no `fetchImpl` — undici destroys a body that has produced no
54
- * bytes for 300s (`bodyTimeout`, default `300e3`), so byte-silence past 300s ends the attempt
55
- * there and arrives as a read rejection reported `transport`. That report is CORRECT (the
56
- * transport really did cut it) and the seat to change it exists: `fetchImpl` on both brain configs
57
- * takes any `fetch`, so a deployment needing the full 600s passes one built on an undici
58
- * `Agent`/`Dispatcher` with its own `bodyTimeout`. Named here so the next reader who measures ~300s
59
- * of tolerance does not conclude this number is a lie.
60
- * {@link import("../orchestration/workflow.js").WORKFLOW_AGENT_STALL_MS} is DERIVED from
61
- * {@link STALL_CONNECT_MS} + this value, so the coarse agent-restart cannot sit UNDER a single
62
- * silent provider call. Move this number and that one follows. It is a floor, not
63
- * a guarantee of ordering — see that constant's own comment for the cases that still invert.
64
- * - idle 90s: mid-stream silence after output started — a healthy stream emits steadily
65
- * (comfortably above any provider keep-alive cadence).
52
+ * THINKING delta already counts as the first token, design/17 O5), so a slow reasoner is safe. A
53
+ * minutes-scale bound is FALSE for a self-hosted server working through a long prefill AFTER
54
+ * headers: it emits nothing for 2–3 minutes while healthy, and cancelling it burns the prefill and
55
+ * re-sends a request that burns it again. The default transport keeps NO clock over this segment
56
+ * (the request carries `bodyTimeout: 0`), so this number is the bound actually granted.
57
+ * {@link import("../orchestration/workflow.js").WORKFLOW_AGENT_STALL_MS} is DERIVED from the
58
+ * first-byte lane at {@link STALL_FIRST_BYTE_MS} ({@link firstByteLaneKnobMs}) + this value, so the coarse agent-restart cannot sit UNDER a single
59
+ * silent provider call (a floor, not a guarantee of ordering — see that constant's comment).
60
+ * - idle 300s: mid-stream silence after output started — the reference implementation's idle floor
61
+ * (a self-hosted server can stall for minutes between deltas while a server-side tool runs; a provider
62
+ * keep-alive comment is not a delta and does not count).
66
63
  * ADVISORY: a brain whose construction-time {@link BrainTimeoutConfig} sets the corresponding field
67
64
  * keeps it (deployment sovereignty; see the `StreamOptions.stallTimeouts` priority chain).
68
65
  */
69
- export declare const STALL_CONNECT_MS = 30000;
66
+ export declare const STALL_FIRST_BYTE_MS = 300000;
70
67
  export declare const STALL_FIRST_TOKEN_MS = 600000;
71
- export declare const STALL_IDLE_MS = 90000;
68
+ export declare const STALL_IDLE_MS = 300000;
69
+ /**
70
+ * The retired names of the header-wait bound, each with the name that replaced it: the construction key and
71
+ * the per-call advisory key. The type face no longer has them, but a type only refuses a LITERAL — a
72
+ * spread object (`{ ...timeouts }`) or a JS caller still delivers the key, and a knob that arrives and is
73
+ * then ignored is a deployment setting silently doing nothing. So their arrival is refused, naming the
74
+ * replacement: a tripwire, not a second spelling (nothing reads the value).
75
+ */
76
+ export declare const RETIRED_TIMEOUT_KNOBS: {
77
+ readonly construction: {
78
+ readonly connectTimeoutMs: "firstByteTimeoutMs";
79
+ };
80
+ readonly advisory: {
81
+ readonly connectMs: "firstByteMs";
82
+ };
83
+ };
84
+ /** Refuse a retired timeout key present on `holder` (see {@link RETIRED_TIMEOUT_KNOBS}); `where` names the
85
+ * object in the message (`"createOpenAIBrain config"`, `"StreamOptions.stallTimeouts"`). */
86
+ export declare function refuseRetiredTimeoutKnobs(holder: object | undefined, level: keyof typeof RETIRED_TIMEOUT_KNOBS, where: string): void;
87
+ /** The first-byte window grows with the request body: one second per this many bytes, rounded up (the
88
+ * reference implementation's allowance) — a large context on a slow uplink is still UPLOADING when a
89
+ * fixed window would call the gateway silent. */
90
+ export declare const FIRST_BYTE_UPLOAD_BYTES_PER_SECOND = 32768;
91
+ /** A first-byte stall is re-sent at most this many times by default ({@link BrainTimeoutConfig.firstByteRetries}),
92
+ * out of its OWN budget rather than `maxRetries` (the reference implementation: "normally one retry, not the
93
+ * session budget"); the shared ladder counts transport failures, and this is not one. */
94
+ export declare const FIRST_BYTE_RETRY_BUDGET = 1;
95
+ /** The first-byte lane's retry budget: absent ⇒ {@link FIRST_BYTE_RETRY_BUDGET}; `0` / `1` are themselves; anything
96
+ * else is refused (`config.stall_timeout_invalid`), never folded to the default or to `0`. */
97
+ export declare function resolveFirstByteRetries(value: unknown): 0 | 1;
98
+ /** The retry's knob is the first times this — the reference implementation widens its retry to a second
99
+ * knob's default (599s against a 300s first window); one knob here keeps that ratio instead. */
100
+ export declare const FIRST_BYTE_RETRY_WINDOW_FACTOR = 2;
101
+ /** The ONE resolution every watchdog knob reads: construction config (an explicit `0` is OFF) > per-call
102
+ * advisory > the engine default. A bad number at either level is refused ({@link resolveStallTimeoutMs});
103
+ * only an ABSENT value falls through. */
104
+ export declare function resolveWatchdogMs(configured: number | undefined, advisory: number | undefined, engineDefault: number, knob: string): number;
105
+ /** One armed first-byte window, with the two numbers its announcement quotes. */
106
+ export interface FirstByteWindow {
107
+ /** The knob's value for THIS attempt (the configured value, widened on the retry). */
108
+ knobMs: number;
109
+ /** The upload allowance added for the request body, ms (0 for an empty body). */
110
+ allowanceMs: number;
111
+ /** What is actually armed: `knobMs + allowanceMs`, capped at {@link MAX_TIMER_DELAY_MS}. `0` = off. */
112
+ windowMs: number;
113
+ }
114
+ /** The first-byte window for one attempt; `retryIndex` = first-byte stalls already suffered (0 first),
115
+ * each widening the knob by {@link FIRST_BYTE_RETRY_WINDOW_FACTOR}. A knob of `0` (OFF) stays off. */
116
+ export declare function firstByteWindow(knobMs: number | undefined, bodyBytes: number, retryIndex: number): FirstByteWindow;
117
+ /**
118
+ * The most a first-byte lane armed at `knobMs` waits on the gateway in total before it gives up: the probe
119
+ * window and every widened retry its budget allows ({@link FIRST_BYTE_RETRY_BUDGET},
120
+ * {@link FIRST_BYTE_RETRY_WINDOW_FACTOR}) — read off {@link firstByteWindow}, the same function that arms
121
+ * each attempt, so the sum cannot drift from what is armed. The knob part only: the upload allowance
122
+ * depends on the request body and the backoff between attempts is not a wait on the gateway, so the lane's
123
+ * real wall time is this plus both — a LOWER bound.
124
+ */
125
+ export declare function firstByteLaneKnobMs(knobMs: number): number;
72
126
  export interface BrainTimeoutConfig {
73
- /** Abort the request if `fetch` doesn't return response headers within this many ms. Retryable. */
74
- connectTimeoutMs?: number;
127
+ /**
128
+ * `fetch()` call → response headers, ms: the window a gateway has to START answering (until 7.33.0 a
129
+ * knob named `connectTimeoutMs` bounded this wait; it is gone — the connect clock is the transport's). Absent ⇒
130
+ * {@link STALL_FIRST_BYTE_MS}; the armed window grows with the request body ({@link firstByteWindow});
131
+ * a stall earns ONE retry at a widened window ({@link firstByteRetries}), then fails naming this knob.
132
+ * Fires as a `stall` (this process cancelled its own request), never as a transport fault. `0` ⇒ off.
133
+ */
134
+ firstByteTimeoutMs?: number;
135
+ /**
136
+ * The first-byte lane's own retry budget: `1` (absent ⇒ {@link FIRST_BYTE_RETRY_BUDGET}) or `0` — the first
137
+ * window's expiry is the call's terminal, for a host whose own failover ladder should take a silent gateway after
138
+ * one window. Independent of `maxRetries` both ways; construction only; any other value fails the call naming this
139
+ * knob. The derived floors ({@link firstByteLaneKnobMs}) read the default.
140
+ */
141
+ firstByteRetries?: 0 | 1;
75
142
  /**
76
143
  * After the stream opens, cancel it if no content delta (text / thinking / tool input) arrives
77
144
  * within this many ms → `BrainError("network", "first-token timeout")`. For reasoning models the
78
145
  * **first thinking delta also counts** as the first token, so a long-thinking model isn't killed
79
- * for being slow to its first *visible* word (design/17 O5).
146
+ * for being slow to its first *visible* word (design/17 O5). Absent ⇒ {@link STALL_FIRST_TOKEN_MS};
147
+ * `0` ⇒ off.
80
148
  */
81
149
  firstTokenTimeoutMs?: number;
82
150
  /**
@@ -84,18 +152,19 @@ export interface BrainTimeoutConfig {
84
152
  * After the first delta, re-armed on every delta; if the stream then goes silent for this many ms
85
153
  * (a gateway that hung mid-output) → `reader.cancel()` + `BrainError("network", "stream idle timeout")`.
86
154
  * Complements `firstTokenTimeoutMs`: that one tolerates a long *first* token (reasoning), this one
87
- * catches a stall *after* output has started, where a healthy stream emits steadily. Default off.
155
+ * catches a stall *after* output has started, where a healthy stream emits steadily. Absent ⇒
156
+ * {@link STALL_IDLE_MS}; `0` ⇒ off.
88
157
  */
89
158
  idleTimeoutMs?: number;
90
159
  }
91
160
  /**
92
- * A per-fetch-attempt abort controller that fires on a connect timeout while still forwarding an
161
+ * A per-attempt abort controller that fires at the first-byte deadline while still forwarding an
93
162
  * outer (task) abort. The returned `signal` governs the **whole** request including the streamed body
94
- * — so once headers arrive, call `clearTimer()` (NOT `dispose()`) to stop the connect timer while
95
- * keeping the outer-abort link alive for the body read; call `dispose()` only when the request is
96
- * fully done or being discarded.
163
+ * — so once headers arrive, call `clearTimer()` (NOT `dispose()`) to stop the deadline while keeping
164
+ * the outer-abort link alive for the body read; call `dispose()` only when the request is fully done
165
+ * or being discarded.
97
166
  */
98
- export declare function createConnectController(connectTimeoutMs: number | undefined, outerSignal: AbortSignal | undefined): {
167
+ export declare function createAttemptController(firstByteWindowMs: number | undefined, outerSignal: AbortSignal | undefined): {
99
168
  signal: AbortSignal;
100
169
  timedOut: () => boolean;
101
170
  clearTimer: () => void;
@@ -110,7 +179,7 @@ export declare function createConnectController(connectTimeoutMs: number | undef
110
179
  * Default ceiling on how long the ENGINE waits for a single brain call (ruled 2026-08-03: one hour).
111
180
  * Deliberately far above any legitimate call: this is a wedged-run backstop, not a latency policy —
112
181
  * a deployment that wants a tight per-call bound sets the fine tier above ({@link BrainTimeoutConfig},
113
- * or the advisory {@link STALL_CONNECT_MS} family the engine supplies by default). All of these clocks
182
+ * or the advisory {@link STALL_FIRST_BYTE_MS} family the engine supplies by default). All of these clocks
114
183
  * run in parallel with the task's own `limits.timeoutSec`; whichever expires first owns the run.
115
184
  */
116
185
  export declare const BRAIN_CALL_GUARDRAIL_DEFAULT_MS = 3600000;
@@ -14,10 +14,54 @@ export function resolveStallTimeoutMs(value, knob) {
14
14
  }
15
15
  return value;
16
16
  }
17
- export const STALL_CONNECT_MS = 30_000;
17
+ export const STALL_FIRST_BYTE_MS = 300_000;
18
18
  export const STALL_FIRST_TOKEN_MS = 600_000;
19
- export const STALL_IDLE_MS = 90_000;
20
- export function createConnectController(connectTimeoutMs, outerSignal) {
19
+ export const STALL_IDLE_MS = 300_000;
20
+ export const RETIRED_TIMEOUT_KNOBS = {
21
+ construction: { connectTimeoutMs: "firstByteTimeoutMs" },
22
+ advisory: { connectMs: "firstByteMs" },
23
+ };
24
+ export function refuseRetiredTimeoutKnobs(holder, level, where) {
25
+ if (holder === undefined || holder === null)
26
+ return;
27
+ for (const [retired, replacement] of Object.entries(RETIRED_TIMEOUT_KNOBS[level])) {
28
+ if (!(retired in holder))
29
+ continue;
30
+ const e = new Error(`${where}: \`${retired}\` was removed in 7.33.0 and is refused rather than ignored — the fetch()→response-headers wait it bounded is \`${replacement}\` (0 = off); the TCP + TLS connect is the transport's own clock, tuned on the process dispatcher (setGlobalDispatcher(new Agent({ connect: { timeout } })))`);
31
+ e.code = "config.stall_timeout_retired";
32
+ throw e;
33
+ }
34
+ }
35
+ export const FIRST_BYTE_UPLOAD_BYTES_PER_SECOND = 32_768;
36
+ export const FIRST_BYTE_RETRY_BUDGET = 1;
37
+ export function resolveFirstByteRetries(value) {
38
+ if (value === undefined)
39
+ return FIRST_BYTE_RETRY_BUDGET;
40
+ if (value === 0 || value === 1)
41
+ return value;
42
+ const shown = typeof value === "string" ? JSON.stringify(value) : typeof value === "bigint" ? `${value}n` : typeof value === "object" && value !== null ? "an object" : String(value);
43
+ const e = new Error(`firstByteRetries must be 0 or 1 (got ${shown}) — 1 re-sends a first-byte stall once at a widened window (the default), 0 ends the call at the first window so the caller's own failover takes it`);
44
+ e.code = "config.stall_timeout_invalid";
45
+ throw e;
46
+ }
47
+ export const FIRST_BYTE_RETRY_WINDOW_FACTOR = 2;
48
+ export function resolveWatchdogMs(configured, advisory, engineDefault, knob) {
49
+ return resolveStallTimeoutMs(configured ?? advisory, knob) ?? engineDefault;
50
+ }
51
+ export function firstByteWindow(knobMs, bodyBytes, retryIndex) {
52
+ if (knobMs === undefined || knobMs <= 0)
53
+ return { knobMs: 0, allowanceMs: 0, windowMs: 0 };
54
+ const widened = knobMs * FIRST_BYTE_RETRY_WINDOW_FACTOR ** Math.max(0, retryIndex);
55
+ const allowanceMs = Math.ceil(Math.max(0, bodyBytes) / FIRST_BYTE_UPLOAD_BYTES_PER_SECOND) * 1000;
56
+ return { knobMs: widened, allowanceMs, windowMs: Math.min(widened + allowanceMs, MAX_TIMER_DELAY_MS) };
57
+ }
58
+ export function firstByteLaneKnobMs(knobMs) {
59
+ let total = 0;
60
+ for (let retryIndex = 0; retryIndex <= FIRST_BYTE_RETRY_BUDGET; retryIndex++)
61
+ total += firstByteWindow(knobMs, 0, retryIndex).knobMs;
62
+ return total;
63
+ }
64
+ export function createAttemptController(firstByteWindowMs, outerSignal) {
21
65
  const ac = new AbortController();
22
66
  let timedOut = false;
23
67
  const onAbort = () => ac.abort(outerSignal?.reason);
@@ -28,11 +72,11 @@ export function createConnectController(connectTimeoutMs, outerSignal) {
28
72
  outerSignal.addEventListener("abort", onAbort, { once: true });
29
73
  }
30
74
  let timer;
31
- if (connectTimeoutMs && connectTimeoutMs > 0) {
75
+ if (firstByteWindowMs && firstByteWindowMs > 0) {
32
76
  timer = setTimeout(() => {
33
77
  timedOut = true;
34
78
  ac.abort();
35
- }, connectTimeoutMs);
79
+ }, firstByteWindowMs);
36
80
  }
37
81
  const clearTimer = () => {
38
82
  if (timer)
@@ -0,0 +1,13 @@
1
+ import { Dispatcher } from "undici";
2
+ /**
3
+ * The one dispatcher every first-party brain hands its fetch: the sending members are the engine's
4
+ * ({@link SENDING_MEMBERS}); every other member — state, lifecycle, events — is the host's, read when
5
+ * accessed ({@link hostMember}), so nothing a fetch starts reading has to be added to a list here.
6
+ * Stateless; owns no sockets.
7
+ */
8
+ export declare const ENGINE_DISPATCHER: Dispatcher;
9
+ /** The `fetch` a brain sends its requests through: the caller's `fetchImpl` (else Node's `fetch`), each
10
+ * request carrying {@link ENGINE_DISPATCHER} in its init. */
11
+ export declare function resolveFetch(config: {
12
+ fetchImpl?: typeof fetch;
13
+ }): typeof fetch;
@@ -0,0 +1,62 @@
1
+ import { Dispatcher, getGlobalDispatcher } from "undici";
2
+ import UnwrapHandler from "undici/lib/handler/unwrap-handler.js";
3
+ import WrapHandler from "undici/lib/handler/wrap-handler.js";
4
+ function handlerApi(handler) {
5
+ return typeof handler.onRequestStart === "function" ? 2 : 1;
6
+ }
7
+ function inSlotApi(api, handler) {
8
+ return api === 1 ? UnwrapHandler.unwrap(handler) : WrapHandler.wrap(handler);
9
+ }
10
+ function slot(version) {
11
+ const held = Reflect.get(globalThis, Symbol.for(`undici.globalDispatcher.${String(version)}`));
12
+ return isDispatcher(held) ? held : undefined;
13
+ }
14
+ function isDispatcher(value) {
15
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "dispatch") === "function";
16
+ }
17
+ function hostDispatcher(callerApi) {
18
+ const own = callerApi === 2 ? slot(2) : undefined;
19
+ return own !== undefined ? { dispatcher: own, api: 2 } : { dispatcher: slot(1) ?? getGlobalDispatcher(), api: 1 };
20
+ }
21
+ function hostDispatchers() {
22
+ const out = [];
23
+ for (const d of [slot(2), slot(1), getGlobalDispatcher()])
24
+ if (d !== undefined && !out.includes(d))
25
+ out.push(d);
26
+ return out;
27
+ }
28
+ function hostMember(key) {
29
+ const hosts = hostDispatchers();
30
+ for (const host of hosts) {
31
+ const value = Reflect.get(host, key, host);
32
+ if (value)
33
+ return typeof value === "function" ? value.bind(host) : value;
34
+ }
35
+ const newest = hosts[0];
36
+ return newest === undefined ? undefined : Reflect.get(newest, key, newest);
37
+ }
38
+ const CALLER_API = Symbol("engine.callerDispatcherApi");
39
+ class EngineDispatcher extends Dispatcher {
40
+ dispatch(options, handler) {
41
+ const { [CALLER_API]: callerApi, ...rest } = options;
42
+ const host = hostDispatcher(callerApi ?? handlerApi(handler));
43
+ return host.dispatcher.dispatch({ ...rest, headersTimeout: 0, bodyTimeout: 0 }, inSlotApi(host.api, handler));
44
+ }
45
+ compose(...interceptors) {
46
+ const composed = super.compose(...interceptors);
47
+ const chain = composed.dispatch;
48
+ const enter = (options, handler) => {
49
+ const stamped = { ...options, [CALLER_API]: options[CALLER_API] ?? handlerApi(handler) };
50
+ return chain(stamped, handler);
51
+ };
52
+ return new Proxy(composed, { get: (target, key, receiver) => (key === "dispatch" ? enter : Reflect.get(target, key, receiver)) });
53
+ }
54
+ }
55
+ const SENDING_MEMBERS = new Set(Reflect.ownKeys(Dispatcher.prototype).filter((key) => key !== "constructor" && key !== "close" && key !== "destroy"));
56
+ export const ENGINE_DISPATCHER = new Proxy(new EngineDispatcher(), {
57
+ get: (engine, key, standIn) => (SENDING_MEMBERS.has(key) ? Reflect.get(engine, key, standIn) : hostMember(key)),
58
+ });
59
+ export function resolveFetch(config) {
60
+ const inner = config.fetchImpl ?? fetch;
61
+ return (input, init) => inner(input, { ...init, dispatcher: ENGINE_DISPATCHER });
62
+ }
@@ -13,7 +13,7 @@ import { isValidReminderMark, mintReminderMark } from "../reminder-mint.js";
13
13
  import { refuseInvalidApproverPosture } from "../auto-mode-directive.js";
14
14
  import { emitTrace } from "../trace.js";
15
15
  import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
16
- import { STALL_CONNECT_MS, STALL_FIRST_TOKEN_MS, STALL_IDLE_MS, withBrainCallGuardrail } from "../../brain/timeout.js";
16
+ import { STALL_FIRST_BYTE_MS, STALL_FIRST_TOKEN_MS, STALL_IDLE_MS, withBrainCallGuardrail } from "../../brain/timeout.js";
17
17
  import { OFFLOAD_TOOL_NAME } from "../tool-result-store.js";
18
18
  import { OUTPUT_TOOL_NAME, createOutputTool } from "./synthetic-tools.js";
19
19
  import { compileOutputSchema } from "./strict-output-schema.js";
@@ -649,7 +649,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
649
649
  : {}),
650
650
  stallTimeoutsPerCall: () => {
651
651
  callIssuedAtRef.current = Date.now();
652
- return { connectMs: STALL_CONNECT_MS, firstTokenMs: STALL_FIRST_TOKEN_MS, idleMs: STALL_IDLE_MS };
652
+ return { firstByteMs: STALL_FIRST_BYTE_MS, firstTokenMs: STALL_FIRST_TOKEN_MS, idleMs: STALL_IDLE_MS };
653
653
  },
654
654
  ...(spec.resilience !== undefined ? { resilience: spec.resilience } : {}),
655
655
  loopTrace: (step) => {
@@ -11,7 +11,7 @@ import { inlineUntrusted } from "../untrusted-text.js";
11
11
  import { mountWriteProtection } from "./write-protection-station.js";
12
12
  import { autoRunOnRecommendationEffective } from "./memory-consolidation-driver.js";
13
13
  import { GLOBAL_USAGE_KEY, usageRetryAfterMs, windowsGovernCost } from "../usage-window-store.js";
14
- import { LSP_CONTRACT_ID, countElicitOptIns, deriveLspSeam, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam, resolveSubagentTranscriptTier } from "../wiring-manifest.js";
14
+ import { LSP_CONTRACT_ID, countElicitOptIns, deriveHandsSeam, deriveLspSeam, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam, resolveSubagentTranscriptTier } from "../wiring-manifest.js";
15
15
  import { DEFAULT_IRREVERSIBLE_SCOPE } from "./checkpoint-scope.js";
16
16
  export function mcpManifestEntries(entries, statuses) {
17
17
  return (entries ?? []).map((entry) => {
@@ -218,6 +218,7 @@ export function prepareWiringManifest(input) {
218
218
  tools: toolRoster,
219
219
  hooks: wiredHookEntries(hooks, spec.hooks !== undefined ? "spec" : "deps", preToolUseObservational),
220
220
  ...(lspManagerWired ? { lsp: deriveLspSeam(toolRoster.entries.some((e) => e.contract.contractId === LSP_CONTRACT_ID), lspDiagnosticsLive) } : {}),
221
+ hands: deriveHandsSeam(handsEnabled),
221
222
  fileHistory: fileHistoryFact,
222
223
  });
223
224
  peerLaneRefs.askEffective = wiringManifest.ask.effective;
@@ -18,6 +18,12 @@
18
18
  */
19
19
  import type { RedactionConfidence, RedactionSource } from "./arg-summary.js";
20
20
  import { type EngineNotice } from "./engine-notice.js";
21
+ /** A name table as the exact-name arm reads it: membership of an UPPER-CASED name, given the entry's value when
22
+ * the caller has one. A `Set` of exact names is one (it ignores the value); the exemption side is the union of
23
+ * its two tables. */
24
+ export interface EnvNameTable {
25
+ has(upperName: string, value?: string): boolean;
26
+ }
21
27
  /** Which rule classified a key as secret-shaped — the env arm's `kind` vocabulary. */
22
28
  export type SecretEnvFindingKind = "suffix-rule" | "namespace-rule" | "exact-name";
23
29
  /** One env entry the `scrub` policy dropped: the key (the VALUE is never carried — this record is meant
@@ -30,14 +36,17 @@ export interface SecretEnvFinding {
30
36
  }
31
37
  /** What the two exact-name lists say about a name, before any shape rule is consulted. */
32
38
  export type ExactEnvNameVerdict = "credential" | "exempt" | "unknown";
33
- /** The exact-name arm of the classification, taking both lists as arguments. The credential list is read
39
+ /** The exact-name arm of the classification, taking both lists as arguments (the exemption side is the exact
40
+ * names plus the named families). The credential list is read
34
41
  * FIRST: an exemption can never take a name away from it, so a name landing on both lists resolves to the
35
42
  * credential reading — the fail-closed direction (a wrongly-exempted name leaks; a wrongly-dropped one
36
43
  * does not). Module-level export (not part of the package surface): the two real lists are disjoint, so
37
44
  * only a controlled collision can tell this precedence from an inverted one. */
38
- export declare function classifyExactEnvName(key: string, credentialNames: ReadonlySet<string>, exemptNames: ReadonlySet<string>): ExactEnvNameVerdict;
39
- /** Is `key` a secret-pattern env var (one the fail-closed `scrub` policy drops before spawning a shell)? */
40
- export declare function isSecretEnvKey(key: string): boolean;
45
+ export declare function classifyExactEnvName(key: string, credentialNames: EnvNameTable, exemptNames: EnvNameTable, value?: string): ExactEnvNameVerdict;
46
+ /** Is `key` a secret-pattern env var (one the fail-closed `scrub` policy drops before spawning a shell)? Pass the
47
+ * entry's `value` to get exactly the scrub's answer for that entry: a named-family exemption holds only for a value
48
+ * that clears the family's screen, so without a value such a member reads as secret (the name alone cannot clear). */
49
+ export declare function isSecretEnvKey(key: string, value?: string): boolean;
41
50
  /** Drop every secret-pattern key (see {@link isSecretEnvKey}) from an env object — the fail-closed `scrub`
42
51
  * policy. `findings` is an OPTIONAL caller-owned collector: pass an array to learn which entries were
43
52
  * dropped and why, read it back after the call. Omitting it leaves this function byte-for-byte what it
@@ -15,21 +15,30 @@ const NON_SECRET_ENV_EXACT_NAMES = new Set([
15
15
  "MAX_BATCH_PREFILL_TOKENS", "MAX_WAITING_TOKENS", "MAX_TOP_N_TOKENS", "RATE_LIMIT_TOKENS",
16
16
  "PROMPT_TOKENS", "CONTEXT_TOKENS",
17
17
  ]);
18
+ const NON_SECRET_ENV_INDEXED_FAMILIES = [
19
+ { stem: "GIT_CONFIG_KEY", valueClears: (value) => !/[a-z][a-z0-9+.-]*:\/\/[^/@]+@/i.test(value) },
20
+ ];
21
+ const indexedFamilyMember = (stem) => new RegExp(`^${stem}_(?:0|[1-9][0-9]*)$`);
22
+ const NON_SECRET_ENV_INDEXED_FAMILY_ROWS = NON_SECRET_ENV_INDEXED_FAMILIES.map((f) => ({ member: indexedFamilyMember(f.stem), valueClears: f.valueClears }));
23
+ const NON_SECRET_ENV_NAMES = {
24
+ has: (upperName, value) => NON_SECRET_ENV_EXACT_NAMES.has(upperName) ||
25
+ (value !== undefined && NON_SECRET_ENV_INDEXED_FAMILY_ROWS.some((row) => row.member.test(upperName) && row.valueClears(value))),
26
+ };
18
27
  const SECRET_ENV_KIND_CONFIDENCE = {
19
28
  "exact-name": "high",
20
29
  "suffix-rule": "medium",
21
30
  "namespace-rule": "medium",
22
31
  };
23
- export function classifyExactEnvName(key, credentialNames, exemptNames) {
32
+ export function classifyExactEnvName(key, credentialNames, exemptNames, value) {
24
33
  const upper = key.toUpperCase();
25
- if (credentialNames.has(upper))
34
+ if (credentialNames.has(upper, value))
26
35
  return "credential";
27
- if (exemptNames.has(upper))
36
+ if (exemptNames.has(upper, value))
28
37
  return "exempt";
29
38
  return "unknown";
30
39
  }
31
- function classifySecretEnvKey(key) {
32
- const byExactName = classifyExactEnvName(key, SECRET_ENV_EXACT_NAMES, NON_SECRET_ENV_EXACT_NAMES);
40
+ function classifySecretEnvKey(key, value) {
41
+ const byExactName = classifyExactEnvName(key, SECRET_ENV_EXACT_NAMES, NON_SECRET_ENV_NAMES, value);
33
42
  if (byExactName === "credential")
34
43
  return "exact-name";
35
44
  if (byExactName === "exempt")
@@ -38,13 +47,13 @@ function classifySecretEnvKey(key) {
38
47
  return "suffix-rule";
39
48
  return SEMA_NAMESPACE_SECRET_RE.test(key) ? "namespace-rule" : undefined;
40
49
  }
41
- export function isSecretEnvKey(key) {
42
- return classifySecretEnvKey(key) !== undefined;
50
+ export function isSecretEnvKey(key, value) {
51
+ return classifySecretEnvKey(key, value) !== undefined;
43
52
  }
44
53
  export function scrubSecretEnv(env, findings) {
45
54
  const out = {};
46
55
  for (const k of Object.keys(env)) {
47
- const kind = classifySecretEnvKey(k);
56
+ const kind = classifySecretEnvKey(k, env[k]);
48
57
  if (kind === undefined) {
49
58
  out[k] = env[k];
50
59
  continue;
@@ -141,12 +141,14 @@ export interface BrainStatus {
141
141
  * approximate across a clock adjustment — {@link retryInMs} stays the authoritative RELATIVE quantity.
142
142
  */
143
143
  retryAtMs?: number;
144
- /** On a `waiting_first_token` frame: how long the first token has been awaited, ms, at the
145
- * moment the frame is emitted (the emitting process's clock; approximate across a hop). Absent on
146
- * every other phase. */
144
+ /** On a `waiting_first_token` frame: how long the current wait has lasted, ms, at the moment the
145
+ * frame is emitted (the emitting process's clock; approximate across a hop). The wait is one
146
+ * segment's: the response-header wait before headers arrive, the first-token wait after — each
147
+ * starts its own count. Absent on every other phase. */
147
148
  elapsedMs?: number;
148
- /** On a `waiting_first_token` frame: the armed first-token watchdog, ms — the wait's ceiling,
149
- * so a consumer can render "45s of 120s". Absent on every other phase. */
149
+ /** On a `waiting_first_token` frame: the ceiling of the segment being waited on, ms — the armed
150
+ * first-byte window before headers, the armed first-token watchdog after — so a consumer can render
151
+ * "45s of 300s". Absent on every other phase. */
150
152
  timeoutMs?: number;
151
153
  /** 1-based index of the attempt that just failed (the wait precedes attempt `attempt + 1`);
152
154
  * same numbering as the `brain.retry` telemetry frame. Absent on frames that are not a retry wait. */
@@ -386,8 +386,7 @@ export declare class TaskRegistry {
386
386
  /** Background output-file read exemption: true iff `canonicalPath` is EXACTLY the registered output
387
387
  * file of a background bash row that (a) the caller can access (same owner/scope/session visibility as
388
388
  * a TaskOutput poll — {@link canAccess}) and (b) was registered on the caller's OWN execution env —
389
- * `env` identity is part of the match, same discipline as {@link retainedShellIds} /
390
- * {@link runningShellIds}. A canonical path only names a file within ONE env's filesystem
389
+ * `env` identity is part of the match, same discipline as {@link runningShellIds}. A canonical path only names a file within ONE env's filesystem
391
390
  * namespace: on a shared registry, a row minted on env A must never exempt a same-named outside-root
392
391
  * file in env B's namespace (same session or not), so instance equality — never a string proxy — is
393
392
  * required. The output file is minted by the ENGINE itself (createTempFile
@@ -610,11 +609,6 @@ export declare class TaskRegistry {
610
609
  * completion notification at the terminal state. `unref()`d — a watcher never keeps the process alive.
611
610
  * Stops itself on terminal, on a vanished shell (env disposed), or when the handle is evicted. */
612
611
  private startBashWatcher;
613
- /** RETAINED shells (THE retain predicate) still running on THIS env, any owner. Env identity is part of the
614
- * match (a shared registry never exempts another env's ids). No caller in src since the park-only parks
615
- * moved to the run-boundary dispose (which spares every running row: {@link runningShellIds}); removal
616
- * scheduled for the next minor. */
617
- retainedShellIds(env: unknown): BackgroundShellId[];
618
612
  /** Every shell-lane row still RUNNING on this env, any owner — the run-boundary dispose's except-list (the
619
613
  * run teardown and the park-only parks: `sweepRunBoundaryBackgroundShells`).
620
614
  * A run's teardown disposes only shells it can attribute to itself: its settle sweep has already killed
@@ -606,7 +606,12 @@ export class TaskRegistry {
606
606
  }
607
607
  let outcome;
608
608
  try {
609
- outcome = handle.env.killBackgroundNow !== undefined ? handle.env.killBackgroundNow(handle.shellId) : "no_pgid";
609
+ const seat = handle.env.killBackgroundNow;
610
+ if (seat === undefined)
611
+ shutdownDebug("hard kill: seat absent", { id: handle.id, shellId: handle.shellId });
612
+ outcome = seat === undefined ? "no_pgid" : seat.call(handle.env, handle.shellId);
613
+ if (seat !== undefined && outcome === "no_pgid")
614
+ shutdownDebug("hard kill: seat answered no_pgid", { id: handle.id, shellId: handle.shellId });
610
615
  }
611
616
  catch (err) {
612
617
  shutdownDebug("hard kill: seat threw", { id: handle.id, shellId: handle.shellId, error: thrownText(err) });
@@ -835,15 +840,6 @@ export class TaskRegistry {
835
840
  handle.watcher = setInterval(() => void tick(), 1_000);
836
841
  handle.watcher.unref?.();
837
842
  }
838
- retainedShellIds(env) {
839
- const out = [];
840
- for (const handle of this.handles.values()) {
841
- if ((handle.type === "background_bash" || handle.type === "monitor") && handle.status === "running" && shellRowRetained(handle) && handle.env === env) {
842
- out.push(handle.shellId);
843
- }
844
- }
845
- return out;
846
- }
847
843
  runningShellIds(env) {
848
844
  const out = [];
849
845
  for (const handle of this.handles.values()) {
@@ -341,18 +341,18 @@ export type PermissionResult = {
341
341
  * `probe*` above; ENGINE-STAMPED and VALIDATED at the tighten, and deliberately NOT merged onto an ask
342
342
  * another layer raised, which a grant leaves standing. */
343
343
  probeReadRootCandidate?: import("./types.js").ReadRootGrantCandidate;
344
- /** An EXPLICIT `ask` permission rule matched this call (design/127 DSL — the CC `alwaysAskRules`
345
- * shape); carries the matched rule's text. Present ⇔ a rule someone WROTE says "ask about this",
346
- * never for a `defaultAction:"ask"` fallback (an unmatched call is default-closed posture, not a
347
- * per-call instruction — CC draws the same line: only a matched ask rule diverts the auto-mode
348
- * lane, a mode-default ask stays classifier-eligible). Consumer: the gate's auto-mode classifier
349
- * step refuses to resolve an ask carrying it — a person's standing "ask me each time" is not
350
- * classifier hesitation, so a classifier verdict must not be the thing that clears it. A policy
351
- * self-declaring the member only ever OPTS ITS OWN ask OUT of classifier auto-resolution
352
- * (tightening — the false-claim direction is safe, unlike the engine-stamped `"hook"` word whose
353
- * risk was laundering provenance AWAY). {@link combinePolicies} carries it onto the surviving ask
354
- * when ANY folded-away concurrent ask bore it (monotone, tighten-only); it is consumed inside the
355
- * gate and deliberately NOT copied onto the park/approval-card request. */
344
+ /** An EXPLICIT `ask` permission rule matched this call (design/127 DSL — the CC `alwaysAskRules` shape); carries the
345
+ * matched rule's text. Present ⇔ a rule someone WROTE says "ask about this", never for a `defaultAction:"ask"` fallback
346
+ * (an unmatched call is default-closed posture, not a per-call instruction — CC draws the same line: only a matched ask
347
+ * rule diverts the auto-mode lane, a mode-default ask stays classifier-eligible). Consumer: the gate's auto-mode
348
+ * classifier step refuses to resolve an ask carrying it — a person's standing "ask me each time" is not classifier
349
+ * hesitation, so a classifier verdict must not be the thing that clears it. A policy self-declaring the member only
350
+ * ever OPTS ITS OWN ask OUT of classifier auto-resolution (tightening — the false-claim direction is safe, unlike the
351
+ * engine-stamped `"hook"` word whose risk was laundering provenance AWAY). {@link combinePolicies} carries it onto the
352
+ * surviving ask when ANY folded-away concurrent ask bore it (monotone, tighten-only); it is consumed inside the gate
353
+ * and NOT copied onto the park/approval-card request — but it is the ONE input of the `ask_rule` origin word they DO
354
+ * carry ({@link origin}): a minter raising an ask for a written rule (a person's settings rule, an operator's must-ask
355
+ * list) that leaves it unset gets `policy` there, indistinguishable from hesitation. */
356
356
  matchedAskRule?: string;
357
357
  /** design/252 G-2 (additive): the RULE-PROVENANCE evidence behind this ask — see
358
358
  * {@link AskRuleEvidence}. ENGINE-STAMPED inside the gate, once, after the org layer and the