@sema-agent/core 7.23.0 → 7.23.2

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 (132) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/agents/agent-transcript-tool.js +2 -1
  3. package/dist/agents/cascade.js +2 -1
  4. package/dist/agents/list-agents-tool.js +2 -1
  5. package/dist/agents/observer.js +3 -2
  6. package/dist/agents/peer-session-drain.js +2 -1
  7. package/dist/agents/retain-ledger.js +2 -1
  8. package/dist/agents/send-message-tool.js +11 -10
  9. package/dist/agents/subagent.js +18 -17
  10. package/dist/agents/teacher.js +5 -4
  11. package/dist/brain/circuit-breaker.js +2 -1
  12. package/dist/brain/degrading.js +2 -1
  13. package/dist/brain/errors.js +24 -15
  14. package/dist/brain/failover.js +2 -1
  15. package/dist/brain/stream-engine.js +2 -1
  16. package/dist/core/a2a.js +4 -3
  17. package/dist/core/artifact-host.js +2 -1
  18. package/dist/core/ask-question.js +3 -2
  19. package/dist/core/auto-promote.js +3 -2
  20. package/dist/core/gate-bounded-await.d.ts +1 -1
  21. package/dist/core/gate-bounded-await.js +3 -2
  22. package/dist/core/gate-crash-trace.d.ts +7 -0
  23. package/dist/core/gate-crash-trace.js +3 -0
  24. package/dist/core/gate-fold.js +6 -2
  25. package/dist/core/gate-lanes.js +6 -3
  26. package/dist/core/git-worktree-env.js +6 -5
  27. package/dist/core/hooks.js +6 -5
  28. package/dist/core/lsp-session.js +3 -2
  29. package/dist/core/mcp.js +4 -3
  30. package/dist/core/memory-admission.js +2 -1
  31. package/dist/core/memory-engine/consolidation-driver.js +5 -4
  32. package/dist/core/memory-engine/consolidation.js +2 -1
  33. package/dist/core/memory-engine/delegation-settlement.js +4 -3
  34. package/dist/core/memory-engine/distiller.js +8 -4
  35. package/dist/core/memory-engine/engine.js +63 -55
  36. package/dist/core/memory-engine/file-backend.js +11 -10
  37. package/dist/core/memory-engine/layout.js +5 -4
  38. package/dist/core/memory-engine/session-incarnation.js +2 -1
  39. package/dist/core/memory.js +2 -1
  40. package/dist/core/oracle-isolation.js +3 -2
  41. package/dist/core/permission-rule-store.js +2 -1
  42. package/dist/core/runner/announce-once-ledger.js +2 -1
  43. package/dist/core/runner/compaction-seams.js +3 -2
  44. package/dist/core/runner/gate-exit.d.ts +5 -5
  45. package/dist/core/runner/gate-exit.js +0 -3
  46. package/dist/core/runner/git-leg-delivery.js +4 -3
  47. package/dist/core/runner/git-status-frame.js +2 -1
  48. package/dist/core/runner/mcp-redial.js +2 -1
  49. package/dist/core/runner/memory-consolidation-driver.js +2 -1
  50. package/dist/core/runner/permission-rule-lanes.js +3 -2
  51. package/dist/core/runner/prepare-artifact.js +2 -1
  52. package/dist/core/runner/prepare-ask-lane.js +2 -1
  53. package/dist/core/runner/prepare-caps-and-workflow.js +4 -3
  54. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  55. package/dist/core/runner/prepare-memory-engine-session.js +2 -1
  56. package/dist/core/runner/prepare-memory.js +5 -4
  57. package/dist/core/runner/prepare-policy-chain.js +3 -2
  58. package/dist/core/runner/prepare-project-context.js +3 -2
  59. package/dist/core/runner/prepare-protocol-tools.js +3 -2
  60. package/dist/core/runner/prepare-suspend-saga.js +2 -1
  61. package/dist/core/runner/prepare-task.js +6 -5
  62. package/dist/core/runner/prepare-turn-wiring.js +2 -1
  63. package/dist/core/runner/resume-apply.js +5 -4
  64. package/dist/core/runner/resume-preflight.js +3 -2
  65. package/dist/core/runner/run-git-lane.js +4 -3
  66. package/dist/core/runner/run-leg.js +4 -3
  67. package/dist/core/runner/run-recovery-lanes.js +3 -2
  68. package/dist/core/runner/run-settle-and-teardown.js +2 -1
  69. package/dist/core/runner/run-stop-and-final-verify.js +2 -1
  70. package/dist/core/runner/run-telemetry-and-budget-seats.js +2 -1
  71. package/dist/core/runner/run-terminal-adoption.js +6 -5
  72. package/dist/core/runner/run-turn-boundary.js +5 -4
  73. package/dist/core/runner/runtask.js +2 -1
  74. package/dist/core/runner/stream-settle-backstop.js +5 -4
  75. package/dist/core/runner/stream-steer-verb.js +2 -1
  76. package/dist/core/runner/strict-output-schema.js +2 -1
  77. package/dist/core/runner/teardown-bounded.js +2 -1
  78. package/dist/core/safe-notify.js +2 -1
  79. package/dist/core/skills-directory.js +2 -1
  80. package/dist/core/strategy-store.js +2 -1
  81. package/dist/core/task-registry-agent.js +2 -1
  82. package/dist/core/task-registry-monitor.js +2 -1
  83. package/dist/core/task-registry-workflow.js +2 -1
  84. package/dist/core/task-registry.js +3 -2
  85. package/dist/core/thrown-value.d.ts +79 -0
  86. package/dist/core/thrown-value.js +56 -0
  87. package/dist/core/tool-errors.d.ts +0 -6
  88. package/dist/core/tool-errors.js +36 -14
  89. package/dist/core/tool-model-gate.js +2 -1
  90. package/dist/core/tool-policy.js +6 -13
  91. package/dist/core/tool-result-store.js +2 -1
  92. package/dist/core/tool-spec.d.ts +12 -0
  93. package/dist/core/write-protect.js +4 -4
  94. package/dist/engine/compaction/compaction.js +3 -4
  95. package/dist/engine/harness/agent-harness.js +2 -1
  96. package/dist/engine/harness/types.js +8 -7
  97. package/dist/engine/loop/agent-loop.js +7 -6
  98. package/dist/engine/lsp/node-lsp-manager.js +5 -4
  99. package/dist/engine/lsp/stdio-lsp-transport.js +12 -5
  100. package/dist/orchestration/dev-vm-script-runner.js +2 -1
  101. package/dist/orchestration/run-workflow-tool.js +7 -6
  102. package/dist/orchestration/workflow-governance.js +2 -1
  103. package/dist/orchestration/workflow-sandbox-conformance.js +3 -2
  104. package/dist/orchestration/workflow-script-store.js +2 -1
  105. package/dist/orchestration/workflow.js +15 -14
  106. package/dist/server/http.js +3 -2
  107. package/dist/stores/file/adoption/adopt.js +3 -2
  108. package/dist/stores/file/fs-atomic.js +2 -1
  109. package/dist/stores/file/permission-rule-adopt.js +2 -1
  110. package/dist/stores/file/permission-rule-store.js +2 -1
  111. package/dist/stores/file/session-store.js +4 -3
  112. package/dist/stores/file/strategy-store.js +7 -6
  113. package/dist/tools/artifact/artifact-tool.js +3 -2
  114. package/dist/tools/artifact/conformance.js +4 -3
  115. package/dist/tools/fs/bash-lexer.d.ts +12 -5
  116. package/dist/tools/fs/bash-lexer.js +5 -2
  117. package/dist/tools/fs/bash-program-position.d.ts +18 -0
  118. package/dist/tools/fs/bash-program-position.js +13 -1
  119. package/dist/tools/fs/bash-readonly-classifier.d.ts +23 -7
  120. package/dist/tools/fs/bash-readonly-classifier.js +74 -41
  121. package/dist/tools/fs/fs-bash.d.ts +28 -0
  122. package/dist/tools/fs/fs-bash.js +31 -13
  123. package/dist/tools/fs/fs-write.js +3 -2
  124. package/dist/tools/fs/safety.js +2 -1
  125. package/dist/tools/fs/search.js +3 -2
  126. package/dist/tools/monitor.js +3 -2
  127. package/dist/tools/sql.js +2 -1
  128. package/dist/tools/web.js +5 -15
  129. package/dist/tools/worktree.js +12 -9
  130. package/package.json +2 -1
  131. package/wire-changes/core@7.23.1.json +205 -0
  132. package/wire-changes/core@7.23.2.json +42 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
+ ## 7.23.2 — 2026-09-19
2
+
3
+ ### Rendering a thrown value is one total rule — a hook could kill the engine with its choice of rejection value (#926; P1, failure face; @cli @server @client-core: text only, no wire key)
4
+ - **Before.** `err instanceof Error ? err.message : String(err)` and its normalizing twin `err instanceof Error ? err : new Error(String(err))` appeared 270 times across 100 files. Both are PARTIAL on the non-Error arm: `String` calls the value's own `toString` / `Symbol.toPrimitive`, so a null-prototype object throws `TypeError: Cannot convert object to primitive value` and a value whose own hook throws propagates that instead. The thrower is deployment code — a `Hooks` callback, a plugin, an adapter rejection — so the rendering step was the one step that could destroy the report it exists to produce.
5
+ - **On the hook seat it was fatal, twice, on two different failure modes.** A hook that REJECTS reached the seat's reject arm, where the render sat inside a promise rejection handler whose derived promise nobody awaited: the `TypeError` became an unhandled rejection and Node terminated the process. A hook that throws SYNCHRONOUSLY escaped the seat before it wrapped anything and reached the crash-text site instead, which carried the same coercion. Measured on the parent tree, each mode exits 1 from its own line. A `preToolUse` hook returning `Promise.reject(Object.create(null))` was enough to end the run — and the engine with it.
6
+ - **After.** One rule, in one leaf module. `thrownText` is total: a thrown STRING is its own message, an `Error` hands over `message` VERBATIM, and anything else is described structurally by the describer the engine already shares for rejected values. `asThrownError` is the same rule for callers that need an `Error` object, and passes an `Error` through by IDENTITY. Every read inside both is guarded, because the reads are themselves attacker-reachable (`instanceof` walks a prototype chain a revoked proxy can trap; `message` can be an accessor that throws). `describeThrown` stops carrying its own duplicate label and becomes what it always claimed to be — the containment face of that one rule. Rules: −1 describer, and 285 copies of one idiom become one call.
7
+ - **Byte identity, which is the promise that matters for a sweep this wide.** The two arms that carry nearly all production traffic are unchanged: an `Error` yields exactly `err.message` (no cap, no one-lining, no quoting, empty stays empty so `… || "unknown"` folds as before), a thrown string yields exactly itself, and every primitive keeps the spelling `String` gave it. Pinned as literals, in both failure modes.
8
+ - **Reach.** What moves is only the text for values that used to render uselessly or fatally: `[object Object]` becomes `an object with keys [a, b]`, `1,2` becomes `an array of 2 member(s)`, a thrown FUNCTION stops printing its entire source (unbounded, and forgeable through `toString`) and becomes `a function`, `1n` is no longer spelled `1`, and the three shapes that used to throw now describe. No gate decision, no control flow and no wire key changes anywhere; a hook crash still fails CLOSED and still reports through the same seats. Deployments that match on the text of a non-Error failure — a log scraper, a test that pinned `[object Object]` — are the only readers affected, and headless is unchanged: the same refusal, with a sentence a reader can act on.
9
+ - **Cross-realm Errors, and the face that names the class.** `instanceof Error` compares against THIS realm's prototype, so a genuine `Error` thrown by a workflow script running inside `vm.createContext` used to be described as an anonymous object with its message gone; the rule brands by `[object Error]` instead, which is realm-independent. And a bare `String(err)` never said the message — it said `TypeError: boom`, the CLASS and the message. Sites that spelled the bare form keep that text through a second face of the same rule, because at least one of them is load-bearing: the teacher's stuck detector keys its signature on it, and collapsing two error classes with equal messages into one string would have let an alternating student accumulate a streak and be abandoned mid-run.
10
+ - **Local normalizers that hand-rolled totality are now total.** Measured before the change: a revoked-proxy stdin error, and one whose `message` is a throwing getter, both ESCAPE the LSP transport's event handler — so the transport never closes. Those duck-typed `.message` reads sat outside any guard. Same shape in the worktree reason helper and the harness normalizer. All three read inside the guard now, with the shared rule as the fallback.
11
+ - **Two sites were reverted after the full suite, and say so in the gate rather than going quiet.** A rejected closed-set WORD is not a thrown value, and the structural describer quotes strings — a word already inside quotes came out doubled; and the notifier door's coercion already sat inside its own guard, where a constant stand-in is the stated contract rather than an accident. Both keep their own partial coercion and are ticketed. A third, the erasure announcement, deliberately RELAYS the value's own text scarred by the neutralizer — a structural description would delete the evidence the announcement exists to carry — so its coercion is kept and merely made total.
12
+ - **Known and unchanged.** The deployment error lane still receives the RAW value for a synchronous hook throw and a normalized `Error` for a rejection; the lane is typed `unknown` and that asymmetry predates this change, so it is pinned rather than quietly fixed. `asThrownError` deliberately does NOT attach the original value as `cause`: cause chains are walked elsewhere to attribute error codes, and letting an arbitrary thrown object start supplying codes belongs with those readers, not with a rendering fix. Two functions named `toError` (engine/lsp, engine/harness) still exist with different behaviour; both are total now, and the harness one still asks the value to narrate itself through `JSON.stringify` — the older self-narration class, untouched here.
13
+
1
14
  # Changelog
2
15
 
16
+ ### Read boundary — a read the boundary DECLINED to judge is no longer cleared by the engine's read-only tables (#930 / sema-internal #65; classify seat; @cli @server @test)
17
+ - **Before.** Under `shellGate:"classify"` + `readFace:"roots"`, a command whose operand list arrives through a STREAM (`echo <path> | xargs grep .`) is one the read walk cannot judge: it says so with a decline, and 7.23.1's classify seat reads no decline as mandate evidence (ruling C-R67 (B), #891) — deliberately, so that a person, the auto-mode classifier or a person's stored allow rule may answer the resulting card. The seat's stated premise for that trade was that the shape classifier "refuses all these shapes anyway", which was true of the PROBE and false of the OUTCOME: the gate's allow layer has a second member that is not the shape classifier — the read-only command tables (#619) — which recognises `xargs <a listed target>` from a table of program names, knows nothing about paths, and retired the card at ZERO asks. Fourteen shapes measured on a real shell, in BOTH the headless and the wired-approver deployment, with the file's contents coming back in the tool output: `echo <path> | xargs grep|head|tail|wc -l|grep -l`, `find … | xargs wc -l`, `printf '%s\0' … | xargs -0 wc -l`. **No deny table is needed** — roots alone are the boundary, which is what a `permissionMode` of "default" or "acceptEdits" mounts; the deny-listed spellings are the second half. The SANDBOX-ADMISSION arm cleared the same card on the same reasoning, measured at the unit face. `default`/`off`/`bypass` were never affected (their seat reads both declines as evidence, so the ask was mandated there), and `| xargs cat` was never affected (`cat` is not in the read-only tables' xargs target list).
18
+ - **After.** The boundary's answer over a command is a LADDER with three rungs, one question: DECLARED an objection (`mandated` — no lane, no rule) > DECLINED to judge (`ReversibilityVerdict.boundaryDeclined` → the gate's `GatePass.probeBoundaryDeclined`) > silent. The middle rung leaves the card ORDINARY for every ANSWERER — a person, the classifier and a stored allow rule all still clear it, which is exactly what C-R67 (B) bought — and withholds **both** allow-layer members that answer nothing: the read-only command tables (which recognise a command SHAPE) and sandbox admission (whose own comment already says containment holds EFFECTS and does not speak to what reaches the model). Rules: ±0 predicates (`readBoundaryMandate` is still the single seat-shared answer and gains a return rung; `refusedCommandMayRead` now reports the decline it already computed instead of returning a bare "exempt" that reads as "the boundary judged and objects to nothing"). No new mandate word: `PERSISTED_RULE_MANDATES` is unchanged, so the persisted-rule lane and the classifier read this ask exactly as they did.
19
+ - **Difference from 7.23.0.** 7.23.0 refused these shapes with a MANDATE (no classifier, no stored rule, confirm-or-refuse). This version is strictly looser than that and strictly tighter than 7.23.1: an ordinary card an answerer may settle.
20
+ - **Reach.** Any deployment that wires a read boundary (`readFace` + roots, with or without `readDenyPatterns`) under `shellGate:"classify"`: a shell command whose operands the walk could not read — a stream-fed operand list, an unreadable program position — now reaches the person/classifier instead of being auto-allowed by the read-only tables. In-root spellings of the same shapes (`echo sub/x.txt | xargs grep .`) are in that set too: the boundary declined for them as well, and "in root" is a fact nobody established. Commands with no decline are untouched — `git status`, `ls -la` and every other read-only shape the tables clear keep costing zero questions.
21
+ - **headless.** Restrictive direction, and the reason this is a P1: a headless classify-tier run that executed these stream-fed reads with no approver now refuses them (an ask with no approver is a refusal). A deployment relying on that auto-allow should widen its roots, store an allow rule for the command, or wire an approver/classifier.
22
+ - **NOT FIXED here, and visible rather than assumed gone (measured on this tree AND on 7.23.1's).** The other half of "the boundary could not read where this command reads" — an UNEXPANDED GLOB (`undecidedPaths`) — is a plain card by ruling (RB-474b) and is still cleared by the read-only tables. Its containment half is caught at execute time by the canonical recheck (#775); its DENY half is not, because that recheck judges roots only. `cat .en?` under `readDenyPatterns:["**/.env"]` discloses `.env` at zero asks on 7.23.1 and on this version alike. Tracked separately; this version neither widens nor narrows it.
23
+ - Pins: `test/backlog930-decline-not-read-only-clearable.test.ts` (14 shapes × 2 arms, + the C-R67 (B) surface, the sandbox arm with its own three-way control, the off seat's mandate, the read-only arm still alive, and the `xargs cat` / in-root controls).
24
+
25
+ ## 7.23.1 — 2026-09-18
26
+
27
+ ### Read boundary — a `$` inside SINGLE QUOTES no longer refuses the whole command (#921, ruling C-R67 ③; behaviour face, both seats; @cli @server @client-core re-pin)
28
+ - **Before.** The compound classifier's whole-string gate scanned for `$` character by character with no idea of quoting, so the most ordinary lines there are carried a card no rule could clear: `awk '{print $1}' f`, `awk -F: '{print $1}' f`, `awk '{print $NF}' f`, `grep 'x$' f`, `sed -n 's/$/x/p' f`. One character decided it — `awk '{print}' f`, the same invocation, was auto-allowed. Inside single quotes the character is a field reference or an end-of-line anchor; bash expands nothing there.
29
+ - **After.** ONE sieve, two passes in a load-bearing order. Everything whose ABSENCE makes quoting exact — escapes, subshells, line breaks, and redirection outside the tolerate mode — is still refused QUOTE-BLIND; only then, with a backslash guaranteed gone, is the lexer's existing quote mask built and `$` refused wherever bash would expand it (unquoted, or inside double quotes). Rules: −1 (the tolerate arm and the default arm were two refusal paths and are now one, with the sentence as the parameter). `quoteMask` reports WHICH quote holds each character; `quoted` is a view of that, minted in the same step.
30
+ - **A BACKTICK is not read through the mask, and the line between the two is measured.** Quoting stops the FIRST expansion, not a later one: `let` and `declare -i` evaluate their argument as an ARITHMETIC expression, and that evaluation performs command substitution on the text it is handed, single quotes included. Against real bash, `let 'a[0`<command>`]'` and `declare -i 'a=b[0$(<command>)]'` RUN the command; `let 'a=$x'` and `let 'a[0$x]'` hand the evaluator the literal characters `$x`, which is a syntax error and runs nothing, and a value arriving through `${x}` is expanded but never re-substituted. So the two spellings that can EXECUTE — `` ` `` and `$(` — stay refused wherever they stand (`(` being in the blind class is what keeps the second one out), which is the same fact the whitelist already states about an assignment word whose value spells a substitution literally. `$` alone cannot execute, so it is the only character this mask decides.
31
+ - **Exactness, since the two characters are the ones that could break the mask.** The mask and bash agree left to right until something stops a quote character from being one. Two things can: a backslash, refused before the mask is built, and the `$'…'` / `$"…"` forms, whose `$` stands OUTSIDE the region it opens — where the mask still reports it unquoted and the gate refuses the string. Either the string is refused or the mask is exact; there is no third case. An unbalanced mask is not used at all.
32
+ - **Reach.** Ten measured shapes stop being whole-refused, and each then answers exactly as the same command without the character. Two directions, both pinned: `awk '{print $1}' f` / `grep 'x$' f` / `cat '$F'` go to zero asks on the default/off and bypass tiers, while `awk '{print $1}' /etc/passwd` and `grep 'x$' <deny row>` STAY mandated — and now name the file they read instead of refusing the string. `awk "{print $1}" f` (double quotes, a real expansion), `cat $F`, `cat $'a\tb'`, `cat \`echo f\``, `echo '\`id\`'` (single-quoted backtick — see above) and every unbalanced-quote spelling are unchanged. `cat '$HOME/.ssh/<key>'` goes from one mandated ask to zero: bash hands `cat` the literal directory name `$HOME`, so the command cannot reach the key — and a deployment whose deny table covers that literal path still gets its refusal (`test/backlog714-home-expansion-precise.test.ts` re-pinned).
33
+ - **The rule lane too.** The segmenter is shared with the persisted permission-rule lane, where a whole-string refusal put the command outside the lane entirely and no stored rule of EITHER polarity could speak about it. These commands are now nameable: a deny rule reaches them, and so does an allow rule.
34
+ - **Not fixed, and now visible rather than assumed gone.** (i) An interpreter's inline program that opens with a slash-addressed pattern is still read as an absolute path: `awk '/error/' app.log` reports a phantom out-of-root read of `/error`, and `perl -pe '/x/' f` of `/x`. sed's positional script got a first-operand row in #892; the interpreter family has not. Pinned in `test/backlog791-interpreter-inline-program.test.ts` G-9. (ii) `trap` / `alias` / `history -s` / `git -c 'core.pager=…'` hand a LATER evaluation a command line and are not in the shell re-entry class, so the boundary walks their argument as an ordinary in-root word and vouches it. That hole is PRE-EXISTING — `trap 'cat /etc/passwd' EXIT` measured vouched on 66b7e7c5 too — and this change adds the `$` spelling (`trap 'cat $F' EXIT`) to the same already-open door; no capability is new, since anyone who can write the one can write the other. Both ticketed, pinned in `test/backlog921-single-quoted-expansion.test.ts` ④.
35
+ - **headless.** Both tiers, permissive direction: a headless run that refused these awk/grep/sed lines for want of an approver now executes them. Nothing is newly refused. Pins: `test/backlog921-single-quoted-expansion.test.ts`.
36
+
37
+ ### Read boundary — a verb's FIRST OPERAND can be a slot its grammar fills, not a file: `sed -n '/error/p' app.log` stops asking (#892; behaviour face, both seats; @cli @server @client-core re-pin)
38
+ - **Before.** Only grep's PATTERN slot was modelled, by a hand-written predicate and a hand-written branch in each of the two walks that needed it. sed's SCRIPT slot was modelled for the `-e` spelling and not for the positional one, so `sed -n '/error/p' app.log` — a script opening with an address regexp, spelled with slashes — was read as the absolute path `/error/p`, found outside the roots, and carried a MANDATED card on an everyday log-reading line. The `-e` spelling of the same script was already exempt: one invocation, two readings.
39
+ - **After.** `VerbOptionSlots.firstOperand` is a declaration row (grep's `pattern`, sed's `script`) read with the same `OptionValueKind` vocabulary the option slots already use, and ONE reader (`suppliesFirstOperandSlot`) answers "did an option supply this slot instead" for both walks. Rules: −1 (the verb-named branch and its private predicate are gone; a verb with no row keeps an ordinary first operand). The slot's value is judged exactly as a declared option value is — a kind that cannot name a file yields no candidate, one that can is a file as before.
40
+ - **Reach — a NARROWING, listed per spelling.** Six shapes lose a mandated card on the default/off and bypass tiers (and take an ordinary ask on the classify tier instead): `sed -n '/error/p' <log>`, `sed -n '/x/r <out-of-root>' f` (see Stated), and the four spellings in which a path-shaped token IS sed's script — `sed <path>`, `sed <deny row>`, `sed -l <path>`, `sed -l <deny row>`. The last four are not a file under EITHER implementation: the token is the script where `-l` takes no value and `-l`'s length argument where it does, so the verb-blind reading that recorded it as a read was an over-approximation the row now retires (`test/backlog764-verb-option-slots.test.ts` G-2b re-pinned; the one spelling whose token really is an input, `sed -n -e 1p -l <file>`, still mandates and still catches the arity mutation). The narrowing is exactly one operand wide: `sed -f prog.sed <path>`, `sed p <path>`, `sed -n p /etc/passwd` and `grep pat /etc/passwd` all still mandate, and no other verb grew a row. Pins: `test/backlog892-first-operand-slot.test.ts`.
41
+ - **Stated (ruled reach, C-R67 ②).** `sed -n '/x/r <out-of-root>' f` was mandated only because its script's first character was `/` and the operand walk read the whole script as an absolute path — an accident, not a promise: `sed -n 'p;r <out-of-root>' f` was never caught, and `sed -n '1r <out-of-root>' f` has been the ruled, disclosed reach of the friction floor since 7.22.0. Removing the misreading removes the accident with it, so that spelling now answers like its siblings. The script slot's named files do not reach the read boundary by ruling; the floor for what they open is the execution environment's sandbox.
42
+ - **headless.** Both tiers, permissive direction and narrow: a headless run that refused `sed -n '/error/p' <log>` for want of an approver now executes it. Nothing is newly refused.
43
+
44
+ ### Read boundary — a decline is read as evidence by the SEAT that declares it; the classify tier trades the mandate for an ordinary ask (#891, ruling C-R67 ①; behaviour face, classify tier only; @cli @server @client-core re-pin)
45
+ - **Before.** The shell scan declines two ways — it cannot name the program bash will run (`env -- ls`, `nice -x 5 cat f`, `xargs -0 wc -l`, `sudo -u root cat f`), or it read the program and the operands arrive on a STREAM it never saw (`find … | xargs wc -l`, `git ls-files | xargs grep -l TODO`, `ls | xargs cat`). Both refused the command outright on BOTH seats, so under `shellGate: "classify"` these everyday pipelines carried a card no allow rule and no auto classifier could retire. Neither decline names a word: they say only that the scan could not tell.
46
+ - **After.** Which decline a seat reads as read evidence is a TABLE the seat declares against (`SHELL_SCAN_DECLINES`, two members; `BOUNDARY_SEAT_DECLINE_EVIDENCE` keeps both, `CLASSIFY_SEAT_DECLINE_EVIDENCE` keeps neither). The question is asked ONCE for the whole command, ahead of every per-verb exit, so nothing downstream stands in front of the table — the blanket refusal of a listed reader whose operands the whitelist does not enumerate included. Rules: −4 (four per-segment refusals fold into that one pass), +1 (the pass). No third verdict state: both seats still raise an ask on the same shapes.
47
+ - **Reach — default and bypass tiers: none, measured byte for byte.** The read-boundary seat is what those tiers mount ALONE, where "not mandated" is VOUCHED, so it keeps both members: all 78 rows of the differential corpus answer identically, `echo /etc/passwd | xargs cat` included (the closed P0 stays closed). Under `shellGate: "classify"` 17 of those rows move from a mandated card to an ordinary one.
48
+ - **What the release does NOT buy, and it is one rule.** The boundary's objection over an operand the command NAMES still mandates, on BOTH halves of that judgement — the deny table and the roots. It is asked by the face that already answered the containment half (`classifyOutOfRootReadGate`), which now reports the deny finding too: ONE extraction of what the operands are, two findings over it, computed in a second pass with the deny judge present so the containment answer stays deny-free and keeps its `paths` contract. So `cat <deny row> && ls | xargs wc -l` answers exactly as its twin `cat /etc/passwd && ls | xargs wc -l`, and so do `ls | xargs wc -l; cat <deny row>`, `echo x | xargs cat <deny row>`, `ls | xargs grep -l TODO <deny row>`, the attached and separated payload spellings (`grep -f<deny row>`), a `NAME=value` operand (`dd if=<deny row>`) and a read standing before a directory change. Because the extraction is the face's own, no read is invented either: a sed SCRIPT is still not a path.
49
+ - **🔔 This reaches the DEFAULT/OFF and BYPASS tiers too, and it is the only place in this batch where they move.** That face reads a segment under a control word VERB-BLIND, so it was already mandating `! echo /etc/passwd` on every tier while `! echo <deny row>` was vouched — the same asymmetry, one layer up, and one its own header claims to cover. Giving it the deny half closes that as well: 252 rows of a 39,930-row control-word corpus move, every one V → M (a tightening; zero M → V), every one carrying a deny-listed operand, under `!` / `if` / `while` / `time` / `command` / `env` / `xargs` and the unreadable-launcher heads. A deployment with no deny table sees nothing (the face never reports the half its boundary cannot answer); the plain spelling is untouched, since a program declared to read no path still names no read.
50
+ - **Accepted reach, stated rather than mitigated.** On the classify tier, once that ordinary ask is retired by a rule or the auto classifier, a read HIDDEN inside such a command is not asked again: a stream is re-split by its consumer (`echo <deny row> | xargs cat` — the path is in `echo`'s words, and what `xargs` hands `cat` is a stream nobody read), whitespace is re-split with it (`echo " /etc/passwd" | xargs cat`), an option the walk could not read may move the working directory first (`env -q cd ..; strings ../secret`), text reaching a shell through stdin is a command line nobody parsed (`echo "cat /etc/passwd" | env -- sh`), and a stdin literal (`env -- ls < /etc/passwd`) or a substitution body (`env -- ls $(cat /etc/passwd)`) behind a decline is not walked. An operand the scan could not READ at all — an unexpanded glob — is not an objection either, which is why `find . -name '*.ts' | xargs wc -l` still drops. Each is pinned at that answer in `test/backlog891-seat-keyed-declines.test.ts`. What such a command may read is floored by the execution environment's sandbox.
51
+ - **headless.** Classify tier only, and in the permissive direction: a headless run (no approver) that refused these commands on a mandated card now refuses them on an ordinary one — and where a stored allow rule covers the command, it now RUNS instead of being refused. Default/off and bypass tiers are unchanged, so a deployment on those tiers sees no lane change.
52
+ - **@cli @server @client-core.** Re-pin any probe that expects `mandated` for these shapes under `shellGate: "classify"`; the default tier's expectations are unchanged. The PUBLIC export face is unchanged (`handoff:diff` 66b7e7c5..HEAD = +0/−0 over all three changes in this batch): the new names — `SHELL_SCAN_DECLINES`, `ShellScanDecline`, `programPositionRunsShell`, `shellScanDeclineOf`, `BOUNDARY_SEAT_DECLINE_EVIDENCE`, `CLASSIFY_SEAT_DECLINE_EVIDENCE` — are module exports inside `src/tools/fs`, not re-exported from `src/index.ts`, and `refusedCommandMayRead`'s new required fourth argument is likewise internal.
53
+
54
+ ### Gates — the debts gate reds text that spills past the seventh cell (`gate:debts`; no product code)
55
+ - A ledger row with one bare `|` inside a cell and no closing pipe still splits into nine segments, so the column-count check passed while the tail after the last pipe sat in no cell at all — unread by the gate's positional readers and dropped by the renderer (found by the cli debt survey: five rows whose open remainders lived in that tail; one server row measured the same way today). A row must open and close with a pipe; a non-empty first or last segment is red and names the row. Negative control in `test/gate-negative-controls.test.ts`.
56
+
57
+ ### Loading — every `src/tools/fs` module is a safe first entry; the gate's crash tracer leaves the record floor (#918; no behaviour change)
58
+ - **Before.** A host that imported `dist/tools/fs/bash-readonly-classifier.js` BEFORE `dist/tools/fs/fs-bash.js` (the cli's danger-matrix harness) got `ReferenceError: Cannot access 'NOT_AUTO_ALLOWED' before initialization` on 7.21.1, 7.22.0 and 7.23.0 alike. The runtime cycle: `tools/fs/safety` → `core/gate-bounded-await` → `runner/gate-exit` (one VALUE import, the crash tracer) → `tool-policy` → `tools/fs/index` → `fs-bash` → the classifier still mid-evaluation, whose re-export of the phrase constant fs-bash's top-level table read had not been reached. A bare import of fs-bash never showed it: ESM evaluation order is the entry's.
59
+ - **After.** `traceHookCrash` lives in `src/core/gate-crash-trace.ts` (two type-only edges, one call); `gate-bounded-await` has no runtime edge into `runner/`; `fs-bash` reads `NOT_AUTO_ALLOWED` from its declaring module (`bash-program-position`) and the classifier's dead re-export is retired. The built dist has no path from the classifier back to fs-bash; the cli's import order loads. Pins (`test/backlog918-module-entry-order.test.ts`): the cli order in a fresh process, every `src/tools/fs` module as a first entry (one fresh `node --import tsx` process each), and no runtime `runner/` import in the bounded-await helper.
60
+ - **Reach.** No wire, no export change (`traceHookCrash` was never public), no behaviour; a host importing engine modules directly in any order now loads them.
61
+
62
+ ### Write protection — the NTFS data-stream strip is the reference fold's `:.*$` pattern; a stream name carrying a line terminator is a STATED miss (#912, ruling C-R66 (b))
63
+ - **Before.** The win32 reading cut the stream suffix by index (`indexOf(":")`), one notch stricter than the reference: `name:stream\nrest` was stripped to `name`, where the reference's regexp `.` stops at the terminator and names nothing.
64
+ - **After.** The cut is the reference's `/:.*$/`. `\.bashrc:evil` still names `.bashrc`; `\.bashrc:evil\nrest` (and `\r`, U+2028, U+2029) no longer does — the reference misses that spelling the same way, and alignment was the ruling. Pin `test/backlog841-invisible-and-stream-fold.test.ts` ⑥ holds the aligned form.
65
+ - **Reach.** One fewer question on exactly that control-character spelling, win32 readings only; no wire key, no closed set, no export change. Headless: that spelling's overwrite goes from refused to executed under the write-protection table (the opt-in deny set and the data-root passthrough are untouched).
66
+
3
67
  ## 7.23.0 — 2026-09-18
4
68
 
5
69
  ### Gates — every script's CLI entry guard folded into ONE helper; the roster runner no longer reads silence as green (#915; `gate:all` and its 21 guarded children; no product code)
@@ -7,6 +7,7 @@ import { stepsFromMessages } from "./subagent-steps.js";
7
7
  import { REPORT_FIELD_MAX } from "./subagent.js";
8
8
  import { getSessionRetainLedger } from "./retain-ledger.js";
9
9
  import { toolFace } from "../core/tool-catalog-entries.js";
10
+ import { thrownText } from "../core/thrown-value.js";
10
11
  export const AGENT_TRANSCRIPT_TOOL_NAME = "AgentTranscript";
11
12
  const AGENT_TRANSCRIPT_DEFAULT_N = 20;
12
13
  const AGENT_TRANSCRIPT_MAX_N = 50;
@@ -110,7 +111,7 @@ export function createAgentTranscriptTool(opts) {
110
111
  catch (e) {
111
112
  const code = e?.code;
112
113
  return {
113
- content: `No transcript: agent ${id}'s session could not be read (${code ?? (e instanceof Error ? e.message : String(e))}).`,
114
+ content: `No transcript: agent ${id}'s session could not be read (${code ?? (thrownText(e))}).`,
114
115
  details: { error: code ?? "read_failed", id },
115
116
  isError: true,
116
117
  };
@@ -3,6 +3,7 @@ import { releaseSession } from "./session-util.js";
3
3
  import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
4
4
  import { buildCumulativeStats } from "./cumulative-stats.js";
5
5
  import { createSafeNotifier } from "../core/safe-notify.js";
6
+ import { thrownText } from "../core/thrown-value.js";
6
7
  const CASCADE_ON_RUNG_SITE = "cascade.onRung";
7
8
  const CASCADE_CONFIG_ERROR_CODE = "config.cascade_invalid";
8
9
  const CASCADE_NO_DISPATCH_ERROR_CODE = "cascade.budget_exhausted";
@@ -172,7 +173,7 @@ export async function runCascade(runner, spec, config, internals) {
172
173
  }
173
174
  catch (e) {
174
175
  passed = false;
175
- gateError = e instanceof Error ? e.message : String(e);
176
+ gateError = thrownText(e);
176
177
  }
177
178
  const statusRejected = passed && config.acceptPartial !== true && result.terminal.kind !== "completed";
178
179
  if (statusRejected)
@@ -5,6 +5,7 @@ import { formatPeerNameRef } from "./cross-session-ref.js";
5
5
  import { entryAccessible } from "./roster-store.js";
6
6
  import { mintPeerSessionCandidates, peerSessionBoxHandle } from "./peer-directory.js";
7
7
  import { toolFace } from "../core/tool-catalog-entries.js";
8
+ import { thrownText } from "../core/thrown-value.js";
8
9
  export const LIST_AGENTS_TOOL_NAME = "ListAgents";
9
10
  export const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
10
11
  export const LIST_AGENTS_MAX_RESULT_CHARS = 10_000;
@@ -27,7 +28,7 @@ export function createListAgentsTool(opts) {
27
28
  peerRows = await opts.peerDirectory.listPeerSessions({ scope: opts.self.scope });
28
29
  }
29
30
  catch (e) {
30
- directoryFault = e instanceof Error ? e.message : String(e);
31
+ directoryFault = thrownText(e);
31
32
  }
32
33
  for (const c of mintPeerSessionCandidates(peerRows, { sessionId: opts.self.sessionId })) {
33
34
  const r = c.record;
@@ -3,6 +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
7
  export const OBSERVER_EVENT_TAGS = ["tool-call", "user-message", "tool-result", "turn-ended"];
7
8
  const TAG_ESCAPE_RE = new RegExp(`<(?=/?(?:${OBSERVER_EVENT_TAGS.join("|")})(?:[>\\s/]|$))`, "gi");
8
9
  export function escapeObserverTags(text) {
@@ -309,7 +310,7 @@ export class ObserverPairing {
309
310
  if (this.buffer.length > 0) {
310
311
  const strandedCount = this.buffer.length;
311
312
  this.buffer = [];
312
- this.safeOnError(new Error(`observer delivery: ${strandedCount} segment(s) enqueued mid-fault were stranded and dropped (never attempted) after: ${err instanceof Error ? err.message : String(err)}`));
313
+ this.safeOnError(new Error(`observer delivery: ${strandedCount} segment(s) enqueued mid-fault were stranded and dropped (never attempted) after: ${thrownText(err)}`));
313
314
  }
314
315
  return;
315
316
  }
@@ -341,7 +342,7 @@ export class ObserverPairing {
341
342
  catch (restartErr) {
342
343
  this.firstRunDone = false;
343
344
  throw restartErr instanceof Error
344
- ? new Error(`${restartErr.message} (after resume-state loss: ${err instanceof Error ? err.message : String(err)})`, { cause: restartErr })
345
+ ? new Error(`${restartErr.message} (after resume-state loss: ${thrownText(err)})`, { cause: restartErr })
345
346
  : restartErr;
346
347
  }
347
348
  }
@@ -11,6 +11,7 @@ import { peerHeldQueueFor } from "./peer-held-queue.js";
11
11
  import { peerIdleMachineFor } from "./peer-idle.js";
12
12
  import { describePeerIdleSubscription, isPeerDeliveryReceiptState, isPeerIdleNoticeKind, peerIdleNoticeLabel, renderCrossSessionDeliveryNotice, renderCrossSessionDroppedNotice, renderCrossSessionIdleNotice, resolveCrossSessionDialogExpiry } from "./peer-notices.js";
13
13
  import { routePeerDeliveryReceipt, routePeerDroppedReceipt, routePeerIdleNotice } from "./peer-notice-route.js";
14
+ import { thrownText } from "../core/thrown-value.js";
14
15
  export const PEER_DRAIN_LEASE_TTL_MS = 5 * 60_000;
15
16
  export const PEER_SESSION_ID_UNGRAMMATICAL_CODE = "peer.session_id_ungrammatical";
16
17
  export function judgePeerLaneMount(deps, sessionId) {
@@ -304,7 +305,7 @@ export function createPeerSessionDrain(opts) {
304
305
  payload = buildPeerMessagePayload(m);
305
306
  }
306
307
  catch (e) {
307
- disclose("refused", `record fields cannot be rendered canonically (${e instanceof CrossSessionCodecError ? e.code : e instanceof Error ? e.message : String(e)})`, m);
308
+ disclose("refused", `record fields cannot be rendered canonically (${e instanceof CrossSessionCodecError ? e.code : thrownText(e)})`, m);
308
309
  settledUpTo = m.seq;
309
310
  continue;
310
311
  }
@@ -2,6 +2,7 @@ import { delimitUntrusted } from "../core/untrusted-text.js";
2
2
  import { PEER_MESSAGE_NOTICE } from "./peer-admission.js";
3
3
  import { createSafeNotifier } from "../core/safe-notify.js";
4
4
  import { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX } from "../config/defaults.js";
5
+ import { asThrownError } from "../core/thrown-value.js";
5
6
  export const SUBAGENT_RESUME_CAP = 8;
6
7
  export class SubagentRetainLedger {
7
8
  ttlMs;
@@ -30,7 +31,7 @@ export class SubagentRetainLedger {
30
31
  return this.notifier.failuresAt("retainLedger.onEvictionError");
31
32
  }
32
33
  noteDetachedFailure(trigger, error, parentToolCallId) {
33
- const err = error instanceof Error ? error : new Error(String(error));
34
+ const err = asThrownError(error);
34
35
  this.failedEvictions += 1;
35
36
  this.lastFailure = err;
36
37
  this.notifier.notify(() => this.hooks?.onEvictionError?.({ ...(parentToolCallId !== undefined ? { parentToolCallId } : {}), trigger, error: err }), "retainLedger.onEvictionError");
@@ -15,6 +15,7 @@ import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } fro
15
15
  import { createSubagentResume } from "./subagent.js";
16
16
  import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig, PEER_MESSAGE_NOTICE, } from "./peer-admission.js";
17
17
  import { toolFace } from "../core/tool-catalog-entries.js";
18
+ import { thrownText } from "../core/thrown-value.js";
18
19
  export const SEND_MESSAGE_TOOL_NAME = "SendMessage";
19
20
  let uplinkSeqGlobal = Date.now();
20
21
  const UPLINK_RESULT_MAX = 8000;
@@ -245,7 +246,7 @@ export function createSendMessageTool(opts) {
245
246
  return resolvePeerSessions(rows, to, peerSelfSessionId !== undefined ? { sessionId: peerSelfSessionId } : undefined);
246
247
  }
247
248
  catch (e) {
248
- return { status: "directory_fault", message: e instanceof Error ? e.message : String(e) };
249
+ return { status: "directory_fault", message: thrownText(e) };
249
250
  }
250
251
  };
251
252
  const deliverToPeerSession = async (c) => {
@@ -288,7 +289,7 @@ export function createSendMessageTool(opts) {
288
289
  if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
289
290
  return peerRefusal("peer_send.invalid_target", `${peerLabel} is being deleted — its box no longer accepts messages. Nothing was parked; do not retry.`);
290
291
  }
291
- return peerRefusal("peer_send.other", `the session box refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
292
+ return peerRefusal("peer_send.other", `the session box refused the message (${thrownText(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
292
293
  }
293
294
  }
294
295
  let subscription;
@@ -314,7 +315,7 @@ export function createSendMessageTool(opts) {
314
315
  }
315
316
  catch (e) {
316
317
  peerIdleMachineFor(peerScope, peerSelfSessionId).requester.settle(record.sessionId, "unavailable");
317
- subscription = { ok: false, code: e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE ? "peer_send.invalid_target" : "peer_send.other", severity: "error", message: `the session box refused the subscription record (${(e instanceof Error ? e.message : String(e)).slice(0, 200)}) — no subscription was made.` };
318
+ subscription = { ok: false, code: e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE ? "peer_send.invalid_target" : "peer_send.other", severity: "error", message: `the session box refused the subscription record (${(thrownText(e)).slice(0, 200)}) — no subscription was made.` };
318
319
  }
319
320
  }
320
321
  }
@@ -427,7 +428,7 @@ export function createSendMessageTool(opts) {
427
428
  }, { priority: "next" });
428
429
  }
429
430
  catch (e) {
430
- return { content: `Message not sent: the parent's notification lane rejected it (${e instanceof Error ? e.message : String(e)}). ${DEDUP_RETRY_NOTE}`, details: { error: "uplink_failed", to }, isError: true };
431
+ return { content: `Message not sent: the parent's notification lane rejected it (${thrownText(e)}). ${DEDUP_RETRY_NOTE}`, details: { error: "uplink_failed", to }, isError: true };
431
432
  }
432
433
  return {
433
434
  content: `Message sent to main — queued for the spawning conversation at its next turn boundary. If that conversation finishes before reading it, the message may not survive. Continue with your task; do not wait for a reply.`,
@@ -568,7 +569,7 @@ export function createSendMessageTool(opts) {
568
569
  announceTranscriptIntegrityGapOnce(handle, scope, opts.onTranscriptIntegrityGap);
569
570
  return queueFullReceipt(`No transcript found for agent ${handle}, so a revival cannot drain the backlog.`);
570
571
  }
571
- return queueFullReceipt(`Its transcript store did not answer (${e instanceof Error ? e.message : String(e)}) — this send did not start a backlog drain; a later resend will try again.`);
572
+ return queueFullReceipt(`Its transcript store did not answer (${thrownText(e)}) — this send did not start a backlog drain; a later resend will try again.`);
572
573
  }
573
574
  const drainLeaseOwner = `${opts.registry.writerId}:${ctx.toolCallId}`;
574
575
  let drainLease = null;
@@ -589,7 +590,7 @@ export function createSendMessageTool(opts) {
589
590
  drainSpawned = await opts.reviveSpawn({ row: claimed, rev: claimedRev, prompt: drainPrompt, peerSeed: { hopChain: [...drainSeed] } });
590
591
  }
591
592
  catch (e) {
592
- drainSpawned = { isError: true, content: e instanceof Error ? e.message : String(e) };
593
+ drainSpawned = { isError: true, content: thrownText(e) };
593
594
  }
594
595
  if (drainSpawned.isError === true) {
595
596
  try {
@@ -672,7 +673,7 @@ export function createSendMessageTool(opts) {
672
673
  };
673
674
  }
674
675
  return {
675
- content: `Message not sent: ${whoT3}'s transcript store did not answer (${e instanceof Error ? e.message : String(e)}) — nothing was parked; send again. ${DEDUP_RETRY_NOTE}`,
676
+ content: `Message not sent: ${whoT3}'s transcript store did not answer (${thrownText(e)}) — nothing was parked; send again. ${DEDUP_RETRY_NOTE}`,
676
677
  details: { error: "session_store_unavailable", to },
677
678
  isError: true,
678
679
  };
@@ -691,7 +692,7 @@ export function createSendMessageTool(opts) {
691
692
  };
692
693
  }
693
694
  return {
694
- content: `Message not sent: the durable mailbox refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked and the agent was not revived. ${DEDUP_RETRY_NOTE}`,
695
+ content: `Message not sent: the durable mailbox refused the message (${thrownText(e)}) — nothing was parked and the agent was not revived. ${DEDUP_RETRY_NOTE}`,
695
696
  details: { error: "mailbox_failed", to },
696
697
  isError: true,
697
698
  };
@@ -720,7 +721,7 @@ export function createSendMessageTool(opts) {
720
721
  spawned = await opts.reviveSpawn({ row: claimed, rev: claimedRev, prompt: revivePrompt, peerSeed: { hopChain: [...reviveSeed] } });
721
722
  }
722
723
  catch (e) {
723
- spawned = { isError: true, content: e instanceof Error ? e.message : String(e), details: { error: "revive_failed" } };
724
+ spawned = { isError: true, content: thrownText(e), details: { error: "revive_failed" } };
724
725
  }
725
726
  if (spawned.isError === true) {
726
727
  try {
@@ -1040,7 +1041,7 @@ export function createSendMessageTool(opts) {
1040
1041
  `${who}'s registry row is being adjudicated right now (a revival claim or a reap sweep holds it) — send again in a moment. ${DEDUP_RETRY_NOTE}`
1041
1042
  : code === "resume.row_gone"
1042
1043
  ? `${who}'s registry row no longer exists (terminal GC) — relaunch a new agent instead.`
1043
- : `${e instanceof Error ? e.message : String(e)} ${DEDUP_RETRY_NOTE}`;
1044
+ : `${thrownText(e)} ${DEDUP_RETRY_NOTE}`;
1044
1045
  return { content: `Message not sent: ${text}`, details: { error: code ?? "resume_failed", to }, isError: true };
1045
1046
  }
1046
1047
  });
@@ -67,6 +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
71
  const BG_AGENT_RESULT_MAX = 4_000;
71
72
  const BG_AGENT_RESULT_FULL_MAX = 200_000;
72
73
  function resultSettleFields(result) {
@@ -291,7 +292,7 @@ export function createSubagentWorktreeHelper(baseEnv, repoRoot) {
291
292
  async mint(childId) {
292
293
  const probe = await baseEnv.exec("git rev-parse --is-inside-work-tree", { cwd: repoRoot });
293
294
  if (!probe.ok || probe.value.exitCode !== 0) {
294
- const detail = probe.ok ? (probe.value.stderr || probe.value.stdout).trim().split("\n")[0] : String(probe.error);
295
+ const detail = probe.ok ? (probe.value.stderr || probe.value.stdout).trim().split("\n")[0] : thrownText(probe.error);
295
296
  throw new Error(`the working directory ${repoRoot} is not a git repository${detail ? ` (${detail})` : ""}`);
296
297
  }
297
298
  const { env, worktreeDir } = await addWorktree(baseEnv, {
@@ -817,7 +818,7 @@ export function createSubagentResume(deps) {
817
818
  }
818
819
  }
819
820
  catch (e) {
820
- const msgRevive = e instanceof Error ? e.message : String(e);
821
+ const msgRevive = thrownText(e);
821
822
  const errCodeReviveReject = abort.signal.aborted ? undefined : thrownErrorCode(e);
822
823
  const errClassReviveReject = abort.signal.aborted ? undefined : classifySubagentError({ kind: "failed", ...(errCodeReviveReject !== undefined ? { code: errCodeReviveReject } : {}) });
823
824
  if (reviveCycle !== undefined && deps.registry !== undefined && deps.taskId !== undefined) {
@@ -1692,7 +1693,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1692
1693
  catch (e) {
1693
1694
  return {
1694
1695
  isError: true,
1695
- content: `Sub-agent not started: worktree isolation failed — ${e instanceof Error ? e.message : String(e)}. Retry without the isolation parameter, or fix the repository state.`,
1696
+ content: `Sub-agent not started: worktree isolation failed — ${thrownText(e)}. Retry without the isolation parameter, or fix the repository state.`,
1696
1697
  details: { error: "isolation.failed" },
1697
1698
  };
1698
1699
  }
@@ -1711,7 +1712,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1711
1712
  }
1712
1713
  catch (e) {
1713
1714
  worktreeKeptPath = worktreeDir;
1714
- return `worktree: ${worktreeDir} (cleanup check failed: ${e instanceof Error ? e.message : String(e)})`;
1715
+ return `worktree: ${worktreeDir} (cleanup check failed: ${thrownText(e)})`;
1715
1716
  }
1716
1717
  };
1717
1718
  let injectedTools = [];
@@ -1845,7 +1846,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1845
1846
  let observerDegradedCount = 0;
1846
1847
  let observerDegradedFirst;
1847
1848
  const noteObserverFailure = (err) => {
1848
- const msg = (err instanceof Error ? err.message : String(err)).slice(0, REPORT_FIELD_MAX);
1849
+ const msg = (thrownText(err)).slice(0, REPORT_FIELD_MAX);
1849
1850
  if (observerPairing !== undefined && observerPairing.state === "armed") {
1850
1851
  observerDegradedCount++;
1851
1852
  if (observerDegradedFirst === undefined)
@@ -2362,7 +2363,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2362
2363
  }
2363
2364
  catch (e) {
2364
2365
  const wt = await finishWorktree();
2365
- return { isError: true, content: `Sub-agent not started: session fork failed (${e instanceof Error ? e.message : String(e)}).${wt ? `\n${wt}` : ""}`, details: { error: "fork failed" } };
2366
+ return { isError: true, content: `Sub-agent not started: session fork failed (${thrownText(e)}).${wt ? `\n${wt}` : ""}`, details: { error: "fork failed" } };
2366
2367
  }
2367
2368
  if (!forkedId) {
2368
2369
  const wt = await finishWorktree();
@@ -2414,7 +2415,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2414
2415
  const wt = await finishWorktree();
2415
2416
  return {
2416
2417
  isError: true,
2417
- content: `Sub-agent not started in background: the delegation settlement account could not record the launch (${(e instanceof Error ? e.message : String(e)).slice(0, 300)}) — fail-closed; repair the memory control plane or retry.${wt ? `\n${wt}` : ""}`,
2418
+ content: `Sub-agent not started in background: the delegation settlement account could not record the launch (${(thrownText(e)).slice(0, 300)}) — fail-closed; repair the memory control plane or retry.${wt ? `\n${wt}` : ""}`,
2418
2419
  details: { error: "settlement_write_ahead_failed" },
2419
2420
  };
2420
2421
  }
@@ -2443,7 +2444,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2443
2444
  }
2444
2445
  }
2445
2446
  if (!settled) {
2446
- const detail = lastErr instanceof Error ? lastErr.message : String(lastErr);
2447
+ const detail = thrownText(lastErr);
2447
2448
  try {
2448
2449
  enqueueMemoryAnnouncement(forkSettlementSeat.controlDir, {
2449
2450
  kind: "gate",
@@ -2490,7 +2491,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2490
2491
  catch {
2491
2492
  }
2492
2493
  }
2493
- return { isError: true, content: `Sub-agent not started in background: ${e instanceof Error ? e.message : String(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
2494
+ return { isError: true, content: `Sub-agent not started in background: ${thrownText(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
2494
2495
  }
2495
2496
  if (forkEntryGate.key !== undefined)
2496
2497
  chargeDelegationEntryHandle(bg.registry, forkEntryGate.key, bg.agentStore, taskId);
@@ -2741,7 +2742,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2741
2742
  dropHostAbortListener();
2742
2743
  void finishWorktree();
2743
2744
  const killed = abort.signal.aborted;
2744
- const msgFork = e instanceof Error ? e.message : String(e);
2745
+ const msgFork = thrownText(e);
2745
2746
  const errCodeForkReject = killed ? undefined : thrownErrorCode(e);
2746
2747
  const errClassForkReject = killed ? undefined : classifySubagentError({ kind: "failed", ...(errCodeForkReject !== undefined ? { code: errCodeForkReject } : {}) });
2747
2748
  const settledBg = bg.registry.settleBackgroundAgent(taskId, {
@@ -2850,7 +2851,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2850
2851
  catch {
2851
2852
  }
2852
2853
  const wt = await finishWorktree();
2853
- return { isError: true, content: `Fork failed: the forked run threw (${e instanceof Error ? e.message : String(e)}).${wt ? `\n${wt}` : ""}`, details: { error: "fork run failed" } };
2854
+ return { isError: true, content: `Fork failed: the forked run threw (${thrownText(e)}).${wt ? `\n${wt}` : ""}`, details: { error: "fork run failed" } };
2854
2855
  }
2855
2856
  finally {
2856
2857
  syncForkAdmission.release();
@@ -2956,7 +2957,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2956
2957
  const wt = await finishWorktree();
2957
2958
  return {
2958
2959
  isError: true,
2959
- content: `Sub-agent not started in background: the delegation settlement account could not record the launch (${(e instanceof Error ? e.message : String(e)).slice(0, 300)}) — fail-closed; repair the memory control plane or retry.${wt ? `\n${wt}` : ""}`,
2960
+ content: `Sub-agent not started in background: the delegation settlement account could not record the launch (${(thrownText(e)).slice(0, 300)}) — fail-closed; repair the memory control plane or retry.${wt ? `\n${wt}` : ""}`,
2960
2961
  details: { error: "settlement_write_ahead_failed" },
2961
2962
  };
2962
2963
  }
@@ -2985,7 +2986,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2985
2986
  }
2986
2987
  }
2987
2988
  if (!settled) {
2988
- const detail = lastErr instanceof Error ? lastErr.message : String(lastErr);
2989
+ const detail = thrownText(lastErr);
2989
2990
  try {
2990
2991
  enqueueMemoryAnnouncement(settlementSeat.controlDir, {
2991
2992
  kind: "gate",
@@ -3064,7 +3065,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3064
3065
  catch {
3065
3066
  }
3066
3067
  }
3067
- return { isError: true, content: `Sub-agent not started in background: ${e instanceof Error ? e.message : String(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
3068
+ return { isError: true, content: `Sub-agent not started in background: ${thrownText(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
3068
3069
  }
3069
3070
  if (capLedgerKey !== undefined)
3070
3071
  chargeDelegationEntryHandle(bg.registry, capLedgerKey, bg.agentStore, reviveRow === undefined ? taskId : undefined);
@@ -3281,7 +3282,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3281
3282
  }
3282
3283
  catch {
3283
3284
  }
3284
- const msg = e instanceof Error ? e.message : String(e);
3285
+ const msg = thrownText(e);
3285
3286
  throw new Error(disp === "rolled_back"
3286
3287
  ? `parked resume rolled back — the approval is still pending (retryable): ${msg}`
3287
3288
  : disp === "failed"
@@ -3609,7 +3610,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3609
3610
  else
3610
3611
  bgRetainLedger?.markSettled(ctx.toolCallId);
3611
3612
  }
3612
- const msg = e instanceof Error ? e.message : String(e);
3613
+ const msg = thrownText(e);
3613
3614
  const errCodeBgReject = killed ? undefined : thrownErrorCode(e);
3614
3615
  const errClassBgReject = killed ? undefined : classifySubagentError({ kind: "failed", ...(errCodeBgReject !== undefined ? { code: errCodeBgReject } : {}) });
3615
3616
  const settled = bg.registry.settleBackgroundAgent(taskId, {
@@ -3672,7 +3673,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3672
3673
  ]);
3673
3674
  if (startup === "settled") {
3674
3675
  if (parkedResume !== undefined) {
3675
- const err = await bgChildPromise.then(() => undefined, (e) => (e instanceof Error ? e.message : String(e)));
3676
+ const err = await bgChildPromise.then(() => undefined, (e) => (thrownText(e)));
3676
3677
  return {
3677
3678
  isError: true,
3678
3679
  content: `Parked resume did not complete: ${(err ?? "the resumed run settled before its delivery lane bound").slice(0, 600)}`,
@@ -6,6 +6,7 @@ import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
6
6
  import { isDefineToolProduct, rebindDefineToolCtx, stampDefineToolBrand } from "../core/tools.js";
7
7
  import { delimitUntrusted, inlineUntrusted } from "../core/untrusted-text.js";
8
8
  import { buildCumulativeStats } from "./cumulative-stats.js";
9
+ import { thrownLabel, thrownText } from "../core/thrown-value.js";
9
10
  export const TEACHER_PROMPT = `You are an expert advisor to a less-capable "student" agent that got stuck.
10
11
  You receive the task and the student's recent failed attempts. Your job is to help the student RECOVER,
11
12
  not to do the work for it.
@@ -97,7 +98,7 @@ export async function runWithTeacher(runner, studentSpec, teacher = {}) {
97
98
  catch (e) {
98
99
  if (isConfigCoded(e))
99
100
  throw e;
100
- discloseStoreIncident({ op: "find", error: String(e?.message ?? e) });
101
+ discloseStoreIncident({ op: "find", error: thrownText(e) });
101
102
  found = [];
102
103
  }
103
104
  let budget = 0;
@@ -143,7 +144,7 @@ export async function runWithTeacher(runner, studentSpec, teacher = {}) {
143
144
  catch (e) {
144
145
  if (isConfigCoded(e))
145
146
  throw e;
146
- discloseStoreIncident({ op: "save", error: String(e?.message ?? e) });
147
+ discloseStoreIncident({ op: "save", error: thrownText(e) });
147
148
  }
148
149
  }
149
150
  }
@@ -218,7 +219,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
218
219
  return out;
219
220
  }
220
221
  catch (e) {
221
- const errText = String(e);
222
+ const errText = thrownLabel(e);
222
223
  logTool(t.name, rawParams, `ERROR: ${errText}`);
223
224
  onToolFail(t.name, errText);
224
225
  throw e;
@@ -242,7 +243,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
242
243
  return out;
243
244
  }
244
245
  catch (e) {
245
- const errText = String(e);
246
+ const errText = thrownLabel(e);
246
247
  logTool(t.name, args, `ERROR: ${errText}`);
247
248
  onToolFail(t.name, errText);
248
249
  throw e;
@@ -1,6 +1,7 @@
1
1
  import { createAssistantMessageEventStream } from "../internal/llm.js";
2
2
  import { BrainError, IN_BAND_OUTPUT_CODES, ZERO_USAGE, extractErrorCode } from "./errors.js";
3
3
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
4
+ import { thrownText } from "../core/thrown-value.js";
4
5
  export const CIRCUIT_OPEN_MARKER = "circuit open for ";
5
6
  class InMemoryBreakerState {
6
7
  m = new Map();
@@ -147,7 +148,7 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
147
148
  out.push({
148
149
  type: "error",
149
150
  reason: aborted ? "aborted" : "error",
150
- error: errorAssistantMessage(model, code, e instanceof Error ? e.message : String(e), aborted ? "aborted" : "error"),
151
+ error: errorAssistantMessage(model, code, thrownText(e), aborted ? "aborted" : "error"),
151
152
  });
152
153
  }
153
154
  })();
@@ -1,6 +1,7 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { ZERO_USAGE, extractErrorCode } from "./errors.js";
3
3
  import { CIRCUIT_OPEN_MARKER } from "./circuit-breaker.js";
4
+ import { thrownText } from "../core/thrown-value.js";
4
5
  export const DEGRADED_DIAGNOSTIC_TYPE = "degraded";
5
6
  const VALID_REASONS = new Set([
6
7
  "breaker_open",
@@ -53,7 +54,7 @@ function errorMessageOf(model, e, aborted) {
53
54
  provider: model.provider,
54
55
  model: model.id,
55
56
  stopReason: aborted ? "aborted" : "error",
56
- errorMessage: e instanceof Error ? e.message : String(e),
57
+ errorMessage: thrownText(e),
57
58
  usage: ZERO_USAGE,
58
59
  timestamp: Date.now(),
59
60
  };