@sema-agent/core 7.23.4 → 7.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/dist/agents/list-agents-tool.d.ts +8 -1
  3. package/dist/agents/list-agents-tool.js +26 -3
  4. package/dist/agents/observer.js +5 -8
  5. package/dist/agents/subagent.js +2 -2
  6. package/dist/agents/team.js +9 -0
  7. package/dist/brain/anthropic.js +3 -2
  8. package/dist/brain/circuit-breaker.js +3 -3
  9. package/dist/brain/degrading.js +11 -14
  10. package/dist/brain/errors.d.ts +35 -18
  11. package/dist/brain/errors.js +49 -31
  12. package/dist/brain/failover.js +2 -3
  13. package/dist/brain/open-responses.js +6 -5
  14. package/dist/brain/openai.js +6 -5
  15. package/dist/brain/route-adjudicator.d.ts +50 -15
  16. package/dist/brain/route-adjudicator.js +112 -20
  17. package/dist/brain/route-conformance.js +9 -0
  18. package/dist/brain/stream-engine.js +18 -10
  19. package/dist/core/arg-summary.js +17 -6
  20. package/dist/core/auto-compaction.d.ts +42 -1
  21. package/dist/core/auto-compaction.js +21 -1
  22. package/dist/core/auto-mode.js +2 -8
  23. package/dist/core/checkpoint-store.d.ts +4 -6
  24. package/dist/core/checkpoint-store.js +2 -2
  25. package/dist/core/exec-gate.js +3 -5
  26. package/dist/core/file-history-store.js +11 -10
  27. package/dist/core/hooks.d.ts +7 -0
  28. package/dist/core/mcp.js +7 -5
  29. package/dist/core/memory-engine/distiller.d.ts +7 -4
  30. package/dist/core/memory-engine/distiller.js +11 -3
  31. package/dist/core/memory.js +1 -0
  32. package/dist/core/permission-rule-model.js +8 -5
  33. package/dist/core/runner/assemble-result.js +12 -19
  34. package/dist/core/runner/contracts.d.ts +1 -0
  35. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -0
  36. package/dist/core/runner/prepare-delegation-surface.js +1 -0
  37. package/dist/core/runner/prepare-file-history.js +19 -0
  38. package/dist/core/runner/prepare-hands-readface.d.ts +4 -1
  39. package/dist/core/runner/prepare-hands-readface.js +4 -0
  40. package/dist/core/runner/prepare-task.js +2 -2
  41. package/dist/core/runner/run-leg.js +2 -13
  42. package/dist/core/runner/run-recovery-lanes.js +4 -20
  43. package/dist/core/runner/run-settle-and-teardown.js +2 -16
  44. package/dist/core/runner/run-turn-boundary.js +4 -20
  45. package/dist/core/runner/runtask.js +3 -5
  46. package/dist/core/runner/stream-settle-backstop.js +5 -5
  47. package/dist/core/runner/tool-end-body.js +5 -4
  48. package/dist/core/runner/tool-output-projection.js +3 -2
  49. package/dist/core/runner/turn-attachments.d.ts +0 -20
  50. package/dist/core/runner/turn-attachments.js +41 -36
  51. package/dist/core/session-store.js +2 -4
  52. package/dist/core/session.d.ts +4 -1
  53. package/dist/core/session.js +2 -1
  54. package/dist/core/task-event.d.ts +19 -9
  55. package/dist/core/task-registry-shared.js +1 -0
  56. package/dist/core/thrown-value.d.ts +52 -11
  57. package/dist/core/thrown-value.js +35 -1
  58. package/dist/core/tool-catalog-entries.js +1 -1
  59. package/dist/core/tool-errors.d.ts +6 -0
  60. package/dist/core/tool-errors.js +6 -14
  61. package/dist/core/tool-model-gate.js +3 -15
  62. package/dist/core/tools.js +15 -16
  63. package/dist/core/untrusted-text.d.ts +6 -0
  64. package/dist/core/untrusted-text.js +1 -0
  65. package/dist/core/workflow-run-store-contract.js +13 -0
  66. package/dist/core/workflow-run-store.d.ts +15 -0
  67. package/dist/core/workflow-run-store.js +1 -0
  68. package/dist/engine/llm/types.d.ts +4 -4
  69. package/dist/engine/loop/agent-loop.js +10 -24
  70. package/dist/index.d.ts +1 -1
  71. package/dist/index.js +1 -1
  72. package/dist/orchestration/workflow.js +44 -23
  73. package/dist/stores/file/file-history-store.js +12 -11
  74. package/dist/tools/fs/bash-program-position.d.ts +23 -0
  75. package/dist/tools/fs/bash-program-position.js +11 -0
  76. package/dist/tools/fs/bash-readonly-classifier.d.ts +30 -7
  77. package/dist/tools/fs/bash-readonly-classifier.js +31 -10
  78. package/dist/tools/fs/fs-bash.d.ts +30 -1
  79. package/dist/tools/fs/fs-bash.js +123 -54
  80. package/dist/tools/fs/read-deny.d.ts +14 -0
  81. package/dist/tools/fs/read-deny.js +5 -0
  82. package/dist/tools/fs/safety.d.ts +1 -1
  83. package/dist/tools/fs/safety.js +2 -1
  84. package/dist/tools/monitor.d.ts +5 -0
  85. package/dist/tools/monitor.js +7 -0
  86. package/package.json +1 -1
  87. package/test/export-surface.snapshot.json +7 -3
  88. package/wire-changes/core@7.23.5.json +133 -0
  89. package/wire-changes/core@7.24.0.json +249 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,138 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.24.0 — 2026-09-19
4
+
5
+ ### The stream engine's last two `instanceof BrainError` reads (#935, follow-on; P2; no wire key)
6
+ - **Before.** `src/brain/stream-engine.ts` kept two bare `e instanceof BrainError` tests after #935 swept the rest. Site A is in the `.catch` of a FLOATING promise (`void run().catch(...)`) — the one place a stream is ever told it failed — reading a value that a handed seat (`buildRequest` / `makeParser` / `doFetch`) may have raised. `instanceof` walks a prototype chain a revoked proxy traps, so a raise there costs the terminal frame entirely. **Measured**: with the old reading the probe never settles — no `error` event, an unhandled rejection, and a consumer waiting on `result()` forever (15 s test timeout). Site B is the in-band overload downgrade.
7
+ - **After.** Both read the BRAND — `brainErrorCodeOf`, the code word against the closed list — and site A reads the four disclosure members through `readMember`. Site B loses a condition (the brand IS the code) and gains realm-independence: a server-coded error minted outside the class (a vm context, a transport that lost the prototype) used to answer `false` there and kill a turn that should have finalized the text it had already streamed. Rules: −1 condition, −2 unguarded prototype walks.
8
+ - **Reach.** No wire key; the disclosure members ride exactly as before for a real `BrainError` (pinned). `test/backlog935b-stream-engine-guarded-brand.test.ts` drives a revoked proxy through both seats and pins the realm-independent downgrade.
9
+ - **Sibling site (merge-tree review).** `degrading.ts` `terminalOf` recognised only `"error"` as an error terminal: a bare stream whose `result()` resolved an ABORTED message became `{ type: "done", reason: "aborted" }` — a done frame with a non-done reason. It now routes `"aborted"` through the one `errorFrameOf`. Wire-visible only for that shape (error frame, reason aborted); pinned in `test/backlog935c-degrading-abort-frame.test.ts`.
10
+
11
+ ### A request root that COMPARES and a request root that RENDERS are two different values (#950; P2; BREAKING export rename; no wire key)
12
+ - **Before.** `normalizeBaseUrl` had one reading serving two jobs. The pairing law needs the query (it selects the endpoint), so the function kept `u.search` byte-identical — and every seat that RENDERS a root took the same function. `?api_key=<secret>` therefore rode the refusal `fixHint`, the exported `routeRefusalText`, all three `RouteRefusalDetail` URL fields, and the `route.base_url_changed_key_unchanged` catalog-swap notice, into whatever a host logs. The name is what licensed it: "normalize" reads as fit-for-anything.
13
+ - **After.** Two functions, each with one job. `routeKeyOf` is the comparison value (query byte-preserved, never to be rendered — the doc says so). `displayUrlOf` is the only spelling allowed into text: composed from the parse, userinfo dropped, and **every** query value replaced with `«redacted»` — no secret-name list, because a list has a blind spot at exactly the name nobody thought of (measured, test 2026-09-19: a downstream name-list redactor passes `?x_vendor_key=<secret>` through verbatim — the one secret-shaped name of twelve that its list did not carry). Query NAMES ride, because dropping the query whole would render two roots that differ only in their query as one string, and the refusal about that very pair would read "targets X … paired with X". When the names match too, the refusal names the parameters whose values diverged instead. Rules: two names for two jobs replace one name for two jobs; both refusal mints and the catalog-swap mint went from "whatever normalize returned" to the same one-line rule (compare the key, render the display).
14
+ - **Three repairs from the adversarial rounds, each reproduced first — one of them undoing a regression this change had introduced.** (a) The unparseable-spelling arm anchored its redaction on the LEADING run, so it only fired on a bare `user@host`; it now withholds by POSITION, and covers all THREE credential positions — userinfo, query, and FRAGMENT (implicit-OAuth puts a bearer token exactly there, and the parse arm drops fragments outright). (b) A first attempt moved the trailing-slash fold from the serialized URL to `u.pathname`, which **was wrong in the common case**: the fold is not this function's to choose — `resolveRequestRoot` strips trailing slashes off the SERIALIZED root and the brains append their endpoint suffix to that. Measured through the real builder, the path-level fold made `…/v1/?tenant=a` and `…/v1?tenant=a` compare EQUAL while their requests differ (a false `paired`, a credential authorized onto another path) and `…?tenant=a/` and `…?tenant=a` compare UNEQUAL while their requests are byte-identical (a refusal over a difference the wire cannot see). The fold is back where the resolver has it, and the invariant — **two roots share a key iff their requests are the same request** — is now pinned as a table with its one registered exception (a root CARRYING a fragment defeats the resolver's own strip; that is the request-construction defect below, not this fold's). (c) The residual note claimed "query" for every collapse, including two unparseable roots differing only in withheld userinfo — where there is no query at all; it now names the query parameters when it can and says only that the difference is in a withheld or folded part when it cannot.
15
+ - **A fourth repair, from the main session's own counterexample sweep.** A BARE query token (`?<token>`, no `=`) was rendered with its payload in the NAME position: `URLSearchParams` reports it as a name with an empty value, so "every value is withheld" protected nothing and the token reached `fixHint`, all three detail members and `routeRefusalText`. What rides is now judged on the RAW segment — a segment with no `=` renders as one placeholder with no name, and the explanation sentence will not name it either. Reading raw segments also retired the decode-then-re-encode step the previous shape needed. Positions this deliberately does NOT withhold — path segments, matrix parameters, subdomains, and the NAME of a `name=value` parameter — are the endpoint's identity, and redacting them would leave a refusal nobody can act on; that is `KL-950a`, with the deployment rule that a credential belongs on the `apiKey` knob or an auth header.
16
+ - **Registered, not fixed here.** `assertServableRequestRoot` returns the RAW trimmed spelling, so `resolveRequestRoot` can hand the brains a root carrying a query and/or fragment, which they then append `/chat/completions` after (`https://h/v1?tenant=a/#note/chat/completions`). That is a defect about request CONSTRUCTION, and what a base URL may carry is a contract decision above this change.
17
+ - **The example that teaches this got the law too.** `src/examples/custom-brain-provider.ts` hand-rolled `${(cfg.baseUrl ?? model.baseUrl).replace(/\/$/, "")}/chat/completions` — no request-root check, a one-slash strip where the engine strips all of them, and the precedence backwards (the ENTRY's `model.baseUrl` wins over the deployment's, which is what the pairing law assumes). `resolveRequestRoot` is therefore now **exported**: a custom brain is a seat that builds a request root and owes the same question, and an example cannot teach a law it has to deep-import to reach.
18
+ - **Reach — BREAKING.** The export `normalizeBaseUrl` is **removed**; `routeKeyOf` is the same function under the name that says what it is, and `displayUrlOf` + `resolveRequestRoot` are new (net −1/+3, count 2395 → 2397). `@sema-agent/server` imports `normalizeBaseUrl` (`src/hooks/hook-llm.ts`) and must rename the import; its behavior is unchanged. `RouteRefusalDetail.{entryBaseUrl, requestBaseUrl, configBaseUrl}` now carry the DISPLAY spelling — they were never route-identity values to compare against a catalog, and now they say so. A consumer that needs identity computes it with `routeKeyOf`. One conformance vector added (`deployment-mismatch-query-differs`): an implementation comparing origin+path only pairs a credential with someone else's tenant.
19
+ - **Sibling site (merge-tree review).** `primaryActivityArg` (`src/core/arg-summary.ts`) composed `origin + pathname`; an OPAQUE scheme (`weird:`/`blob:`/`mailto:` — `host` empty) rendered `nullu:<secret>@h/v1`. Same rule as the renderer: no authority ⇒ withhold by position. Narrowing on the activity line for opaque URLs only; `test/backlog950b-activity-arg-opaque-scheme.test.ts`.
20
+
21
+ ### The memory-distillation chat seat asks the request-root question the brains ask (#943; P2; no wire key)
22
+ - **Before.** `openAiCompatChatSeat` screened its `baseUrl` for emptiness and nothing else, then built `${baseUrl}/chat/completions` by hand. A root carrying userinfo, a `file:`/`ftp:` scheme, or a spelling that is not a URL at all constructed cleanly and failed at `fetch` — as a transport error indistinguishable from an outage, so the consolidation driver retried it for the whole backoff ladder. #933 ② closed the same three shapes at the three brain factories; the distillation seat builds a request root too and was not asked.
23
+ - **After.** The root goes through `assertServableRequestRoot`, the same law, judged at CONSTRUCTION. The ENVELOPE stays this seat's (`config.memory_consolidation_driver`, like its sibling api-key / alias-model screens) because a host screens on that code and the driver routes config-class throws differently from transport ones; only the envelope is local, the question and its wording are the adjudicator's. The trailing-slash strip moves to construction with it, so the root is read once instead of once per call.
24
+ - **Reach.** `RunnerDeps.memoryConsolidationDriver.chat = { baseUrl, apiKey, model }` with one of the three bad roots now refuses where it is configured instead of on the first distillation run; a userinfo root is named as `[redacted-credentials]@host`, never echoed. Servable roots are untouched.
25
+
26
+ ### The `compacted` event publishes the delta the engine MEASURED (#946; P2; additive wire key `freedTokens`; @cli @server @client-core)
27
+ - **Before.** The event carried `triggerTokensBefore` and `tokensAfter` and documented them as a matching pair to subtract. They are not one: the trigger gate reads a usage-ANCHORED figure whenever the provider's last usage is fresh, while the post size is always structural + the fixed prompt overhead (`promptOverheadTokens`, added unconditionally). Subtracting across them mixes coordinates and goes NEGATIVE on ordinary runs — **measured on the plain auto lane: `3037 − 4591 = −1554`**, and again on a short-session `/compact` (`4237 − 4987 = −750`), with no error anywhere in the pass. `force` / `forceUnderThreshold` remove the floor that used to hide it. The engine's own self-consistent delta, `freedTokens`, existed inside the compaction all along and never rode the wire.
28
+ - **After.** `freedTokens` is on the `compacted` event from all three mint points (turn boundary, PTL recovery lane, end-of-task settle) — one structural sum minus the same structural sum, clamped ≥ 0 at the mint. The two raw sizes stay, un-clamped and un-deleted, retyped as DIAGNOSTICS with "not subtractable" said at the type and in `docs/INTEGRATION-CORE.md`: they answer *what tripped the trigger* and *how big is it now*, which are different questions from *how much was freed*.
29
+ - **No fabricated numbers.** When the post measurement fails, `freedTokens` is now ABSENT rather than `preChars` ("fully effective" — never true of a landing that leaves a summary behind). The two floor sites dropped their `postTriggerTokens ?? (triggerTokens − freedTokens)` fallback, which re-assembled the very mixed-coordinate subtraction and only ever ran when BOTH of its inputs were unmeasured. No measurement ⇒ the anti-thrash floor does not move, which is the same non-suppressing direction the fabricated value was chosen for, without the claim.
30
+ - **One mint, not three copies.** The `compacted` frame was spelled out verbatim in all three lanes, so adding this key meant editing three identical literals and guarding them with a test that grepped their SOURCE TEXT for a matching line. They now call one `mintCompactedEvent(comp, trigger, identity)` (`src/core/auto-compaction.ts`) — `trigger` and the identity envelope are the only things that differed. Rules: −2 copies of the frame, and the drift guard becomes a real invariant (`type: "compacted"` appears exactly once in `src/`, and one `comp` through all three triggers yields the same key set). Both module ceilings that the first shape had raised are banked back down, 18 lines each.
31
+ - **The hook seat had the same pair, and took the same fix.** `Hooks.postCompact`'s `PostCompactContext` carried `tokensBefore` + `tokensAfter` with no warning at all — and its pair is worse than the frame's, because `tokensBefore` there is prepareCompaction's coordinate, which lacks the overhead term the post size always carries. It gains the same `freedTokens?`, read from the same `freedTokens` the pass computed (one source, not a second mint), and both old members now say "DIAGNOSTIC, NOT subtractable" at the type. Pinned by equality: the hook's delta and the `compacted` frame's are the same number for the same pass.
32
+ - **Reach.** Additive on the wire. `RunnerDeps.onCompaction`'s `freedTokens` becomes optional in TypeScript (it can now be absent, and was previously a fabricated number in exactly that case) — a consumer doing arithmetic on it should handle absence. **@cli**: `/context`'s compaction line should read `freedTokens` instead of drawing its arrow from the two diagnostics. **@server @client-core**: forward the new key. `test/backlog946-compaction-freed-delta.test.ts` measures both negative deltas, pins the three mint points as a SET, and drives the failed-measurement arm.
33
+
34
+ ### An unwired — or inert — file-history store says so, once (#947; P2; no wire key)
35
+ - **Before.** `RunnerDeps.fileHistoryStore` needs two halves to do anything: the store, and a filesystem-capable `ExecutionEnv`. When they disagreed, core said NOTHING. The capture-side note retired with the capture REQUEST it disappointed, leaving exactly one loud moment — somebody asks for a restore and gets `rewind.store_unconfigured`. Until then a deployment edits files believing the engine records them (`trackFileEdit` is simply absent: zero verbs, zero notes, zero words), and a host that wired a store into a stub-env shape holds a store nothing will ever write to.
36
+ - **After.** One predicate over the disagreement, announced on the config channel the sibling prepare phases already use (`deps.onError`, `phase: "config"`, advisory, never a refusal). The ARM names the half that is missing, because the fix differs: no store ⇒ "nothing is recorded, and a later `restoreFiles`/`rewindFilesTo` will refuse"; no env ⇒ "the store is INERT". Both halves absent is NOT a disagreement — no tree, no store, no expectation — and stays silent on purpose; that arm is pinned as a negative control so the silence is a decision rather than a gap.
37
+ - **Said once per Runner, per arm.** A parent plus N same-tree delegated children run through one Runner and would otherwise hear the same configuration fact N+1 times. The memo is keyed on the `onError` seat object, and a Runner wraps that seat exactly once at its deps boundary, so every run through one Runner shares one identity — **three Runners handed the same raw sink are three wrappers and therefore three sentences** (measured). It is not once per deployment. The seat is also the only key available: the no-store arm has no store, and with the machinery off the coordinate resolver answers the run's own session id and never the lineage's, so a parent and a same-tree child do not share a history scope.
38
+ - **Reach.** A Runner with hands and no store gains ONE operator line, on its first run. Nothing refuses, no note reaches `TaskResult`, no model-facing text changes, no wire key. `test/backlog947-file-history-unwired-disclosure.test.ts` pins both arms, both controls, one sentence across three runs of one Runner, a second Runner hearing it on its own, and the two arms deduplicating apart.
39
+
40
+ ### A peer row with no activity word reads `unknown`, instead of being called `idle` (#940; P2; no wire key; @cli)
41
+ - **Before.** The peer lane's state was a ternary — `dead ? "offline" : tempo === "active" || tempo === "blocked" ? "busy" : "idle"` (`src/agents/list-agents-tool.ts`). `tempo` is a member a HOST writes, and neither sema host writes one, so the else-arm was the COMMON path: every live peer session on the machine was reported to the model as `idle` — "that session is doing nothing" — about sessions nobody had asked.
42
+ - **After.** The peer half of #917's rule: one table (`PEER_ROW_STATE`: `active`/`blocked` → `busy`, `idle` → `idle`), an OWN-key lookup for the same reason `agentRowState` uses one (a directory seat is a host face, and a row that never passed `readPeerSessionRecord` can carry `constructor` as its tempo), and `unknown` for anything the table cannot answer — including the absent word. Liveness still pre-empts: a dead row is `offline` whatever it last wrote. Rules: the two lanes now state ONE rule ("a row's state is a table lookup over the word the row carries; anything else is `unknown`") instead of a table on one side and a hand-written ternary on the other.
43
+ - **The key STAYS.** `PeerSessionRecord.tempo` was proposed for retirement as a dead key (no sema writer). It is not dead: CC 2.1.259 writes it from its own session-activity classifier, with the identical word set `active | idle | blocked` (`blueprints/cc-decoded/cli259.js` offset 22754933, through the export `updateSessionActivity` — export table at offset 30739365). The engine owns the row SCHEMA and a host owns the VALUE, so the missing writer is a host-side gap, not a dead abstraction — **the CC-parity writer is pending on the shell side (@cli)**. Retiring the key would have been a divergence from an answered CC question, and the shell would have had to re-add it.
44
+ - **Reach.** No wire key added or removed; `ListAgentsRow.state` keeps its exact union. One value change on one lane: a live peer row whose host wrote no `tempo` now reads `unknown` instead of `idle` (rendered line: `name [ref] · unknown · peer session · session.<id>`). Consumers that render the word are unaffected in shape; a consumer branching on `idle` to mean "free to take work" was being told that about every peer. `test/backlog940-peer-row-state.test.ts` pins the absent word, the table, the liveness pre-empt, five prototype-key tempos and an agent-lane negative control.
45
+
46
+ ### A member turn is re-dispatched only where a second attempt could come out differently (#941; P2; no wire key)
47
+ - **Before.** `runTurn` (`src/agents/team.ts`) retried every turn that did not settle `completed`-with-text, once, with the identical objective, prompt, model and toolset — including terminals that cannot come out differently. A `blocked` cause waits for nobody by its own definition, and a code the governance table adjudicated TERMINAL is a policy question the control plane has already answered: each cost a whole extra member run to reproduce the same word, and the governance family re-asked an answered question of the control plane.
48
+ - **After.** The retry is refused only for the two terminals the engine can POSITIVELY identify as deterministic: a `blocked` cause, and a `failed` whose code the governance table adjudicated TERMINAL (read off `classifySubagentError`, the classifier the delegation faces already answer with — no second opinion about any code). Everything else keeps its second attempt, INCLUDING the classifier's `logic` bucket: that bucket is an else-arm — every code with no row of its own lands in it — so hanging "never retry" on it would make each future brain code silently non-retryable here. An independent review measured exactly that: `stream_torn` (a response lost before any content or finish reason, the textbook re-issuable failure) lives in that bucket and lost its retry under a first draft of this change. Rules ±0.
49
+ - **Reach.** No wire key, no text change: a blocked or policy-refused member still renders the same `[unavailable: <status>/<code>]` marker, and the discussion, transcript and budget accounting are unchanged in shape. What changes is spend — such a turn now costs one child run instead of two (measured on a scripted runner: 3 runs instead of 6 for a two-member, one-round discussion). `test/backlog941-deterministic-terminal-no-redispatch.test.ts` pins the blocked arm and four terminal governance codes, with six retryable codes, the whole `logic` else-arm (`stream_torn` / `refusal` / `length_empty` / a code this engine has never seen), the code-less failure and the empty-completed turn as positive controls.
50
+
51
+ ### The last bare reads of a value the engine did not construct (#942; P2; no wire key)
52
+ - **Before.** #926 and #935 made `src/core/thrown-value.ts` the engine's one rule for reading an untrusted value; nine sites had not taken it, and two of them were the load-bearing kind. A third-party `execute`'s RETURN value was read bare (`ret.content` / `.details` / `.terminate` / `.isError`) by a `normalizeContent` that ran OUTSIDE `defineTool`'s own try — so a member behind a throwing getter, a revoked proxy handed back as `content` (`Array.isArray` traps one), or a single hostile content BLOCK raised a raw `TypeError` past the tool wrapper. The `prepare()`-throw walk in `stream-settle-backstop.ts` had no guard at all, inside the backstop whose entire job is to make a thrown prepare land as a result. `errorCodeOf` wrapped its whole `cause` walk in ONE coarse `try`, where a hostile `code` at depth 1 lost every code beneath it. The card projector, the exec-gate step's catch and the MCP failure-copy read `details` / `name` / `code` bare, all three ON a failure path. And four workflow rows read a thrown cause through `instanceof Error` — the test a genuine Error minted in the script's `vm` realm fails, so the row went anonymous exactly where the cause came from the script.
53
+ - **After.** All of them read through `readMember` / `isErrorLike`. Two structural changes rather than more guards: `defineTool`'s whole result-building path — normalize, empty-output guard, the returned object — now runs inside the ONE try it already had, so every read of the returned value leaves through the formatted-tool-error door instead of escaping the wrapper (measured: an `AbortError`-named raise from a content block now reads `Interrupted`; it used to escape with the thrower's own words); and `errorCodeOf`'s coarse `try` is gone in favour of per-member reads, so an unreadable link costs THAT link and the walk continues (measured: `{ code: <throws>, cause: { code: "x" } }` answers `x`, and used to answer nothing). Rules: −1 coarse `try`, and the two class-level rules this sweep needed now have ONE home each beside the other readers — `thrownIs` (a total `instanceof`) and `thrownName` — with the four private copies that existed or were first written locally (`tool-errors`'s `isErrorInstance`, `auto-mode`'s timeout test, `exec-gate`'s, `workflow`'s) folded into them.
54
+ - **Two of the nine were NOT bare, and stay as they are.** `run-harness-handlers.ts:390` reads `.details` off an object the engine mints at `defineTool`'s exit (the one adaptation point every first-party, caller and MCP tool passes through), and the deeper `filePath` read below it is gated to three first-party tool names. Stated rather than silently skipped.
55
+ - **Four more sites, found by an independent adversarial review of this change and verified against the code before being taken.** The `retryAfterMs` read TWO statements below the walk that was just guarded, inside the very object literal that becomes the result — unconditional, ahead of the code test beside it, so the backstop that exists to make a thrown prepare LAND would itself have been the throw. `thrownErrorCode` (`subagent.ts`), one helper feeding three REJECT legs that run before registry settlement and the terminal notification, the background one inside the final catch handler. The exec-gate's class-POSITIVE arm, which still read `e.code` bare — recognizing a class does not make its accessor safe. And all thirteen `instanceof WorkflowXError` tests in `workflow.ts`, whose FIRST act on a failed leg is the class test. One of those is reached by a PUBLIC primitive: `ctx.parallel` / `ctx.pipeline` build their settled list from a script-supplied thunk, so whatever the script throws — and the script runs in its own vm realm with fresh intrinsics — lands verbatim on the fan-out fold's class test. Spelled `instanceof`, a revoked proxy raised out of the fold and rejected the whole batch, breaking the contract stated four lines above the call site ("a fan-out slot NEVER rejects the batch — every failure folds to null"). Measured both ways: the new pin folds the hostile thunk to `null` and reds on the bare spelling. On the two CATCH LADDERS the same guard is reasoned rather than reproduced — no construction staged from a test reaches them with a hostile value — and the control test there says so in as many words.
56
+ - **The shared rule, not a fifth copy.** The guarded class test now lives beside the other readers as `thrownIs` (`src/core/thrown-value.ts`), and the three private copies that already existed or would have been added — `tool-errors.ts`'s `isErrorInstance`, `auto-mode.ts`'s `isAutoModeTimeout`, and the two this change first wrote locally — are gone or now delegate. Net implementations of that rule: −2, where a first draft was +2. Three more same-class sites were swept with it: `observer.ts`'s `errorName` (compared against name CONSTANTS precisely because the value crosses a host seam, yet gated on this realm's `Error`), `session.ts`'s `isSessionConflict` and `session-store.ts`'s `isNotFound` (an `in` test — a `has` trap — plus the getter behind it), and the ten twin renders in the in-memory `FileHistoryStore`, whose own containment comment promises that a misbehaving host `ExecutionEnv` costs only the file it threw on.
57
+ - **The TENTH station, found on the real lane, not in the diff.** `reduceToolEnd` (`turn-attachments.ts`) reads a third-party `details` inside the harness's tool_end handler — outside the tool wrapper's try, and a SECOND time after the result store already walked the card. Measured end to end with a Runner, a scripted brain and a third-party `ToolSpec`: a `type` getter that answered once and raised on the second read ended the whole run as `failed` / `provider.error` on turn one, and the model never reached turn two. Every read in the reducer is now guarded, lists COUNTED and INDEXED rather than `Array.isArray`-ed and iterated (all three are proxy traps), the scan clamped rather than refused, and an unreadable card leaves the state exactly as it was. Both task cards now build their projected item through one `readTaskEntry` instead of two copies. The module ceiling moves 2221 → 2234 with that reason on the table line; the previously reported `run-harness-handlers.ts:390` is NOT itself a bare read — it reads an engine-minted result — which is what the earlier pass said, having stopped at the line instead of following the value into this reducer.
58
+ - **The ELEVENTH station, same shape, the ERROR arm.** `toolEndBodyFrom` (`tool-end-body.ts`) lifts three discriminators — `code`, `errorKind`, `delivered` — off the same third-party `details`, in the same handler, outside the same try. Measured: a `code` getter raising on its SECOND read ended the run on turn one. All three reads are guarded; unreadable is absent, which is the arm the string filters already had. A TWELFTH candidate was investigated and is NOT reachable, measured both ways: the LSP nudge's `details.filePath` read needs the lane wired (which needs hands) AND one of three tool names — and with hands mounted those names belong to the first-party fs tools, whose details this engine mints (a caller tool offering the name is shadowed; measured), while without hands the lane is closed. Both halves are pinned as negative controls so a change to either is visible.
59
+ - **The snapshot bag is DEFINED, not assigned.** `readOwnMembers` copied member by member with `out[key] = …`, and `__proto__` is an accessor on `Object.prototype` — so a value carrying an OWN `__proto__`, which is exactly what `JSON.parse` yields for `{"__proto__":{…}}` and exactly how a provider's error body arrives, swapped the bag's prototype instead of being copied: measured, `bag.errorKind` then read `"forged"` off the injected object, the key that did it vanished from `Object.keys`, and the bag was no longer an ordinary object. The copy now defines each key, so `__proto__` lands as an ordinary own data key and the bag stays on `Object.prototype`. It reaches a frame through `thrownMarksOf`, whose whole job is to carry a thrown value's `details` onto a tool result — and `errorKind` is the key that result's discriminator lift reads.
60
+ - **The abort word is decided by the NAME.** `formatToolError` folded a cancellation to `Interrupted` behind a realm-bound class test, so an `AbortError` minted elsewhere rendered as its own text and read like a failure; it now asks `thrownName`, and the file's remaining brand tests are `isErrorLike` — one brand rule where there were two.
61
+ - **Reach.** No wire key, no code word, no text change for any readable value. Three text changes where the value was hostile, each named above. One unannounced narrowing, stated: a tool result's `terminate` is now read `=== true` rather than `?? false`, so a BYOM tool returning a truthy non-`true` no longer terminates the batch — every in-tree consumer already tested `=== true`, so nothing observable moves. `test/backlog942-guarded-reads-residue.test.ts` drives the three shapes (null prototype / throwing getter / revoked proxy) through the tool wrapper, both `cause` walks, the card projector and the exec-gate step (both arms), with a positive control on each arm.
62
+ - **Regression caught at review, fixed before the tag.** The guarded list reader accepted anything with an integer `length`, so a STRING `newTodos` (`"bad card"`) cleared the todo snapshot where the base ignored it. A list is an object with a length; strings and numbers are not lists (pinned). A card with unreadable ITEMS still ingests the readable subset (KL-942e).
63
+
64
+ ### The two identities of a background delegation child are written down (#948; P2; docs only; @client-core @cli)
65
+ - **Before.** A background child carries a registry HANDLE (`a…`) and a session/run id (uuidv7), on different faces, and nothing stated which face carries which or what joins them. A consumer folding them into one address is wrong on one of the two faces — the question client-core asked, and the anchor cli's verification needs.
66
+ - **After.** `docs/INTEGRATION-CORE.md` §1.6a: one table (fleet row frame / `task_progress` / roster row × handle / session id / join key) plus three contract sentences — ① the registry id is a handle (`TaskOutput` / `TaskStop` / `SendMessage`) and a session id is a transcript/run id (`AgentTranscript`, resume), neither derived from the other, anchored once at spawn by `bindBackgroundAgentSession`; ② `task_progress.taskId === BackgroundChildEvent.transcriptId`, because a run's task id is `spec.taskId ?? sessionId` and all three background lanes set no `spec.taskId` (each says so at its own launch site); ③ roster / fleet `address` is the registry `task_id` verbatim. Every claim carries its coordinate, each one re-verified against the tree. One sentence each on the `task_progress` and `ListAgentsRow.address` JSDoc points at the table.
67
+ - **Reach.** Documentation only — no code path, no wire key, no text. Nothing to consume; it states what was already true.
68
+ ### The two credential vocabularies name the same vendors (#954; P2; narrowing; @server @cli @test)
69
+ - **Before.** This engine carries two credential-shape tables with two different head risks — the REFUSE-WRITE table (`detectSecret`, whose hit fail-closed rejects a durable memory write, so its head risk is the false positive) and the REDACTION table (`scrubSecrets`, spread into `redactSecrets` and `redactHostLeaks`, whose miss puts a credential in persisted and forwarded text). Neither had ever been pinned against the other, and four vendors had drifted apart. Measured on synthetic shapes: `github_pat_<60+>` (a fine-grained PAT) and `AccountKey=<base64 40+>` (an Azure connection-string key) were confident enough to REJECT a memory write and rode all three redaction faces **verbatim**; a PGP private-key block — banner `-----BEGIN PGP PRIVATE KEY BLOCK-----`, the exact form a write-gate bypass was once fixed for on the other table — rode them verbatim **header, body and footer**; and `hf_<20+>` was stripped from every egress text while being **accepted** into durable memory.
70
+ - **After.** Four rows, no new mechanism. The fine-grained PAT prefix joins the existing prefixed-token row's vendor list (widening its `gh[opsur]` class cannot reach it — the character after `gh?` is `t`, not the separator the row requires — and would pull every `gh?_` identifier in). The private-key row's banner takes the optional ` BLOCK` suffix on BOTH ends: widening only the opening banner leaves the closing one unmatched and the row's end-of-input fallback then eats every byte after the block. Azure gets its own high-confidence row mirroring the refuse-write shape exactly, because the keyword-value arm's lookbehind deliberately refuses a keyword glued to a preceding letter (`Key` behind `Accoun`) and dropping that would flag every `…key=` word in prose; the field name is preserved and only the value replaced, as that arm already does. And `hf_` joins the refuse-write table with that table's word boundaries AND a digit requirement — the same second discriminator its bearer row carries, and the narrower of the two spellings, which is the correct direction for the face whose head risk is the false positive. The first cut of that row had only the separator-free body and flagged plausible identifiers (`hf_HubApiListModelsPaginated`, `hf_ModelCardMetadataParser`, `hf_datasetsloadingscriptcachedirectory` — all measured as rejected memory writes); an adversarial round caught it before this shipped. A real token is a random base62 body and carries a digit; a camel-cased or lowercase symbol name does not.
71
+ - **Pinned in both directions.** `test/backlog954-credential-vocabulary-parity.test.ts` claims **every live row of both tables by identity** — the refuse-write table's label plus its regex source (parsed out of the module text, since that table is deliberately not exported), the redaction table's regex source off the exported passes — and asserts claims and live rows are the same set both ways, so a row added to or edited in one table alone reds by name. On top of that: every shape the narrow table names is stripped by all three redaction faces; every vendor row of the broad table is recognized by the narrow one **or** carries its own written reason (four are deliberate and are pinned as such: the publishable `pk_` class, a bare `Bearer` word with no `Authorization:` anchor, the two faces' different body floors, and the hyphenated word slug that is the refuse-write face's named head false positive — class-wide waivers are not accepted); fourteen false-positive neighbours stay clean on both faces; the composed tiers are shown to inherit by spread, not by copy, on shapes only the new rows catch; each credential is asserted stripped at EVERY occurrence, not just the first; and the two tiers' own four rows (URL credentials, signed-URL parameters, whole URLs, filesystem paths) are pinned by name so a new one there reds too. A row's identity is its regex text TOGETHER WITH its flags — dropping a `g` leaves the text byte-identical while turning a redaction into a first-occurrence-only one.
72
+ - **Reach — narrowing, two faces.** Egress text: `github_pat_…`, `AccountKey=…` and a PGP private-key block now leave as `[redacted]` where they previously left verbatim. Two extent changes come with that, both measured: the new prefix creates matches that start EARLIER, so `see github_pat_docs-2024-release-notes-here for details` is now redacted whole rather than untouched (over-redaction, the direction this face declares cheap — the same class as the hyphenated word slug it already accepts); and the widened closing banner BOUNDS a match that previously ran to end-of-input, so a mismatched banner pair no longer eats the rest of the argument after the block. Every consumer of activity summaries, MCP error text, checkpoint command summaries and repetition segments sees the marker instead of the value; a consumer matching on those values in engine-produced text will stop matching, which is the point. Durable memory: a note carrying `hf_<20+>` now throws `MemoryGateError("memory.secret_detected")` at the write door — the `remember` tool, consolidation ADD and consolidation UPDATE alike — where it was previously stored. No wire key, no export, no code word added.
73
+ - **Refuse ⊆ redact, one more row (merge-tree review).** The PEM banner qualifier admitted digits on the refuse-write face (`RSA2 PRIVATE KEY`) but only letters on the redaction face; the redaction row reads the same alphabet now (`test/backlog954b-pem-qualifier-digits.test.ts`). A Google key whose 35-char body ends in `-` is redacted but not refused (KL-954f).
74
+
75
+ ### A failed workflow run's machine code is on BOTH poll faces (#932 follow-on; P2; additive key; @server @cli)
76
+ - **Before.** `formatWorkflowRun` put `errorCode` on the structural `details` face and not in the `content` JSON, while the comment beside the structural one asserted the code "reached the `content` JSON text" — it never did. A consumer reading the JSON face had only the fenced human text to classify a failed run, and the code told it otherwise.
77
+ - **After.** The same key, the same absence rule, on both faces; the comment states what the code does. `test/backlog932-workflow-summary-error-code.test.ts` pins both faces equal on a coded failure and the key absent on both for a codeless failure and a completed run.
78
+ - **Reach — additive.** A new optional key on the `content` JSON of a failed workflow `TaskOutput`; nothing is removed or renamed, and a run with no code carries it on neither face.
79
+
80
+ ## 7.23.5 — 2026-09-19
81
+
82
+ ### `ListAgents` states what a row IS, instead of calling every non-live agent `finished` (#917; P2; widening; @server @cli @client-core)
83
+ - **Before.** Both agent lanes of the listing read `status === "running" || status === "pending" ? "running" : "finished"`, so a `parked`, `failed`, `killed` or `cancelled` agent was reported to the model as **finished** — the word the tool's own note attaches the continuable rule to. The roster lane answered `unknown` only when there was NO local row at all; a row in a non-successful terminal was folded like the rest. The model-facing twin of the "an absent answer folds into a successful terminal" family.
84
+ - **After.** One table over the task-status union, shared by both lanes (`AGENT_ROW_STATE`): two folds about LIVENESS — `pending` is queued and certainly not finished, `completed` is the one status that earns `finished` — and passthrough for everything else, so `parked` / `failed` / `killed` / `cancelled` travel as themselves. It is a `Record` over the whole union, so a new task status reds `tsc` here instead of quietly inheriting the old else-arm. The registry's status is an OPEN enum on the wire, so a word this engine does not know now answers `unknown`, never `finished`. The `(N finished agent(s): …)` note counts only real completions.
85
+ - **Reach — widening.** `ListAgentsRow.state` gains `parked` / `failed` / `killed` / `cancelled`; nothing is removed or renamed. A consumer switching exhaustively over the union gains four arms; a consumer testing `state === "finished"` was being told the wrong thing for four of the seven statuses and is now correct. The peer-session lane (`busy` / `idle` / `offline`) is untouched. `test/backlog917-list-agents-state.test.ts` pins one row per task status on both lanes, the unknown-word arm, and the peer lane as a negative control.
86
+
87
+ ### Every inspector of a thrown value reads through the one guarded reader (#935; P2; no wire key)
88
+ - **Before.** `readMember` (`src/core/thrown-value.ts`) is already the engine's rule for reading ONE member of an untrusted value, but six inspectors had not taken it. `classifyConnectFailure` read `.code` / `.cause` / `.errors` bare — from inside the connect loop's catch block, so a throwing accessor raised there; `isAbortError` tested `instanceof` and read `.name` bare **inside a promise rejection handler nobody awaits**, which is the unhandled-rejection shape that takes the process down; the loop's `createErrorToolResult` and `defineTool`'s rewrap read a thrown value's `details` / `errorKind` bare (and the former SPREAD the bag, evaluating every getter behind it); `describeNetworkError` / `hasStaleConnectionCode` / `isAbortSemanticsError` wrapped their whole walk in ONE coarse `try`, where a single hostile link cost every layer below it; `tool-model-gate`'s rebrand hand-built its own guard and kept a dead second one around the already-total `thrownText`.
89
+ - **After.** All of them read through `readMember`, plus two new shared primitives rather than a seventh hand-built guard: `readOwnMembers` (the plural — copies a bag member by member instead of spreading it, and answers `undefined` for anything that is not one) and the now-exported `isErrorLike` (the realm-independent error brand test, so an error minted in a vm context or an injected transport is still walked). Rules: −4 coarse `try` walks, −2 hand-built guards, and two frozen rows leave the `src/brain` silent-arm ratchet (banked, not held as headroom).
90
+ - **The class, swept, not the six reported points.** An independent review found the same read hand-rolled in four more places — `circuit-breaker.ts`, `failover.ts` and `degrading.ts` (twice) each spelled `e instanceof Error && e.name === "AbortError"` on a value an injected brain threw, inside an async IIFE with **no `.catch`**, where a raise is an unhandled rejection. They now share one guarded reader, `abortLikeName`; WHICH names count stays at each site, because that is policy and it differs (the breaker deliberately excludes `TimeoutError`). Its sibling `brainErrorCodeOf` replaces the one `instanceof BrainError` in the same IIFE. Side effect, measured by the same review: a cancellation minted in another realm used to fail `instanceof` and be counted by the circuit breaker as provider ill-health.
91
+ - **Finer, not just safer.** A member that refuses to be read now costs THAT member: a hostile `code` no longer loses the deterministic verdict sitting on `cause`, and a hostile `details` no longer loses the `errorKind` beside it. The aggregate leg list is read the same way, down to its LENGTH and each index — `Array.isArray` answers true for a Proxy over an array, so the bare `.length` behind it ran that proxy's trap and threw out of the classifier, on the path that decides how many times a connect failure is retried (measured; the call ended at one attempt where the budget said three). Values that will not say what they are still answer the fail-safe verdict — indeterminate for the connect classifier, "not a cancellation" for the abort tests.
92
+ - **Reach.** No wire key, no code word, no text change for any readable value (each site's negative control pins the previous bytes). One text change where the value was hostile: a transport rejection whose `name` accessor throws used to surface at the terminal as the ACCESSOR's words, unclassified (measured: `"hostile name"` in place of the rejection); it now surfaces as the rejection's own message with its brain code. `test/backlog935-guarded-inspectors.test.ts` drives the three hostile shapes (null prototype / throwing getter / revoked proxy) through every site.
93
+
94
+ ### A request the runtime refused to BUILD is no longer a retryable network failure (#933; P2; @server @cli @client-core)
95
+ - **Before.** `fetch` answers with a `TypeError` for two unrelated things — a network error, and arguments it will not build a request from — and the connect loop folded both onto one arm. A gateway base URL carrying userinfo (`https://user:pass@host`) makes the runtime throw `TypeError("Request cannot be constructed from a URL that includes credentials: …")` with no `code` and no `cause`; the connect classifier read that empty error graph as "nothing I can judge", answered indeterminate, and the call then spent its entire backoff ladder re-failing identically before a packet was ever sent (measured downstream at ~3 minutes per call, every call).
96
+ - **After — one rule, and a STRUCTURAL one.** `classifyFetchThrow` says whether a throw out of `fetch` was about the NETWORK or about the REQUEST, by the same law the neighbouring connect classifier states (never by message text): a throw that reached the network carries the transport's own error as `cause`, while one the runtime refused to BUILD has no cause at all or nests only a parse code. Measured over ten shapes — userinfo / unparseable URL / unsupported method / invalid header name / GET-with-body on one side, refused / DNS / pre-header reset / TLS-onto-plaintext / mid-body destroy on the other. An argument refusal leaves through the same door a pre-flight refusal does: `BrainError("invalid_request")`, no retry, the runtime's own sentence carried (it names the defect) with every authority's userinfo removed WHOLE first — it quotes a URL, and the general redactor keeps a short or unprefixed username. No `apiError` mark — nothing reached the wire, so an operator is not pointed at the provider. Every non-`TypeError` rejection (an injected transport's own class, the engine's connect-timeout `Error`) stays on the transport arm and still climbs the ladder.
97
+ - **And the bad value is refused where it ARRIVES.** One assertion on the request root, at the seats that accept one: it must be an ABSOLUTE http(s) URL and it may not carry the credential. `createOpenAIBrain` / `createAnthropicBrain` / `createOpenResponsesBrain` refuse a bad `config.baseUrl` synchronously at construction, and `Model.baseUrl` — which no factory can see — is refused at request build, before the transport is entered and before the pairing law speaks (so an unservable root is diagnosed as one, not as a credential mismatch). The scheme half earns its place by measurement: `file:` and `ftp:` reject with `TypeError("fetch failed")` over a causeless "unknown scheme" — the exact shell a refused connection uses — so nothing downstream can tell that settled defect from an outage, and it bought the whole ladder. Refusing is the only honest answer for the credential half too: silently stripping the userinfo would send an UNAUTHENTICATED request to the host the operator thought they had authenticated to. Rules: the three brains' copies of the coalesce-and-strip root line fold into one `resolveRequestRoot` (−2), and the two bad-root refusals are one assertion at one seat (+0).
98
+ - **Reach.** No wire key added, removed or re-typed. One class of failure changes code (`network` → `invalid_request`) and arrival time (one attempt instead of a ladder); a host that constructs brains from operator-supplied config must be ready for a synchronous throw. Stated cost in `docs/KNOWN-LIMITS.md` (KL-933a). `test/backlog933-unbuildable-request.test.ts` pins both halves against the real runtime, with the real-socket `ECONNREFUSED` negative control.
99
+
100
+ ### Workflow list rows state WHY a failed run failed (#932; P2; additive; @server @cli @client-core)
101
+ - **Before.** `WorkflowRunSummary` — the shared projection every `WorkflowRunStore` backend hands back from `listByScope` — already carried the three disclosures that qualify a row which LOOKS clean (`agentFailures`, `budgetOvershoot`, `timeoutInterruption`), but not the run's own `errorCode`. So a `/workflows` panel reading rows saw one word, `failed`, for a refused spawn, a blown token ceiling, a stalled run and a body that simply threw — and had to `get` the full run per failed row (or detour through a `TaskOutput` card) to say which.
102
+ - **After.** `WorkflowRunSummary.errorCode?: string` is projected VERBATIM from `WorkflowRun.errorCode`: same word, same vocabulary, no second attribution rule (the thrown-code → first-failed-agent → absent ladder still runs once, where the run is written). Present exactly when the run carries one; absent reads "no code on the record", never "the run succeeded". Because the projection is shared, every backend answers alike — the importable `workflowRunStoreContract` now asserts the round-trip, the absent key on a codeless failure, and the absent key on a completed run, so a service-side store that keeps rows in columns is told it needs one.
103
+ - **Reach.** Additive: no key removed, none re-typed, every pre-existing member byte-identical. Consuming is optional. `test/backlog932-workflow-summary-error-code.test.ts` pins the projection on both in-tree backends.
104
+ ### Permission rules — the rule lane reads a quoted shell operator as argument text (#931; P2; @cli @server)
105
+ - **Before.** `parseRuleText("Bash(awk '{print $1}' f)", "allow")` answered `invalid.not_simple_command`, and so did `Bash(grep 'x$' f)`, `Bash(cat '$F')` and `Bash(grep "a|b" f.log)`. The card OFFERED those rules and the store refused them: the offer round trip is fail-closed, so the "don't ask again" option simply vanished. #921 gave the whole-string classify gate a quote mask; the rule lane's own per-segment floor (`parseLeadingCommandName`) stayed quote-BLIND and refused every operator character wherever it stood.
106
+ - **After.** The lane calls that floor through ONE option set (`RULE_LANE_FLOOR`) carrying `quotedOperatorsAreText: true` — the same `quoteMask` sieve, not a second reading of quoting. The per-reader `quotedOperatorsAreText` axis on the lane's `LaneReading` is gone (both readings always took the same answer): rules −1.
107
+ - **The matcher's fail-closed claim is now asked out loud.** `admitsUnder` refuses a stored BODY whose quoting is unbalanced, which its own contract already claimed ("a body this lane cannot read matches nothing"). While the floor refused a quoted operator that was true by accident — an unbalanced body could not reach a command that parsed. Reachable only through corrupted or foreign store bytes (no entrance mints one), so behaviour is unchanged for every rule this engine wrote.
108
+ - **REACH, and it is wider than the exact form (@cli @server).** A PREFIX rule widens with the floor: `Bash(grep:*)` now admits `grep 'x|y' f`, `grep 'a;b' f`, `grep '$x' f` and `grep 'x&y' f`, each of which asked before. A stored prefix that names a program therefore reaches every quoted-operator spelling of that program's arguments. Still refused, quoted or not: `"$x"` (double quotes do not suppress expansion), `'$(id)'`, a backtick, a line break, a redirection, and `'x'|'y'` (the pipe is unquoted). A deployment that audits stored rules should re-read its prefix rows against this reach.
109
+ - **What did NOT move.** The whole-string gate one step above is untouched and is what keeps this exact: an unquoted or double-quoted `$`, a backtick, a subshell, an escape, a line break and (on the matching/minting reading) a redirection still refuse the WHOLE text, quoted or not, so the only operator characters that can reach a segment are ones bash itself reads as literal text. Unbalanced quoting turns the mask off for the whole string (fail-closed). The interpreter-prefix refusal (`Bash(awk:*)`) and the DENY/ASK direction (its own lexer, `programRunReachOf`) are unmoved — the "deny reads more commands than allow, never fewer" asymmetry still runs the safe way.
110
+
111
+ ### Closed sets — both shell-decline SEAT TABLES declare a row per member, behind a fence (#924; P3)
112
+ - **Before.** Which {@link ShellScanDecline} a seat reads as read evidence was a `Set`, twice: the read-boundary seat's was DERIVED from the closed set (`new Set(SHELL_SCAN_DECLINES)`) and the classify seat's was an empty literal (`new Set()`). A THIRD member added to the set would be picked up by one seat automatically and missed by the other — silently, and in the WIDENING direction (a MANDATED card becoming an ordinary one). No fence, no gate and no reviewer had a handle on it.
113
+ - **After.** Both are row-per-member `as const` tables beside the set they range over (`bash-program-position.ts`), each with an `AssertAllKeysHandled` fence, registered in `docs/CLOSED-SETS.md` so the scan leg (`npm run gate:closed-sets`) reads their keys against the set's members in both directions. The two `Set`s the seats' readers take are DERIVED from the rows, so neither can say what its table does not. "Empty by the argument" is now a row saying `false`. Zero behaviour change: the derived sets are byte-for-byte what they were.
114
+ - `QuoteOpen` (`bash-lexer.ts`, the quote mask's per-character word) is registered in the same change — the release's wire-change manifest saw it as a closed set and the registry did not.
115
+
116
+ ### Approval cards — a deny-hit card names the sensitive-path deny list and the pattern (#939 ①; P2; @cli @server)
117
+ - **Before.** On the full Bash face, `cat .env` under `readDenyPatterns:["**/.env"]` raised a card reading, in full, `tool "Bash" performs an irreversible action — explicit approval required`, `origin:"shell_gate_tighten"`, and nothing else. Nothing said a deny row was what raised it; nothing named the pattern. The SAME table on the structured read faces refuses and names the pattern (#937), so an approver on the shell face answered YES about credential-class bytes with no clue what they were releasing.
118
+ - **After — the read faces' own sentence, all FIVE parts.** The card carries `probeReason` = `reading "<absolute target>" matches the sensitive-path read deny list (pattern "<pattern>"). This list guards credential-class paths and applies regardless of the containment roots.` — the ACTION, the resolved ABSOLUTE target, the deny table by name, the PATTERN verbatim, and one sentence of why. It composes the SAME two pieces the refusing faces compose (`sensitivePathDenyList` + `SENSITIVE_PATH_DENY_SCOPE`, `read-deny.ts`), now the ONE naming for all four composers; every existing refusal text is byte-unchanged. Only the disposition differs, and it must: this card is approvable, so "is refused" would be a lie on it.
119
+ - **The finding is the SEGMENT's, not the command's.** A compound whose one dirty segment is the hit (`head inside.txt && cat .env`) names THAT segment's path and pattern, in either segment order — a card saying only "this command hits the deny list" leaves the approver reading two segments with no way to tell which half is the problem.
120
+ - **Where it rides, and why.** `probeReason`, never the ask's `message`: the message is what the auto-mode classifier is shown, and a probe must not own an input to the decider that can clear its own ask. The gate caps that string at `PROBE_REASON_MAX` by truncating the TAIL, so the mint elides a long operand in its MIDDLE instead — every part survives and the elision is visible. (`PROBE_REASON_MAX` moves to the leaf neutralizer module beside its `PROBE_CAUSE_PATH_MAX` sibling, re-exported from `checkpoint-store` so the public spelling is unchanged: the mint, the gate and the persist boundary now read one constant.) The account is minted ONCE (`probeAccountOf`) and spread at every arm a shell seat answers a verdict from — including the backgrounded arm and the poll-loop arm, which carried no account at all. The recursive-reach `cause` rides the same mint, unchanged.
121
+ - **@cli @server** — `AskRequest.probeReason` / `RiskDescriptor.probeReason` now carry a value on this family where they carried none. No new field, no new code, no wire key. Consumers already rendering `probeReason` get the sentence for free; a consumer that renders nothing is unchanged.
122
+ - **The finding is read from whichever pass found it.** When the walk cannot NAME the program (`env --`, `nice -x 5`, `watch`, `sudo -u`), when the operands arrive on a STREAM (`… | xargs …`), or when the grammar gate refused the command whole (`{ cat <deny row>; }`, `ca''t <deny row>`), the compound verdict carries no deny fields at all — the row is found by the containment gate's second pass, which computed the path and the pattern and threw both away. `OutOfRootReadGateVerdict.denied` now carries the finding, and the card reads it from there: the shapes an approver can least read for themselves are no longer the silent ones. A shape whose row NO pass could read stays bare — its mandate is the honest weaker one, and a card naming a pattern there would assert what nothing measured.
123
+ - **The TRAVERSAL mandate names its table too.** `recursiveReadPaths` (`grep -r`, `diff -r`) is minted only beside a wired judge WITH entries, so it is a deny-table mandate as much as a match is — and it carried no sentence at all. It cannot name a matched row (the walk is zero-I/O and does not enumerate the subtree, so claiming a hit would be false for the same command in a tree that holds none), so it names what IS true: the ACTION, the resolved ABSOLUTE root, the table by name, the PATTERNS in force, and the clause that makes it a mandate. Rows are dropped whole and counted, never cut into a pattern nobody wrote. A match beside a traversal reports the match.
124
+ - **The table's REACH clause leaves the capped sentence.** It is the same 88 characters on every card of this family — fixed text, not a finding — and inside the 200-character cap it was spending more than half the budget: with the engine's OWN built-in deny table (eight rows longer than 22 characters, the longest 47) and a real project root, BOTH variable parts were elided and "the pattern verbatim" stopped being true of the engine's own rows. Composing two pieces instead of three takes the room for them from 45 characters to 133. The REFUSING faces are uncapped and keep the full sentence.
125
+ - **Not moved:** who may answer the card. The ask is still MANDATED and still approvable, a YES still runs the command, a NO still refuses, and the read-only face still REFUSES outright. Whether the full face should refuse rather than ask (#939 ②) is a separate ruling.
126
+
127
+ ### Read boundary — `Monitor` runs the same execute-time canonical recheck the two `bash` faces run (#938; P2; @server @cli)
128
+ - **Before.** `Monitor` carried an approval-time shell seat like every other shell face (`installShellSeat("Monitor")`), and that seat judges the WRITTEN spelling. Nothing re-judged where the KERNEL would take those words: `env.spawnBackground` was reached directly. With a read boundary declared, `cat <symlink out of the roots>` and `cat <glob whose expansion is denied>` were refused on `Bash` and UNJUDGED on `Monitor` — and a Monitor command runs unattended for as long as the watch lasts, streaming what it reads into the conversation.
129
+ - **After — a MOUNT, not a second leg.** The closure `createBashTool` built inline is now `createShellCanonicalRecheck`, built ONCE at the hands phase (the one place the boundary declaration lives) and handed to the Monitor mount two phases later. Its contract, scope, over-fires and blind spots are the leg's own and unchanged. A refusal is the whole call result: a watch that must not read is a watch that must not START, so nothing registers, nothing streams, and there is no task to stop.
130
+ - **The card names the tool that refused.** The leg is shared; the refusal is not anonymous. `Monitor`'s canonical refusals say `Error (Monitor):` where the `bash` faces say `Error (Bash):` — a model told the wrong tool refused retries on that tool, the one repair the refusal must not suggest. Both `bash` faces' text is byte-unchanged.
131
+ - **`Monitor` takes the same option `createBashTool` takes** (`readBoundary`) and BUILDS the leg itself, so a direct library mount cannot be fail-open on one face and fail-closed on the other.
132
+ - **The leg gets the operands the two read faces hand nobody (SECURITY; @server @cli).** Measured: `cat <in-root symlink out of the roots>` is refused at execution on both shell faces — and `env -- cat <the same symlink>` RAN, after an approval, on `Bash` and on `Monitor`. When the walk cannot NAME the program (`env --`, `sudo -u`) or the operands arrive on a stream, both read faces hand this leg NOTHING, so there was nothing to canonicalize. The containment gate's reader scan DOES read those shapes — same operands, same judge — and now publishes them (`OutOfRootReadGateVerdict.checked` / `.undecided`, which it already computed and discarded). The leg's exclusion grew with it and is the load-bearing half: it skips what the boundary RAISED in BOTH voices (an operand outside the roots, and one the deny judge MATCHED lexically), because both were on the card the approver answered and a yes on either runs — the ruled disposition is untouched.
133
+ - **The leg's exemption is PER HALF, not per path (SECURITY).** A yes runs the read the card DESCRIBED and nothing else. Keyed on the path alone, either voice's release silenced BOTH halves and the direction inverted: an in-root name a deny row matches, whose TARGET is outside the roots, raised a DENY card and the yes then ran the out-of-root read — while the same command with the deny row REMOVED was refused, so adding a deny row turned a refusal off. The mirror: `cat <outside>/link` whose target is a deny row raised a CONTAINMENT card and the yes read the guarded file with the deny half never consulted. Each half now carries its own `answered` set, out-of-root operands ride the candidate list like every other (so the deny half sees them), and no candidate is dropped.
134
+ - **Absent boundary ⇒ no recheck and no I/O**, exactly as on the shell faces. The leg REFUSES an escape and never rescues a refusal, so an in-root watch starts exactly as before.
135
+
3
136
  ## 7.23.4 — 2026-09-19
4
137
 
5
138
  ### Read boundary — one deny judge for every read face: the `bash_readonly` shell wires the same seat (#937; P1; design/199 v1 exception retired; @server @cli)
@@ -49,7 +49,14 @@ export interface ListAgentsRow {
49
49
  name: string;
50
50
  /** Peer rows only: the listing-minted ref. */
51
51
  ref?: string;
52
+ /** What SendMessage accepts: on both AGENT lanes the registry `task_id` VERBATIM (no prefixing or
53
+ * re-minting), never the child's session id — a different domain, tabled in INTEGRATION-CORE §1.6a.
54
+ * Peer rows carry the `session.<id>` box handle instead. */
52
55
  address: string;
53
- state: "busy" | "idle" | "offline" | "running" | "finished" | "unknown";
56
+ /** What the row IS, right now. Both lanes are a table lookup over the word the row carries
57
+ * ({@link PEER_ROW_STATE} / {@link AGENT_ROW_STATE}), and `unknown` answers whatever the table cannot
58
+ * — on the peer lane the common case, since no host writes the activity word. `finished` means
59
+ * COMPLETED and nothing else: a model applies the continuable rule to that word. */
60
+ state: "busy" | "idle" | "offline" | "running" | "parked" | "finished" | "failed" | "killed" | "cancelled" | "unknown";
54
61
  }
55
62
  export declare function createListAgentsTool(opts: ListAgentsToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{}>, unknown>;
@@ -9,6 +9,29 @@ import { thrownText } from "../core/thrown-value.js";
9
9
  export const LIST_AGENTS_TOOL_NAME = "ListAgents";
10
10
  export const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
11
11
  export const LIST_AGENTS_MAX_RESULT_CHARS = 10_000;
12
+ const AGENT_ROW_STATE = {
13
+ pending: "running",
14
+ running: "running",
15
+ parked: "parked",
16
+ completed: "finished",
17
+ failed: "failed",
18
+ killed: "killed",
19
+ cancelled: "cancelled",
20
+ };
21
+ function agentRowState(status) {
22
+ return Object.hasOwn(AGENT_ROW_STATE, status) ? AGENT_ROW_STATE[status] : "unknown";
23
+ }
24
+ const PEER_ROW_STATE = {
25
+ active: "busy",
26
+ blocked: "busy",
27
+ idle: "idle",
28
+ };
29
+ function peerRowState(r) {
30
+ if (r.liveness === "dead")
31
+ return "offline";
32
+ const tempo = r.tempo;
33
+ return tempo !== undefined && Object.hasOwn(PEER_ROW_STATE, tempo) ? PEER_ROW_STATE[tempo] : "unknown";
34
+ }
12
35
  export function createListAgentsTool(opts) {
13
36
  return defineTool({
14
37
  name: LIST_AGENTS_TOOL_NAME,
@@ -43,7 +66,7 @@ export function createListAgentsTool(opts) {
43
66
  name: r.name,
44
67
  ref: c.ref,
45
68
  address: peerSessionBoxHandle(r.sessionId),
46
- state: r.liveness === "dead" ? "offline" : r.tempo === "active" || r.tempo === "blocked" ? "busy" : "idle",
69
+ state: peerRowState(r),
47
70
  });
48
71
  }
49
72
  let registryCapped = false;
@@ -58,7 +81,7 @@ export function createListAgentsTool(opts) {
58
81
  continue;
59
82
  seenIds.add(t.task_id);
60
83
  const full = opts.registry.getAccessibleTask(t.task_id, view);
61
- rows.push({ kind: "background_agent", name: full?.name ?? t.description ?? t.task_id, address: t.task_id, state: t.status === "running" || t.status === "pending" ? "running" : "finished" });
84
+ rows.push({ kind: "background_agent", name: full?.name ?? t.description ?? t.task_id, address: t.task_id, state: agentRowState(t.status) });
62
85
  }
63
86
  }
64
87
  }
@@ -70,7 +93,7 @@ export function createListAgentsTool(opts) {
70
93
  continue;
71
94
  seenIds.add(e.agentId);
72
95
  const live = opts.registry?.getAccessibleTask(e.agentId, view);
73
- rows.push({ kind: "roster", name: e.name, address: e.agentId, state: live === undefined ? "unknown" : live.status === "running" || live.status === "pending" ? "running" : "finished" });
96
+ rows.push({ kind: "roster", name: e.name, address: e.agentId, state: live === undefined ? "unknown" : agentRowState(live.status) });
74
97
  }
75
98
  }
76
99
  catch {
@@ -3,7 +3,7 @@ import { defineTool } from "../core/tools.js";
3
3
  import { createSafeNotifier } from "../core/safe-notify.js";
4
4
  import { escapeEnvelopeTag } from "../core/untrusted-text.js";
5
5
  import { toolFace } from "../core/tool-catalog-entries.js";
6
- import { thrownText } from "../core/thrown-value.js";
6
+ import { isErrorLike, thrownName, thrownText } from "../core/thrown-value.js";
7
7
  export const OBSERVER_EVENT_TAGS = ["tool-call", "user-message", "tool-result", "turn-ended"];
8
8
  const TAG_ESCAPE_RE = new RegExp(`<(?=/?(?:${OBSERVER_EVENT_TAGS.join("|")})(?:[>\\s/]|$))`, "gi");
9
9
  export function escapeObserverTags(text) {
@@ -224,9 +224,6 @@ export class ObserverResumeStateError extends Error {
224
224
  this.name = OBSERVER_RESUME_STATE_ERROR_NAME;
225
225
  }
226
226
  }
227
- function errorName(err) {
228
- return err instanceof Error ? err.name : undefined;
229
- }
230
227
  export const OBSERVER_FRESH_START_NOTE = "[Note: your previous observation context was lost; this is a fresh start mid-task.]";
231
228
  export class ObserverPairing {
232
229
  state = "armed";
@@ -300,7 +297,7 @@ export class ObserverPairing {
300
297
  await this.deliverBatch(digest);
301
298
  }
302
299
  catch (err) {
303
- if (errorName(err) === OBSERVER_STOPPED_BY_USER_ERROR_NAME) {
300
+ if (thrownName(err) === OBSERVER_STOPPED_BY_USER_ERROR_NAME) {
304
301
  this.state = "stopped";
305
302
  this.buffer = [];
306
303
  this.safeOnError(err);
@@ -330,7 +327,7 @@ export class ObserverPairing {
330
327
  await this.spawner.deliver({ digest });
331
328
  }
332
329
  catch (err) {
333
- if (errorName(err) !== OBSERVER_RESUME_STATE_ERROR_NAME)
330
+ if (thrownName(err) !== OBSERVER_RESUME_STATE_ERROR_NAME)
334
331
  throw err;
335
332
  const restart = this.spawner.restartFresh?.bind(this.spawner);
336
333
  if (restart === undefined)
@@ -341,8 +338,8 @@ export class ObserverPairing {
341
338
  }
342
339
  catch (restartErr) {
343
340
  this.firstRunDone = false;
344
- throw restartErr instanceof Error
345
- ? new Error(`${restartErr.message} (after resume-state loss: ${thrownText(err)})`, { cause: restartErr })
341
+ throw isErrorLike(restartErr)
342
+ ? new Error(`${thrownText(restartErr)} (after resume-state loss: ${thrownText(err)})`, { cause: restartErr })
346
343
  : restartErr;
347
344
  }
348
345
  }
@@ -67,7 +67,7 @@ import { LAUNCH_RECEIPT_OWN_WORDS_CLAUSE, launchReceiptNoQuoteClause } from "./l
67
67
  import { ObserverDigestTap, ObserverPairing, createObserverReportToolSpec, markObserverTaskId, unmarkObserverTaskId, isObserverTaskId, ObserverResumeStateError, ObserverStoppedByUserError, observerFramingPrompt, observerSlug, resolveObserverDeclaration, } from "./observer.js";
68
68
  import { SubagentStepRecorder } from "./subagent-steps.js";
69
69
  import { toolFace } from "../core/tool-catalog-entries.js";
70
- import { thrownText } from "../core/thrown-value.js";
70
+ import { readMember, thrownText } from "../core/thrown-value.js";
71
71
  const BG_AGENT_RESULT_MAX = 4_000;
72
72
  const BG_AGENT_RESULT_FULL_MAX = 200_000;
73
73
  function resultSettleFields(result) {
@@ -386,7 +386,7 @@ export function classifySubagentError(terminal) {
386
386
  return { errorKind: "logic", retryable: SUBAGENT_RETRYABLE.logic };
387
387
  }
388
388
  function thrownErrorCode(e) {
389
- const code = e !== null && typeof e === "object" ? e.code : undefined;
389
+ const code = readMember(e, "code");
390
390
  return typeof code === "string" ? code : undefined;
391
391
  }
392
392
  export function completedAgentCard(child, extras) {
@@ -1,5 +1,6 @@
1
1
  import { resolveToolSubset } from "./tool-filter.js";
2
2
  import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
3
+ import { classifySubagentError } from "./subagent.js";
3
4
  import { terminalProjection } from "../core/runner/terminal-projection.js";
4
5
  import { sanitizeUntrustedText } from "../core/untrusted-text.js";
5
6
  const DISCUSSION_WRAPPER_TAGS = ["statement", "discussion", "discussion-final", "earlier-discussion-summary", "previous-summary", "topic"];
@@ -101,6 +102,12 @@ export async function runTeamDiscussion(opts) {
101
102
  checkpointGate: p.terminal.gate,
102
103
  };
103
104
  };
105
+ const canDifferOnRetry = (terminal) => {
106
+ if (terminal.kind === "blocked")
107
+ return false;
108
+ const err = classifySubagentError(terminal);
109
+ return !(err?.errorKind === "governance" && err.retryable === false);
110
+ };
104
111
  const runTurn = async (objective, systemPrompt, modelRef, role, tools, mcp) => {
105
112
  let last = "";
106
113
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -134,6 +141,8 @@ export async function runTeamDiscussion(opts) {
134
141
  }
135
142
  const face = terminalProjection(res.terminal);
136
143
  last = `[unavailable: ${face.status}${face.errorCode ? `/${face.errorCode}` : ""}${face.errorMessage ? ` — ${face.errorMessage}` : ""}]`;
144
+ if (!canDifferOnRetry(res.terminal))
145
+ break;
137
146
  }
138
147
  return { text: last || "[unavailable]", ok: false };
139
148
  };
@@ -7,7 +7,7 @@ import { mintFallbackToolCallId } from "./tool-call-id.js";
7
7
  import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
8
8
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
9
9
  import { ANTHROPIC_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders, takeHeaderCasefold } from "./request-params.js";
10
- import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
+ import { adjudicateModelRoute, applyRouteCredentialHeaders, assertServableRequestRoot, createBrainRouteJudge, resolveRequestRoot, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
11
  import { MIN_THINKING_TOKENS, budgetCapSkipsThinking, declaredEffortLevels, reasoningBudgetShare, reasoningRequestCarried, resolveEffort } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
13
  function thinkingBudget(maxTokens, share, fixed, hardCap = false) {
@@ -224,6 +224,7 @@ function closeToolUseBlock(acc) {
224
224
  return acc.closedTc;
225
225
  }
226
226
  export function createAnthropicBrain(config = {}) {
227
+ assertServableRequestRoot(config.baseUrl, "config.baseUrl");
227
228
  const doFetch = config.fetchImpl ?? fetch;
228
229
  const cacheBreakpoints = config.cacheBreakpoints !== false;
229
230
  const stream = (model, context, options) => {
@@ -238,6 +239,7 @@ export function createAnthropicBrain(config = {}) {
238
239
  httpLabel: "anthropic",
239
240
  stallTimeouts: options?.stallTimeouts,
240
241
  buildRequest: (overrides) => {
242
+ const root = resolveRequestRoot(model.baseUrl, config.baseUrl, "https://api.anthropic.com");
241
243
  const routeCredential = resolveRouteCredential({
242
244
  optionsApiKey: options?.apiKey,
243
245
  optionsHeaders: options?.headers,
@@ -249,7 +251,6 @@ export function createAnthropicBrain(config = {}) {
249
251
  if (!routeVerdict.ok)
250
252
  throw new BrainError("invalid_request", routeRefusalText(routeVerdict));
251
253
  const apiKey = routeCredential.apiKey;
252
- const root = (model.baseUrl || config.baseUrl || "https://api.anthropic.com").replace(/\/+$/, "");
253
254
  const nonEmptyBlocks = context.systemBlocks?.filter((b) => b.text.length > 0);
254
255
  const system = nonEmptyBlocks?.length
255
256
  ? nonEmptyBlocks.map((b) => ({
@@ -1,5 +1,5 @@
1
1
  import { createAssistantMessageEventStream } from "../internal/llm.js";
2
- import { BrainError, IN_BAND_OUTPUT_CODES, ZERO_USAGE, extractErrorCode } from "./errors.js";
2
+ import { abortLikeName, brainErrorCodeOf, IN_BAND_OUTPUT_CODES, ZERO_USAGE, extractErrorCode } from "./errors.js";
3
3
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
4
4
  import { thrownText } from "../core/thrown-value.js";
5
5
  export const CIRCUIT_OPEN_MARKER = "circuit open for ";
@@ -142,8 +142,8 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
142
142
  }
143
143
  }
144
144
  catch (e) {
145
- const aborted = options?.signal?.aborted === true || (e instanceof Error && e.name === "AbortError");
146
- const code = e instanceof BrainError ? e.code : "network";
145
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) === "AbortError";
146
+ const code = brainErrorCodeOf(e) ?? "network";
147
147
  record(key, aborted ? "http" : code, !bypass);
148
148
  out.push({
149
149
  type: "error",
@@ -1,5 +1,5 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
- import { ZERO_USAGE, extractErrorCode } from "./errors.js";
2
+ import { abortLikeName, ZERO_USAGE, extractErrorCode } from "./errors.js";
3
3
  import { CIRCUIT_OPEN_MARKER } from "./circuit-breaker.js";
4
4
  import { thrownText } from "../core/thrown-value.js";
5
5
  export const DEGRADED_DIAGNOSTIC_TYPE = "degraded";
@@ -59,9 +59,12 @@ function errorMessageOf(model, e, aborted) {
59
59
  timestamp: Date.now(),
60
60
  };
61
61
  }
62
+ function errorFrameOf(message) {
63
+ return { type: "error", reason: message.stopReason === "aborted" ? "aborted" : "error", error: message };
64
+ }
62
65
  function terminalOf(message) {
63
- return message.stopReason === "error"
64
- ? { type: "error", reason: "error", error: message }
66
+ return message.stopReason === "error" || message.stopReason === "aborted"
67
+ ? errorFrameOf(message)
65
68
  : { type: "done", reason: message.stopReason, message };
66
69
  }
67
70
  async function forward(stream, sink) {
@@ -100,17 +103,12 @@ export function passthroughStream(brain, model, context, options) {
100
103
  }
101
104
  if (!sawTerminal) {
102
105
  await inner.result();
103
- out.push({
104
- type: "error",
105
- reason: "error",
106
- error: errorMessageOf(model, new Error("brain stream ended without a terminal event"), false),
107
- });
106
+ out.push(errorFrameOf(errorMessageOf(model, new Error("brain stream ended without a terminal event"), false)));
108
107
  }
109
108
  }
110
109
  catch (e) {
111
- const aborted = options?.signal?.aborted === true ||
112
- (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError"));
113
- out.push({ type: "error", reason: "error", error: errorMessageOf(model, e, aborted) });
110
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
111
+ out.push(errorFrameOf(errorMessageOf(model, e, aborted)));
114
112
  }
115
113
  })();
116
114
  return out;
@@ -175,12 +173,11 @@ export function createDegradingBrain(opts) {
175
173
  out.push(current.terminal ?? terminalOf(current.message));
176
174
  }
177
175
  catch (e) {
178
- const aborted = options?.signal?.aborted === true ||
179
- (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError"));
176
+ const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
180
177
  const errMsg = errorMessageOf(lastAttempted, e, aborted);
181
178
  if (degradeInfo)
182
179
  tagDegraded(errMsg, degradeInfo);
183
- out.push({ type: "error", reason: aborted ? "aborted" : "error", error: errMsg });
180
+ out.push(errorFrameOf(errMsg));
184
181
  }
185
182
  })();
186
183
  return out;
@@ -112,31 +112,48 @@ export declare function stripErrorCodePrefix(errorMessage: string): string;
112
112
  /**
113
113
  * Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES})
114
114
  * or gives no such verdict. Classification is by `code` along the error graph — never by message text,
115
- * which providers and runtimes reword freely.
115
+ * which providers and runtimes reword freely. (Distinct question from {@link classifyFetchThrow}, which
116
+ * does not classify a failure at all: it reads the runtime's own fixed shell to tell whether a request
117
+ * was ever built.)
116
118
  *
117
119
  * The graph, not just the chain: undici nests the actionable error under `cause`, and a dual-stack
118
120
  * connect nests one `AggregateError` whose `errors[]` hold the per-family results. A code outside the
119
121
  * set ANYWHERE in the graph returns `"indeterminate"`, and every AGGREGATE LEG must reach a
120
- * deterministic verdict of its own — so a hostname whose IPv4 leg is refused while its IPv6 leg timed
121
- * out, or whose second leg says nothing this classifier can read, keeps the full ladder. Position
122
- * decides how silence is read: a WRAPPER with no code (the `TypeError("fetch failed")` shell, an
123
- * `AbortSignal.timeout` DOMException whose `code` is a number) is structural and defers to what it
124
- * wraps, while a LEG with nothing readable is an outcome nobody accounted for and blocks the verdict.
125
- *
126
- * Fail-safe by construction — every uncertain outcome (empty aggregate, an unreadable leg, a graph
127
- * larger than the node budget, no deterministic code found at all) answers `"indeterminate"`, i.e. the
122
+ * deterministic verdict of its own. Position decides how silence is read: a WRAPPER with no code (the
123
+ * `fetch failed` shell, an `AbortSignal.timeout` DOMException whose `code` is a number) is structural
124
+ * and defers to what it wraps, while a LEG with nothing readable is an outcome nobody accounted for and
125
+ * blocks the verdict. Fail-safe by construction — every uncertain outcome (empty aggregate, unreadable
126
+ * leg, a graph past the node budget, no deterministic code at all) answers `"indeterminate"`, i.e. the
128
127
  * pre-existing behavior.
129
128
  */
130
129
  export type ConnectFailureClass = "deterministic" | "indeterminate";
131
130
  export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
132
- /** RB-386③ — render a transport-level error WITHOUT folding away its cause chain. undici's
133
- * `fetch` rejects with `TypeError("fetch failed", { cause })` where the CAUSE carries the actually
134
- * actionable fact (`connect ECONNREFUSED …` / `getaddrinfo ENOTFOUND …` / `SocketError: other side
135
- * closed` with a `code`); `String(e)` / `e.message` alone collapsed ALL THREE network-failure shapes
136
- * (refused / DNS / mid-stream disconnect) into the same opaque "fetch failed", end to end (BrainError
137
- * detail → TaskResult.errorMessage → bg-agent terminal notification / poll error). Walks the cause
138
- * chain (bounded), appending each layer's `code:`-prefixed message; layers whose message the parent
139
- * already contains are skipped (no duplicate noise). Falls back to the plain message for causeless
140
- * errors — those call sites' output is byte-identical to before. */
131
+ /** What a throw out of `fetch` was about: the NETWORK (retryable — the historic reading of every throw
132
+ * here), or the REQUEST ITSELF, which is deterministic: identical arguments reproduce it every time. */
133
+ export type FetchThrowClass = "transport" | "request_unbuildable";
134
+ /** Classify a throw out of `fetch` — the fold point where a request-shaped defect used to be laundered into
135
+ * a transient network failure and handed to the retry ladder (founding case: a base URL with userinfo
136
+ * throws a `TypeError` with no `code` and no `cause`, so the connect classifier answered indeterminate and
137
+ * the call spent its whole backoff budget on a request the runtime never built).
138
+ *
139
+ * ONE question — is there TRANSPORT EVIDENCE? — in its two forms: the runtime's own shell sentence, or a
140
+ * `cause` that is a transport fact rather than a parse verdict. A `TypeError` with neither was never built.
141
+ * Measured over ten real shapes; non-`TypeError` stays on the transport arm and an unreadable value answers
142
+ * `transport`, the pre-existing behavior. Stated cost: KL-933a. */
143
+ export declare function classifyFetchThrow(e: unknown): FetchThrowClass;
144
+ /** The cancellation NAME an untrusted rejection states, or `undefined`. Five sites hand-rolled
145
+ * `e instanceof Error && e.name === "AbortError"` on a value an injected brain threw, each able to
146
+ * raise inside an async IIFE with no `.catch`. One guarded, realm-independent read here; WHICH names
147
+ * count stays at each site, because that is policy and it differs (the breaker excludes `TimeoutError`). */
148
+ export declare function abortLikeName(err: unknown): "AbortError" | "TimeoutError" | undefined;
149
+ /** The brain code an untrusted rejection carries, or `undefined`. Reads the WORD against the code list
150
+ * rather than testing `instanceof BrainError`, which walks a chain a revoked proxy traps — and the one
151
+ * site that did so sits in an async IIFE with no `.catch`. */
152
+ export declare function brainErrorCodeOf(e: unknown): BrainErrorCode | undefined;
153
+ /** Render a transport-level error WITHOUT folding away its cause chain: undici rejects with
154
+ * `TypeError("fetch failed", { cause })` where the CAUSE carries the actionable fact, and the shell
155
+ * alone reads the same for refused / DNS / mid-stream disconnect. Walks the chain (bounded), appending
156
+ * each layer's `code:`-prefixed message and skipping layers the parent's message already contains;
157
+ * a causeless error renders as its plain message. */
141
158
  export declare function describeNetworkError(e: unknown): string;
142
159
  export {};