@sema-agent/core 7.29.0 → 7.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/dist/agents/child-model-seat.d.ts +59 -7
  3. package/dist/agents/child-model-seat.js +33 -7
  4. package/dist/agents/cross-session-judge.d.ts +27 -5
  5. package/dist/agents/cross-session-judge.js +23 -12
  6. package/dist/agents/list-agents-tool.d.ts +31 -6
  7. package/dist/agents/list-agents-tool.js +66 -14
  8. package/dist/agents/peer-admission.d.ts +20 -8
  9. package/dist/agents/peer-admission.js +21 -12
  10. package/dist/agents/peer-directory.d.ts +29 -0
  11. package/dist/agents/peer-directory.js +20 -0
  12. package/dist/agents/peer-held-queue.d.ts +31 -4
  13. package/dist/agents/peer-held-queue.js +32 -1
  14. package/dist/agents/peer-session-drain.d.ts +91 -13
  15. package/dist/agents/peer-session-drain.js +409 -102
  16. package/dist/agents/read-inbox-tool.d.ts +22 -0
  17. package/dist/agents/read-inbox-tool.js +39 -0
  18. package/dist/agents/roster-store.d.ts +2 -4
  19. package/dist/agents/roster-store.js +1 -1
  20. package/dist/agents/send-message-tool.d.ts +4 -4
  21. package/dist/agents/send-message-tool.js +22 -2
  22. package/dist/agents/subagent.js +62 -44
  23. package/dist/agents/team-role.d.ts +34 -0
  24. package/dist/agents/team-role.js +17 -0
  25. package/dist/agents/verify.js +2 -2
  26. package/dist/core/auto-compaction.d.ts +42 -0
  27. package/dist/core/auto-compaction.js +35 -2
  28. package/dist/core/child-clamp-seats.d.ts +11 -0
  29. package/dist/core/child-clamp-seats.js +3 -0
  30. package/dist/core/context-edit.d.ts +3 -2
  31. package/dist/core/context-edit.js +6 -5
  32. package/dist/core/context-guard.d.ts +2 -1
  33. package/dist/core/context-guard.js +3 -2
  34. package/dist/core/context-settings.d.ts +103 -0
  35. package/dist/core/context-settings.js +60 -0
  36. package/dist/core/engine-notice.d.ts +11 -1
  37. package/dist/core/frame-settlement.d.ts +97 -0
  38. package/dist/core/frame-settlement.js +36 -0
  39. package/dist/core/governance-codes.d.ts +1 -1
  40. package/dist/core/governance-codes.js +2 -0
  41. package/dist/core/hooks.d.ts +10 -10
  42. package/dist/core/mailbox-store.d.ts +41 -7
  43. package/dist/core/mailbox-store.js +9 -3
  44. package/dist/core/parent-spec-seat.d.ts +14 -1
  45. package/dist/core/parent-spec-seat.js +6 -0
  46. package/dist/core/permission-rule-model.js +1 -1
  47. package/dist/core/roles.d.ts +20 -0
  48. package/dist/core/roles.js +35 -16
  49. package/dist/core/runner/assemble-result.d.ts +2 -0
  50. package/dist/core/runner/assemble-result.js +1 -1
  51. package/dist/core/runner/compaction-call-options.d.ts +26 -2
  52. package/dist/core/runner/compaction-call-options.js +9 -0
  53. package/dist/core/runner/compaction-seams.d.ts +4 -3
  54. package/dist/core/runner/compaction-seams.js +13 -6
  55. package/dist/core/runner/contracts.d.ts +56 -7
  56. package/dist/core/runner/decide-continuation.d.ts +2 -1
  57. package/dist/core/runner/decide-continuation.js +11 -10
  58. package/dist/core/runner/initial-run-state.js +1 -1
  59. package/dist/core/runner/prepare-caps-and-workflow.js +9 -1
  60. package/dist/core/runner/prepare-config-doors.js +4 -1
  61. package/dist/core/runner/prepare-context-lane.d.ts +9 -2
  62. package/dist/core/runner/prepare-context-lane.js +15 -10
  63. package/dist/core/runner/prepare-delegation-surface.d.ts +3 -2
  64. package/dist/core/runner/prepare-delegation-surface.js +9 -3
  65. package/dist/core/runner/prepare-file-history.js +10 -8
  66. package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
  67. package/dist/core/runner/prepare-gate-stations.js +3 -1
  68. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  69. package/dist/core/runner/prepare-prompt-inputs.d.ts +6 -0
  70. package/dist/core/runner/prepare-prompt-inputs.js +2 -1
  71. package/dist/core/runner/prepare-task.js +22 -9
  72. package/dist/core/runner/prepare-wiring-manifest.d.ts +6 -1
  73. package/dist/core/runner/prepare-wiring-manifest.js +6 -1
  74. package/dist/core/runner/retired-runner-deps.d.ts +3 -1
  75. package/dist/core/runner/retired-runner-deps.js +1 -0
  76. package/dist/core/runner/run-compaction-machinery.d.ts +4 -2
  77. package/dist/core/runner/run-compaction-machinery.js +5 -3
  78. package/dist/core/runner/run-harness-handlers.js +1 -1
  79. package/dist/core/runner/run-identity-wiring.js +19 -1
  80. package/dist/core/runner/run-leg.js +1 -1
  81. package/dist/core/runner/run-notification-lane.js +8 -2
  82. package/dist/core/runner/run-recovery-lanes.js +9 -5
  83. package/dist/core/runner/run-settle-and-teardown.d.ts +4 -22
  84. package/dist/core/runner/run-settle-and-teardown.js +6 -10
  85. package/dist/core/runner/run-stop-and-final-verify.js +1 -0
  86. package/dist/core/runner/run-terminal-adoption.js +5 -5
  87. package/dist/core/runner/run-turn-boundary.js +32 -5
  88. package/dist/core/runner/runtask.d.ts +8 -1
  89. package/dist/core/runner/runtask.js +32 -1
  90. package/dist/core/runner-deps.d.ts +44 -13
  91. package/dist/core/secret-env.js +2 -1
  92. package/dist/core/swappable-deps.d.ts +20 -4
  93. package/dist/core/swappable-deps.js +6 -1
  94. package/dist/core/task-event.d.ts +5 -0
  95. package/dist/core/task-notification.d.ts +144 -2
  96. package/dist/core/task-notification.js +154 -5
  97. package/dist/core/task-result.d.ts +14 -0
  98. package/dist/core/task-spec.d.ts +7 -0
  99. package/dist/core/tool-catalog-entries.js +2 -1
  100. package/dist/core/tool-conformance.js +1 -0
  101. package/dist/core/tool-policy.d.ts +9 -4
  102. package/dist/core/tool-spec.d.ts +14 -0
  103. package/dist/core/wiring-manifest.d.ts +12 -0
  104. package/dist/core/wiring-manifest.js +5 -1
  105. package/dist/engine/harness/agent-harness.d.ts +14 -1
  106. package/dist/engine/harness/agent-harness.js +28 -3
  107. package/dist/engine/harness/types.d.ts +3 -1
  108. package/dist/engine/llm/types.d.ts +8 -7
  109. package/dist/engine/llm/types.js +1 -1
  110. package/dist/engine/lsp/node-lsp-manager.d.ts +3 -0
  111. package/dist/engine/lsp/node-lsp-manager.js +5 -3
  112. package/dist/engine/session/import-validate.js +4 -0
  113. package/dist/index.d.ts +10 -7
  114. package/dist/index.js +7 -5
  115. package/dist/orchestration/run-workflow-tool.d.ts +4 -4
  116. package/dist/orchestration/workflow.d.ts +1 -1
  117. package/dist/orchestration/workflow.js +8 -5
  118. package/dist/shell/lex.d.ts +38 -54
  119. package/dist/shell/lex.js +239 -286
  120. package/dist/stores/cc/mailbox-store.js +3 -2
  121. package/dist/stores/file/corrupt-read-sink.d.ts +28 -2
  122. package/dist/stores/file/file-history-store.d.ts +2 -4
  123. package/dist/stores/file/file-history-store.js +1 -1
  124. package/dist/stores/file/fs-atomic.d.ts +16 -11
  125. package/dist/stores/file/fs-atomic.js +17 -6
  126. package/dist/stores/file/index.d.ts +5 -6
  127. package/dist/stores/file/index.js +1 -1
  128. package/dist/stores/file/mailbox-store.d.ts +4 -9
  129. package/dist/stores/file/mailbox-store.js +29 -30
  130. package/dist/stores/file/session-policy-store.d.ts +2 -0
  131. package/dist/stores/file/session-policy-store.js +1 -1
  132. package/dist/stores/file/session-store.d.ts +11 -7
  133. package/dist/stores/file/session-store.js +23 -16
  134. package/dist/stores/file/shared-ledger.js +3 -14
  135. package/dist/stores/file/tool-result-store.d.ts +2 -0
  136. package/dist/stores/file/tool-result-store.js +5 -2
  137. package/dist/stores/file/workflow-journal-store.js +14 -9
  138. package/dist/tools/fs/bash-program-position.d.ts +15 -24
  139. package/dist/tools/fs/bash-program-position.js +11 -29
  140. package/dist/tools/fs/bash-readonly-classifier.d.ts +131 -117
  141. package/dist/tools/fs/bash-readonly-classifier.js +404 -272
  142. package/dist/tools/fs/shell-read-check.js +3 -3
  143. package/dist/tools/fs/shell-read-establish.d.ts +1 -1
  144. package/dist/tools/fs/shell-read-establish.js +5 -33
  145. package/dist/tools/fs/shell-read-identity.d.ts +13 -0
  146. package/dist/tools/fs/shell-read-identity.js +39 -0
  147. package/dist/tools/fs/shell-read-judge.d.ts +8 -0
  148. package/dist/tools/fs/shell-read-judge.js +7 -0
  149. package/dist/tools/fs/shell-read-plan.d.ts +15 -6
  150. package/dist/tools/fs/shell-read-plan.js +171 -195
  151. package/dist/tools/fs/shell-read-word.d.ts +64 -0
  152. package/dist/tools/fs/shell-read-word.js +94 -0
  153. package/dist/tools/fs/shell-reentry-text.d.ts +10 -0
  154. package/dist/tools/fs/shell-reentry-text.js +73 -0
  155. package/package.json +1 -1
  156. package/test/export-surface.snapshot.json +58 -8
  157. package/wire-changes/core@7.30.0.json +1821 -0
  158. package/wire-changes/core@7.30.1.json +154 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,398 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.30.1 — 2026-09-24
4
+
5
+ ### An unquoted extended-glob opener makes a shell text unestablishable (#1064 ⑨; patch; read boundary NARROWING; +0 exports; +0 wire keys; @cli @test)
6
+
7
+ - **What was wrong.** After `shopt -s extglob`, bash reads `pack@(age.json)` as one pattern and `<<@(x)` as the here-document delimiter `@(x)`; the lexer reads a split word (`pack@`, `age.json`) and the delimiter `@`. The read station judged the split words, found nothing, and vouched: `shopt -s extglob` on one line and `head -c 64 < pack@(age.json)` on the next read a deny-row file at zero cards on the real lane (v7.29.0 and v7.30.0 alike). The only extglob reading lived inside the HOME-rebinding check.
8
+ - **One rule.** An unquoted `?(` `*(` `+(` `@(` `!(` in an argv word, a redirection target or a here-document delimiter joins the plan's `unreadable` floor (`unreadableSentenceOf`, beside the unresolved-word sentence): the whole text is unestablishable — mandated on `bash_off` and `bash_classify`, refused on the read-only face, denied headless — and a deny hit anywhere in it still refuses. A quoted opener, a comment and a here-document body are the text they always were; an `$((…))` arithmetic body is never globbed and keeps its reading.
9
+ - **Review round (before release).** Two bypasses of the first cut are closed: an executable substitution whose text equals an arithmetic body (the exemption is granted only to a body that is one parenthesised group spanning the whole body — `$((cat x); :)` is a command substitution — and only where every substitution spelled so is such a body), and a here-document delimiter quoted in part (`<<'E'@(X)` — a delimiter quoted anywhere counts).
10
+ - **Pre-release batch 2.** The arithmetic test reads the lexer's structure (a quoted, escaped or commented parenthesis is text: `$((echo '('; …); (echo ')'))` is a command substitution and keeps the floor), and a here-document body keeps each substitution's kind (`cat <<EOF\n$((2*(3+4)))\nEOF` stays vouched on every seat, as on 7.30.0).
11
+ - **Who is hurt.** A text with an unquoted opener costs a card even when extglob is off (`ls !(x)` — a syntax error there — and the arithmetic command `(( 2*(3+4) ))`, whose `2*(` reads as an opener), and so do pattern positions bash never globs: `[[ $v =~ ^[0-9]+(\.[0-9]+)?$ ]]`, `[[ $f == *.@(js|ts) ]]`, `case $x in @(a|b))` (both full-shell seats ask, the read-only face refuses; ≈0 in the corpus). KL-994k.
12
+
13
+ ### The declared-home deny floor is established as a read — pattern, tree, canonical target (#1064 ⑬; patch; read boundary NARROWING; `ReadCandidate.denyOnly?` added (optional); +0 exports; +0 wire keys; @cli @test)
14
+
15
+ - **What was wrong.** 7.30.0 gave an unplaced home word (a text that may rebind `HOME`, a home word inside a value) its declared-home spelling as one more LITERAL spelling for the deny judge. A pattern (`~/k*.txt`), a traversal (`grep -r KEY ~`) or a symlink under the home never matched it, so those asked — and, approved, read the deny row — where v7.29.0 refused them.
16
+ - **One rule.** Each such word's `floorSpelled` becomes a SHADOW candidate (`denyFloorShadowsOf`, `shell-read-judge.ts`) established exactly as a read is — canonical target, a pattern's matches, a traversal root's tree — and judged for deny rows only (`ReadCandidate.denyOnly`): it never adds a containment finding and never vouches, so a read that is not a deny row still asks through its own unplaced candidate. The literal spelling the unresolvable arm used to carry is gone (one mechanism, not two).
17
+ - **Also on the floor.** A bare `cd` / `cd --` puts the declared home on the deny floor (`cd && cat <deny row under home>` refuses); a `[k]=` compound element is read as the value bash assigns, so its `~` expands and its `$HOME` is a home word (`A=([0]="$HOME/<deny>")`, `A=([0]=~/<deny>)` refuse).
18
+ - **Review round (before release).** A shadow is never an identity in the pre-spawn re-check (a real read retargeted onto a shadow's target is still refused), and a `[k]=` element is re-read as an assignment VALUE (a leading `#` stays literal — `A=([0]=#dir/".env")` refuses as on 7.30.0; a quoted `"[k]=…"` element keeps its tail's spelling too, as on 7.30.0).
19
+ - **Pre-release batch 2.** A `[k]=` element keeps its word's own reading (its folded tail, globbed as the word is) as a deny-only candidate beside the assignment value: an unquoted `${A[k]}` expands a stored `pack*`, and `A=([0]="pack*"); head ${A[0]}` refuses on disk as on 7.30.0.
20
+ - **Admission.** A deny-only read (a shadow, an element word's own reading) is its own identity class in the pre-spawn re-check: a real read retargeted onto a shadow's target is still caught, and a deny-only read retargeted onto a deny row between approval and spawn is re-judged and refused (`read_path_denied`), as a real read is.
21
+ - **Pre-release batch 4.** Candidate de-duplication keys on `denyOnly` too: a deny-only read never absorbs an ordinary read of the same spelling (a real match retargeted after judgment is refused at admission in either assignment order, as on 7.30.0); and the arithmetic test follows its group across the segments the lexer cuts at `&&` `||` `&` `|` and line breaks (`$((2*(3+4) && 1))`, a multi-line body stay vouched).
22
+ - **Pre-release batch 5.** The pre-spawn identity comparison is PER CANDIDATE (`shell-read-identity.ts`): each candidate's established targets are compared with its own re-establishment, keyed by where it stands in the plan and what it is. A pooled set let another word's pattern (`A=([0]="pair*") B=pair*; head linkx`) or a home shadow already reaching the new target hide a retargeted read, which was admitted; it is refused now (the `pair*` form was admitted on 7.30.0 too).
23
+ - **Consumer face.** `judgeShellRead().establishment` now lists the shadow reads too (`candidate.denyOnly: true`); a consumer that counts established reads sees them.
24
+ - **Not covered** (KL-994l): an option payload's home word (`--file="$HOME/…"`), `pushd ~`, parameter modifiers (`${HOME:-…}`), and a `~` assignment value no program opens (`A=~/<deny>; head "$A"` still asks — the #1004 ruling keeps `A=~/.env; true` vouched).
25
+
26
+ ### A language server's child process gets the tool child's environment scrub (#1073; patch; env face NARROWING; `NodeLspManagerOptions.env?` added (optional); +0 exports; @server)
27
+
28
+ - `defaultLspSpawn` passed no `env` to `child_process.spawn`, so a language server inherited the whole host `process.env` — credentials included — while a tool's child process got `scrubSecretEnv`. The default spawn now passes the same scrub (the one function, applied at each spawn). A host that wants another environment passes `NodeLspManagerOptions.env`, used verbatim; a host-supplied `spawn` owns its child's environment as before. The LSP scrub has no findings collector: what it removed is not reported (the tool child's `onScrub` channel has no LSP twin). **Who is hurt:** a language server that read a credential-shaped variable from the host environment no longer sees it; pass it through `env`.
29
+
30
+ ### Plural credential names are scrubbed from a spawned environment (#1072; patch; env face NARROWING; +0 exports; @server @web-admin)
31
+
32
+ - `SECRET_ENV_RE` reads `KEYS`, `TOKENS` and `PASSWORDS` as it reads their singulars (`SECRETS` / `CREDENTIALS` already were): `SERVICE_AUTH_TOKENS`, `API_KEYS`, `DB_PASSWORDS` no longer reach a child shell. The exact-name exemptions (`PARTITION_KEY`, …) still win.
33
+ - **A token COUNT is not a credential.** `TOKENS` is also a count word: right after `MAX` / `OUTPUT` / `INPUT` / `THINKING` / `NUM` / `TOTAL` (`TOKEN_COUNT_WORDS`, the reverse ledger in the same place) it is the count and is kept — `MODEL_MAX_TOKENS`, `MAX_THINKING_TOKENS`, `CLAUDE_CODE_MAX_OUTPUT_TOKENS`, `MAX_TOKENS` still reach an agent CLI run in the child shell. The singular `TOKEN` keeps its 7.30.0 reading.
34
+
35
+ ## 7.30.0 — 2026-09-24
36
+ ### A resumed leg with no shell is no longer told to run `git status` (#1046; prompt-face NARROWING; +0 exports; +0 wire keys; @server @test)
37
+
38
+ - **What was wrong.** The three resume arms that carry no pending tool — `wake`, `resource_limit`, `dry_run_review` — each told the model to "re-orient from the workspace: run `git status` …", whatever the leg could actually run. A leg with no `executionEnv` and no `executionEnvFactory` runs on the no-I/O stub and mounts no shell; a server started without a remote execution lane is exactly that leg. The model was asked for a command no tool could run, and a woken run on that path ended `blocked` in 3 of 3 black-box reproductions. The same held for an explicit `StubExecutionEnv` passed as `executionEnv` and for a real env whose roster excludes `Bash`.
39
+ - **One clause, one bit.** The three arms now share one re-orient clause (`reorientClause`, `src/core/runner/decide-continuation.ts`), forked on the leg's own `fullShellReachable` — the bit the inherited-gate phase computes and the defer-classify phase asserts equal to the post-exclusion roster (the full shell mounted or not; a mismatch throws). With the full shell the clause is the workspace one, `git status` included. Without it the clause reads: "re-orient from the conversation: re-read your most recent messages and tool results to confirm what is already done, then continue the remaining work. Do NOT restart the task or re-run work that is already committed." — no shell command at all. The fork reads an existing fact; three hand-written copies of the clause became one.
40
+ - **The one shell sentence that changed.** `dry_run_review` used to word its clause differently — "(run `git status` and review your recent changes) to confirm the current state … already done". It now reads the shared clause — ": run `git status` and review your recent changes / last commits to confirm what is already done … already committed". The meaning is the same and it still names `git status`; its verdict sentence before the clause is unchanged. The shell text of `wake` and `resource_limit` is byte-unchanged, and so is every lead sentence ("You were WOKEN from a parked pause", "You were resumed after a pause", "You were resumed after a dry-run review").
41
+ - **Who is hurt.** A consumer or test that pins the continuation text of a no-shell resume and expects `git status` in it must re-pin; lead-sentence pins keep passing. That includes resumes over a test env that extends `StubExecutionEnv` (such an env mounts no shell, so it now gets the conversation clause). No wire frame, no export and no settings key moves. The internal `Prepared` seat gains `fullShellReachable: boolean` (not a package-root export). `docs/wire-consumption/unreleased-1046.md` lists the observable text.
42
+ - **Not covered.** `docs/KNOWN-LIMITS.md` KL-1046a: a `handsReadOnly` leg reads the bit as false and gets the conversation clause. With the default read-only allow list that is right (`git` is not on it, so `git status` is refused there); a deployment that adds `git` to `hands.bashReadonlyAllow` loses a hint it could have followed. After a `dry_run_review` on a no-shell leg, the model re-orients from the conversation alone.
43
+
44
+ ### The file-history liveness advisory names its arm, and a deployment that decided not to capture has a word for it (#1047; patch; type face WIDENING on `RunnerDeps.fileHistoryStore`; +0 exports; @server @cli)
45
+
46
+ - **The advisory carries `classification`.** The "file history is not live for this run" line (`onError`,
47
+ `phase: "config"`, once per Runner per arm) now says which half is missing as a word:
48
+ `"file-history-no-store"` (a filesystem-capable run with no `RunnerDeps.fileHistoryStore` key) or
49
+ `"file-history-no-env"` (a store is wired but no filesystem-capable env will ever fill it). The sentence is
50
+ unchanged except for one trailing pointer on the no-store arm naming the off-switch below; a host that
51
+ matched the text keeps matching it, and a host that reads the word no longer has to.
52
+ - **`RunnerDeps.fileHistoryStore?: FileHistoryStore | "disabled"`** (same off-switch word as
53
+ `TaskSpec.checkpointStore`). `"disabled"` runs exactly as an omitted key does — no first-touch tracking, no
54
+ boundary, a restore request refuses `rewind.store_unconfigured` — and says nothing: the deployment made the
55
+ decision the advisory exists to prompt. An omitted key still announces (`"file-history-no-store"`). The
56
+ bad-value door admits this one word only; `null`, `"off"`, `"Disabled"` and every other non-store value
57
+ still refuse prepare (`config.invalid_file_history_store`). **Who is hurt:** code that reads the field off
58
+ a `RunnerDeps` it did not build and uses it as a store (the union now includes a string — a type error at
59
+ upgrade, never a runtime surprise); constructors are unaffected. **Compensation:** the explicit word
60
+ replaces guessing from the sentence. **Order:** core first — a pre-7.30.1 core refuses `"disabled"` loudly
61
+ at prepare (`config.invalid_file_history_store`), so a host must not write it before it runs this core.
62
+ Pickup: `docs/wire-consumption/unreleased-1047.md`; residual `KL-1047a`. Pins:
63
+ `test/backlog1047-file-history-disabled-word.test.ts` (four cells, near-miss spellings, the once-per-arm
64
+ memo, and the package-root `FileStorageBackend` + `Runner` loading path).
65
+
66
+
67
+ ### Agent teams, final form: the discovery face, the inbox wake, the durable terminal frame, the hand-back facets (#969 / #1024 / #1025 / #1034 / #1035 / #1036; minor)
68
+
69
+ **BREAKING (settings vocabulary, three sentences).** `crossSessionInbound` is now `wake | next-turn | hold | refuse`;
70
+ the word `accept` is retired. **Who is hurt:** every reader that pins the closed set — the settings-schema mirror,
71
+ the server's `CROSS_SESSION_INBOUND` enum and its lock-step pin, a cli settings face — and every settings file
72
+ that spells `accept`. **Compensation:** a layer that still spells `accept` is READ as `next-turn` (never held,
73
+ never invalid) and reported once per leg through `onError` (`classification: "peer-inbound-setting-spelling"`) and
74
+ on the resolved object (`deprecatedLayers`), so the value in force is never silently different from the value
75
+ written; an unset stack is byte-identical (mode parity, baseline `next-turn`). **Reach:** the order law is hard
76
+ — core first, then the settings-schema mirror, then the server's pin, and only then may a host write `wake`;
77
+ a pre-7.30 core reading `wake` holds everything (`invalid-setting`).
78
+
79
+ - **The listing (#1024).** `ListAgents` lists a peer session by default only while it is FRESH — live and
80
+ (its host says it is working, or it changed within the listing window, 24 h by default) — and `includeAll:
81
+ true` (a tool parameter) adds the stale and the offline rows; `deleted` never lists. Each peer row now
82
+ renders `active <N> ago` / `offline <N> ago`, the last two segments of its working directory, and its
83
+ self-reported presence as `self: agent=…, intent=…` (each column absent when the row has none; every value
84
+ bounded, the prefix names the speaker). `PeerSessionRecord` gains `agent?` / `intent?` (≤ 120 chars, a
85
+ longer or non-string value refuses the row); `TaskSpec.peerPresence?: { agent?, intent? }` rides the leg's
86
+ `wiring_manifest.peerPresence` verbatim for the host that writes the row — and a value the row reader would
87
+ refuse refuses the prepare by the same rule (`config.peer_presence_invalid`, naming the field). `ListAgents` also mounts on the
88
+ delegation surface with no directory (the two agent lanes alone — a local deployment sees the teammates it
89
+ spawned). **Retired:** `ListAgentsToolOptions.includeOffline` (type face −1; zero consumers measured); a
90
+ mount still passing it is refused at construction (`config.deps_retired_key`, naming `includeAll`).
91
+ Contract sentence `peer_directory.dead_is_the_hosts_word`: `dead` is the host's word, its retention period
92
+ is at least the engine's window, the engine never rewrites liveness from age.
93
+ - **The wake (#1025).** `wake` is judged exactly as `next-turn` at the drain; what it adds is the host's leg
94
+ (contract sentence `cross_session.wake_is_the_hosts_leg`): a host may start ONE turn on a `wake` session
95
+ whose box grew, under `maxWakesPerHour` (6). One drain pass injects at most `maxMessagesPerRound` (5) box
96
+ records — held releases, notice lines, peer messages and engine frames through ONE counter — and, when
97
+ records still wait, ONE engine line `[Inbox notice] N cross-session message(s) are waiting … (M delivered
98
+ this round)` rides the new `TaskNotificationPayload.inboxNotice` carrier (`_sema_provenance.kind:
99
+ "inbox_notice"`, keyed `task_id: "inbox:<box>"` + pass counter, never colliding with a message frame). The
100
+ sender may mark `SendMessage({ urgent: true })` (record key `peerMeta.urgent`): an urgent record is presented
101
+ ahead of the others of the SAME pass and counted on the notice; it never changes when the recipient reads.
102
+ **New tool `ReadInbox`** pulls the waiting records on demand through the same cursor — a record a boundary
103
+ delivered is never returned, a record the tool returned is never injected later; the tool never acks. Both
104
+ knobs and the listing window join the peer lane's ONE knob table, **renamed** (ruled, no alias): the type
105
+ `PeerAdmissionConfig` → `PeerLaneConfig`, `PEER_ADMISSION_DEFAULTS` → `PEER_LANE_DEFAULTS`,
106
+ `resolvePeerAdmissionConfig` → `resolvePeerLaneConfig`, the seat `RunnerDeps.peerAdmission` → `RunnerDeps.peerLane`,
107
+ and `SendMessageToolOptions.admission` → `.lane` — the table covers the admission gate, the wake/delivery
108
+ budgets and the listing window, so "admission" named a third of it (`maxWakesPerHour` / `maxMessagesPerRound` /
109
+ `listingFreshWindowMs`; the counts must be whole numbers — a fraction falls back loudly like any bad value).
110
+ The notice code `config.peer_admission_out_of_range` keeps its spelling (its text now says "Peer-lane setting").
111
+ The old keys are refused by name, never ignored: `RunnerDeps.peerAdmission` refuses `new Runner(...)` and
112
+ `SendMessageToolOptions.admission` refuses the tool's construction, both `config.deps_retired_key` naming the
113
+ replacement — a silently dropped key had let a configured rate bound fall back to the default. Every retired
114
+ `RunnerDeps` seat is now judged on the caller's own object at construction, so an inherited or non-enumerable
115
+ seat is refused too (construction copies deps into plain objects that keep neither); the prepare and resume
116
+ doors stay behind it. *Behavior:* a retired seat used to fail the first run / resume; it now throws at `new Runner`.
117
+ **Pickup, three sentences:** @server — `RunnerDeps.peerAdmission` no longer exists; wire the same object as
118
+ `peerLane` (a `Partial<PeerLaneConfig>`), and read `PEER_LANE_DEFAULTS` where the old constant was read.
119
+ @settings-schema — the peer-lane knob table's type name is `PeerLaneConfig`; a mirror that names the old
120
+ type must rename, and it gains the three keys above. Both repos grep 0 for the old names today; the pickup is
121
+ named so the rename cannot land on either as a surprise.
122
+ - **The durable terminal frame (#969 §D-②).** Where a deployment declares `RunnerDeps.sessionBoxEngineFrames:
123
+ true` (every drainer reads the codec), an engine-minted task-notification frame addressed to an idle session
124
+ parks in its durable box as a `MAILBOX_PEER_RECORD_KINDS` +`engine_frame` record (`encodeEngineFrame`:
125
+ codec version + tier + frame) and the session's next run — in any process — re-injects it through the engine
126
+ lane with its tier (no parity, no admission, no envelope). A codec version this engine does not read stops the
127
+ drain at that record; a record that is not the codec is settled without delivery; only the engine's own lanes
128
+ (`background_agent` / `monitor` / `workflow`) are ever encoded, and a decoded frame's optional facets
129
+ (`spawnName` / `handback` / `compaction`) are shape-judged on read — an ill-formed one is settled without
130
+ delivery, never rendered as a false disclosure. The leg whose execution parked a frame (its own session's box
131
+ or another session's) awaits that append before its promise resolves — and only that leg: a pending append
132
+ another session's producer started is never a condition of an unrelated run's `result()`. Without the declaration the frame parks in process exactly as before. Contract
133
+ sentence `mailbox.engine_frame_is_not_a_peers_words`.
134
+ - **The terminal frame's facets (#969 §D-①, CC 2.1.278 hand-back).** `TaskNotificationPayload.spawnName?`
135
+ (the `Agent({name})` key, rendered `<agent-name>` so the parent can `SendMessage({ to })` it by name),
136
+ `handback: "send" | "flagged" | "withheld"` (a report delivered / delivered under the hand-back screening's
137
+ SECURITY WARNING / nothing delivered; the model face renders `<handback>flagged|withheld</handback>` only when a
138
+ report did NOT arrive plainly) and `compaction?: { passes, breakerOpen, kind }` — one projection
139
+ (`terminalFrameFacets`) at all six delegation terminal mints. The truncation pointer now reads "ask the agent
140
+ for it via SendMessage, or read it with TaskOutput where mounted".
141
+ - **The child that forgot (#1035, disclosure only).** `TaskResult.compaction?: { passes, breakerOpen, kind:
142
+ manual|auto|reactive }` is present iff the run compacted at a boundary or a prompt-too-long recovery, or
143
+ opened its breaker; a delegated child's terminal frame carries it and renders `<context>compacted N× (kind)…`.
144
+ - **The child that forgot, the rest of it (#1035).** Three things a delegated child got wrong about its own
145
+ context, fixed by three existing laws each gaining one seat rather than by new machinery.
146
+ - **The settings travel.** `ParentSpecSeatFields` gains `compaction`, `skillListingBudgetFraction` and
147
+ `skillListingMaxDescChars`, so a parent's context-management settings serve the whole delegation tree
148
+ through the seat's existing law: the child's OWN value wins WHOLE (the keys it omitted fall to the ENGINE
149
+ defaults, never half to the parent), the seat fills an unset one, and the numbers are repaired per leg
150
+ against that leg's own window by the sanitizer every leg already runs. `enabled: false` is an ordinary
151
+ value, not a removal. The **observer sidecar** now inherits through the same `applyParentSpecSeat` call as
152
+ the three delegation lanes instead of a hand-kept list of five fields, so every seat member — today's and
153
+ tomorrow's — reaches it. *Behavior:* a parent that turned compaction off, or tuned its reserve, used to
154
+ watch its children run on the defaults. *Behavior, named (7.30.0 verification round):* an engine leg that never
155
+ asks the person — the observer sidecar and the verifier — states BOTH halves of that through one shared seat
156
+ (`interactiveTools: false` and `interactionPosture: "headless"`), so an interactive parent or deployment
157
+ default no longer gets it refused at the posture door (the observer under an "interactive" parent, and the
158
+ verifier under an "interactive" deployment default, never ran; the verifier's form predates this version).
159
+ The verifier leg's `configFingerprint` now differs from the implementation leg's by that stated posture.
160
+ *Behavior, named:* `compaction: { enabled: undefined }` (an explicit
161
+ `undefined`, e.g. a host building the object from an optional value) now reads as ABSENT — compaction ON, the
162
+ documented default — where 7.29 never compacted (the settings spread wrote `undefined` over the default, and
163
+ the pass's own gate read that as off while the boundary gate read it as on); the seat hands the same object
164
+ to children, so theirs follow. A host that meant "off" must say `false`. The one member the observer does not take is the question face:
165
+ it states `interactiveTools: false` itself, so a parent's live `onQuestion` never mounts `AskUserQuestion`
166
+ on a leg that only files a report (its roster stays ObserverReport / ReportFindings / ReadToolResult).
167
+ - **The inherited model follows the catalog.** A leg now carries the REF that chose its model
168
+ (`ResolvedRole.modelRef`, the harness, `ToolExecuteContext.modelRef`), and a child with no model of its own
169
+ inherits that ref — resolved once against the catalog generation in force at the CHILD's spawn — instead of
170
+ the `Model` object its parent has held since its own prepare. After a `swapDeps({ models })` such a child
171
+ runs on the new generation; a word the swap RETIRED refuses the spawn loudly (the shared unknown-ref
172
+ policy, as this Agent call's error result) where it used to run silently on the retired object. An
173
+ object-pinned parent is unchanged. Task lane, fork lane, observer sidecar and workflow mount read one rule.
174
+ - **The window is a setting, not a route.** New deployment seat `RunnerDeps.contextSettings` — a per-model
175
+ overlay keyed by catalog word, `{ contextTokens?, autoCompactTokens?, enabled? }` — and the fifth member of
176
+ `SWAPPABLE_DEP_SEATS`. It is the ONE swappable seat an IN-FLIGHT leg re-reads: the compaction trigger, the
177
+ sanitize clamp, the context-editing frontier, the request guard and the disclosed `contextUsage` all take
178
+ it at the moment of the question, so an operator who lowers a model's usable window reaches the legs
179
+ already running. Routing, credentials, price and fence stay snapshotted and can never ride this seat. The
180
+ overlay's PHYSICAL window (`contextTokens`) may only LOWER the card's, never raise it: an operator states how
181
+ much of a model to use, not what its endpoint accepts. Its TRIGGER window (`autoCompactTokens`) is clamped
182
+ only to that physical window, so it can sit ABOVE the card's own `autoCompactTokens` — compaction then starts
183
+ later than the card declared (`docs/KNOWN-LIMITS.md` KL-1035h). One value gate at construction and at the door (a bad number or an unknown member
184
+ refuses loudly, and so does a word that names no catalog model — `config.context_settings_unknown_word`,
185
+ checked against the catalog each swap leaves in force, so a catalog swap cannot orphan a declared word);
186
+ one announcement, `config.context_settings_swapped` (key material only). A leg chosen by a tier word or
187
+ alias — the default role on a tier-expanded catalog answers `"pro"` — reads its own word's overlay, else
188
+ the one declared for a word it stands in for (`tiers: { pro: "big" }` ⇒ `contextSettings.big`) — only the
189
+ words the tier expansion recorded for the model it bound (the tier words it wrote, then the binding's own
190
+ catalog word). A deployment key that a tier word passes by but that names ANOTHER model is never read, and a
191
+ deployment's own key stands in for nothing, even when it shares a model object with another. `RunnerSelfSeat` gains the optional `contextOverlayOf?(ref)`
192
+ (the live lookup) in place of the draft's `contextSettings?` member, and `Runner` gains the public method
193
+ `contextOverlayOf(ref)` that implements it; the `contextSettings` getter stays — the method answers the
194
+ overlay a leg on `ref` reads (tier bindings followed), the getter the declared table. *Also:* the
195
+ end-of-task summary now measures on the model the leg is RUNNING on, like the boundary and recovery lanes,
196
+ instead of its prepare snapshot, and the request-layer frontiers take their window AND their
197
+ chars-per-token coordinate from one snapshot of the current model — both visible after a budget degrade.
198
+ The SUMMARY request's own capacity is deliberately outside the seat, stated at the site (`KL-1035e`).
199
+
200
+ **Who is hurt:** a deployment that set `compaction` on a leader while relying on its children keeping the
201
+ defaults (now inherited — a child may author its own to opt out); a deployment whose model catalog is
202
+ hot-swapped and whose retired words are still named by running parents (an inheriting spawn now refuses
203
+ instead of running on the retired object — the parent leg itself is unaffected); a caller that CONSTRUCTS a
204
+ `ResolvedRole` literal (one required member, `modelRef`) or drives `runWorkflow` with its own `defaultModel`
205
+ (it answers `{ model, modelRef? }` now); a consumer pinning `SWAPPABLE_DEP_SEATS` or the notice-code set
206
+ (+1 word each). **Compensation:** every changed reading is loud where it refuses and disclosed where it
207
+ acts; the seat is absent by default and an absent seat is byte-identical to 7.29.
208
+ **Pickup:** `docs/wire-consumption/unreleased-1035.md`. Known limits: `KL-1035a`–`KL-1035g`.
209
+ - **The summarized inbox (#1036, C-R90).** `compacted.summarizedCrossSessionMessages?` counts the
210
+ cross-session message frames that sat in the summarized prefix (present iff ≥ 1) — and, now, the engine
211
+ acts on it. **A session-box record is acked only after a frame carrying it reached the TRANSCRIPT and no
212
+ compaction applied up to that settlement beat summarized that entry away.** Before, a record was acked —
213
+ physically deleted — the moment its frame was handed to the injector, and the drain's pass ran BEFORE the
214
+ same boundary's compaction: the very summary that swallowed a teammate's message could not be noticed, and
215
+ no copy was left anywhere. A summarized record is handed over ONCE more on the same boundary (same
216
+ `task_id`, same `seq`, `attempt: 2`, `resupplied: true`, one line of preamble); a re-supplied occurrence
217
+ summarized again settles as the new `PeerInboundDisposition` word `resupply_exhausted` — acked and
218
+ DISCLOSED as a loss, never as "the model read it". Contract sentence
219
+ `cross_session.settled_means_committed_and_not_summarized`. What follows from it:
220
+ - **New optional wire keys** `TaskNotificationPayload.resupplied?: true` (absent = not a repair; there is
221
+ no `false` form), `.attempt?: number` (absent = 1; never "unknown", never 0) and
222
+ `inboxNotice.resupplied?: number` (absent = no repair this pass; `delivered` now counts only records the
223
+ model had not seen before). The injection queue's dedup key gains an `:a<attempt>` tail for `attempt ≥ 2`
224
+ — byte-identical for every pre-1036 frame — without which a repair would fold into the occurrence it
225
+ repairs and the model would be shown nothing at all. A consumer that folds notification frames by
226
+ `taskId:status[:seq]` must add the attempt segment.
227
+ - **`MailboxStore.ack` now returns `{ acked: boolean }`** (`Promise<void>` → `Promise<MailboxAckResult>`).
228
+ **BREAKING for an out-of-tree `MailboxStore` implementation**, including a durable SQL twin; runtime
229
+ behavior of the bundled backends is unchanged — the lease fence (MB1) already made a non-holder's ack a
230
+ no-op, and what is new is that the no-op is *reported*, so a drain whose lease lapsed can no longer read
231
+ a silent refusal as success.
232
+ - **A HELD record now stays in the box** instead of being acked into a process-local copy: the box is the
233
+ held queue's durable seat, which is what #968's "held needs a durable seat of its own" was asking for.
234
+ Later records are still delivered past it; only the ack prefix stops there. A release (an approval,
235
+ a re-judgment) is delivered only inside a pass that holds the box's lease: an approval that arrives
236
+ between passes answers `released_pending` and the next lease-holding pass delivers it first — a leg
237
+ whose lease lapsed can no longer deliver the message a takeover is delivering, and a leg that
238
+ reacquires the box first retires every held entry whose record the box no longer holds
239
+ (`peer.held_settled` reason `superseded`) — another owner settled it, so it is never shown again. The same
240
+ step reconciles the other direction: a record this leg recorded as held, whose entry the shared queue no
241
+ longer holds and about which it took no decision (a takeover released it and ended before the frame reached
242
+ a transcript), is judged again instead of being skipped for the rest of the leg — where it also stopped the
243
+ ack prefix. A decision the queue took (a person's deny) stays terminal.
244
+ - **`ReadInbox` is budget-exempt** (the aggregate tool-result cap, like `Read`): the pull sizes its own
245
+ page and that tool result is the transcript receipt for every record it carries, so no request-side
246
+ transformation may replace it with a preview — a record is acked only as the model was shown it whole. The
247
+ receipt is judged per record on the result AS COMMITTED (`frameSurvivesInToolResult`: the frame's text
248
+ in the committed content, every whitespace run on both sides folded to one space — a hook that only
249
+ re-spaces, re-indents or re-wraps keeps the frame; the listing ride, which never commits announced state
250
+ at ride time, is the same caution applied before a commit exists). A frame the committed result lacks is
251
+ settled by WHO removed it. A host `postToolUse` hook that replaced, cut or reshaped the output made the
252
+ host's decision: each frame no longer found in it settles terminal (it leaves the box), is disclosed once
253
+ per result on `onError` (`classification: "peer-inbox-host-withheld"`, naming the count and the seqs — an
254
+ operator line, not a user-facing notice: KL-1036m), and is NEVER handed over again — re-sending it through
255
+ the engine lane would carry what the hook took out straight to the model (a truncating hook: KL-1036l). A
256
+ removal the host did not make (an engine-side crop — `ReadInbox` is budget-exempt, so none
257
+ today) leaves the record owed, handed over again at the same attempt (a re-supply stays a re-supply).
258
+ The frames the hook kept settle as read, and are repaired like any other if a later compaction
259
+ summarizes them away (the model had read them whole). A leg that ENDS before a
260
+ frame reaches the transcript likewise acks nothing — the record is re-served to the session's next run
261
+ (at-least-once, as documented) instead of being deleted unread.
262
+ - **Two advisory engine sinks** carry the facts: `AgentHarness.onFrameCommitted?(carrier, entryId)` fires at
263
+ the transcript append that proves a frame landed (a tool-result carrier includes the `content` it was
264
+ committed with), and `MaybeCompactOptions.onCommitted?(effect)` fires at
265
+ the compaction commit with the entry ids that went into the summary (or `"unknown"`, read conservatively
266
+ as all of them). The drain's pass is no longer a `turn_boundary` hook of its own — the turn-boundary lane
267
+ tail-calls it in a `finally`, so settlement runs after that boundary's compaction by statement rather
268
+ than by registration order.
269
+ - **`UserMessage.engineNoteKey?: string`** (C-R90 ②) persists an engine-note frame's dedup key on the
270
+ transcript entry it became: opaque, compared only for equality, stripped from provider payloads like its
271
+ five sibling provenance fields, normalized at the session import gate, absent on every earlier entry. The
272
+ live ledger dies with the process; this is what lets a rewind — or a service answering "which
273
+ cross-session frames did that summary swallow" — read the answer off the transcript.
274
+ - **The team role (#1034, first slice).** `wiring_manifest.teamRole: lead | teammate | none` (a top-level leg /
275
+ a named delegation / an unnamed one; leg-local, outside the fingerprint) and the pure `mayDirect(sender,
276
+ self) → lead | peer | stranger`, with contract sentence `team.direction_is_never_user_consent`. The directing
277
+ verbs (a plan approval, a shutdown request) are a later closed set.
278
+ - **The stop seat (#969 §E).** `StopHookContext.backgroundTasks?()` — the lazy in-flight background-task view
279
+ (CC `SubagentStop.background_tasks`).
280
+ - **SendMessage's narrow arm (CC 2.1.278).** The not-found refusal now says what IS reachable from the mount.
281
+
282
+ Who is hurt: a deployment wiring `RunnerDeps.peerAdmission` or importing the three renamed symbols (a type
283
+ error at upgrade and a refusal by name at runtime, never a silent default); a reader of `ListAgents` that relied on every live row listing by default (a 3-day-old session
284
+ now needs `includeAll`); a settings file spelling `accept` (read as `next-turn`, announced); a wire consumer
285
+ pinning `MAILBOX_PEER_RECORD_KINDS`, `SemaProvenance.kind` or the settings closed set (each +1 word). Exports
286
+ +28 / −3 for the whole version (`npm run handoff:diff v7.29.0 HEAD`): +20 from this section (`isFreshPeerRow`,
287
+ `PEER_LISTING_FRESH_WINDOW_MS`, `PeerPresence`, `PEER_PRESENCE_MAX_CHARS`, `createReadInboxTool`,
288
+ `READ_INBOX_TOOL_NAME`, `READ_INBOX_MAX_LIMIT`, `ReadInboxToolOptions`, `TEAM_ROLES`, `TeamRole`,
289
+ `DIRECTION_RELATIONS`, `DirectionRelation`, `teamRoleOf`, `mayDirect`, `HANDBACK_STATES`, `HandbackState`,
290
+ `COMPACTION_KINDS`, `CompactionKind`, `handbackStateOf`, `terminalFrameFacets`), +5 from the summarized-inbox
291
+ section (`CompactionEffect`, `MailboxAckResult`, `PEER_RESUPPLY_CAP`, `READ_INBOX_RESULT_BUDGET_CHARS`,
292
+ `RESUPPLIED_FRAME_PREAMBLE`) and +3/−3 for the rename (`PeerLaneConfig` / `PEER_LANE_DEFAULTS` /
293
+ `resolvePeerLaneConfig` replace `PeerAdmissionConfig` / `PEER_ADMISSION_DEFAULTS` /
294
+ `resolvePeerAdmissionConfig`); type face −1 (`includeOffline`). Done in this version, after the design round
295
+ the earlier draft of this section deferred them to: compaction-setting inheritance to a child (#1035) and the
296
+ post-compaction re-supply of the inbox (#1036), both below. Still not done: the hand-back re-prompt cap (7.31);
297
+ an inbox give-up point (`docs/KNOWN-LIMITS.md` KL-969n — the durable box never drops a record, only the notice
298
+ repeats); the end-of-task settle compaction is not counted in `TaskResult.compaction`.
299
+ ### Every shell reader consumes the one lexer's FACTS; the strict mode, the quote mask and five hand-written readers retire (#994 S1; behaviour NARROWING ×4 and WIDENING ×3; +0 exports; one public RESULT SHAPE change on `parseLeadingCommandName` — @server; @cli @test)
300
+
301
+ - **What changed.** `src/shell/lex.ts` has no modes: it reads every construct bash reads (a redirection with its descriptor, a here-string, a substitution, a comment, a `|&`, `time [-p] [--]`'s grammar) and records it. The classify faces' whole-string gate (`hardRejectReason` + the quote mask + the strict lexing mode) is one PREDICATE over the lexed command, `strictResidualRefusal` (`src/tools/fs/bash-readonly-classifier.ts`), minting the sentences the gate always minted in the order it minted them. The position reader reads `assignment.bare && atCommandPosition` (`src/tools/fs/bash-program-position.ts`); the read station reads the assignment VALUE, the expansion list and the home word off the word (`src/tools/fs/shell-read-word.ts`, `shell-read-plan.ts`); the poll loop reads the lexer's segments (`pollLoopShapeOf`); the two data-free redirections pass as the TOKENS they are (`isDataFreeRedirection`), no longer as words stripped out of the text.
302
+ - **Retired (rules fewer, by the design's count: −10).** One lexical walk (`quoteMask` / `quoteStep` / `QuoteOpen`); five quote-pairing state machines (`tildeExpandsInValue`, `hasUnquotedExpansionMetachar`, `hasUnquotedGlobMetachar`, `foldHomeVariableSpelling`, `pollLoopQuotesBalanced`); two assignment grammars (the position reader's raw re-scan `prefixWrittenBare`, the `homeReassigned` regex); two direct splits of command text (`pollLoopBeatsOf`, `pollLoopSleepReason`). Also gone: `assignmentWordPayload`, `stripExemptRedirections`, `redirectionPrecedesConnector`, `rejectedSansRedirection`, the `UNRESOLVED_VIEW` table (every unresolved reason refuses alike), the lexer's `LexMode`/`LexOptions`, and the `background_operator` parse-failure reason (a lone `&` is a connector the predicate refuses with the same sentence). `ASSIGNMENT_PREFIX` / `writtenAssignmentPrefix` stay for the two TEXT readers S2 retires (`tool-policy.ts`, `arg-summary.ts`); no S1 reader reads them.
303
+ - **NARROWING ① — a non-blank whitespace character is a word character (residual ② of the strict mode; ruling (a)).** `ls<NBSP>x`, `ls<VT>pwd`, `ls<NBSP>"|"<NBSP>touch marker` are ONE program to bash, and now to the classify face: refused as the unlisted name they are, where the strict mode read the head `ls` (a prefix of that name) and classified green. **Public result shape (@server):** `parseLeadingCommandName` bounds its first word on bash blanks (`[ \t]`), so its `name` for such an input is the fused word; `runtime-governance.ts:31,136` consumes it. Who needs it: every allow/deny name table, which now sees the program bash resolves. Who is hurt: nobody — the fused word runs nothing (`command not found`, measured). Compensation: none needed; the poll-loop face's own Unicode-whitespace refusal is unchanged.
304
+ - **NARROWING ② — a locale string `$"…"` asks on both faces (ruling C-2).** Its translation is not in the text: the read station marks its candidates unresolvable (the deny judge still reads the written spelling — `cat $"./.env"` refuses at zero cards as before), and the classify face refuses it under the substitution sentence as before. `cat $"safe.txt"` on the boundary-only seat: 7.28.1 ran at zero cards, now one card. Who needs it: the reference implementation's reading (a translated string is not readable). Who is hurt: a `$"…"` spelling of an in-root read costs a card. Compensation: the plain and `$'…'` spellings are unchanged.
305
+ - **NARROWING ③ — a `#`-headed persisted-rule body is `invalid.empty_command` on import (ruling (b)).** `Bash(# …)` names no command (bash reads a comment); 7.28.1 stored it as a rule for the program `#`. The code is the existing one; no member is added.
306
+ - **NARROWING ④ — a text that may rebind `HOME` places no home-rooted word, at any depth (corrected before release).** `$HOME` / `${HOME}` / a leading `~` are spelled with the declared home only when the command text spells the name `HOME` nowhere but as those plain reads — read lexically (`homeMayBeRebound`, `bash-readonly-classifier.ts`; quotes, backslashes and `\<newline>` continuations removed first), one predicate the classify faces and the read station both consume. Any other spelling leaves every such word unresolvable (a mandated card on both full-shell seats, a refusal on the read-only face, denied headless) — while its DECLARED-home spelling stays a deny-only view, so a deny row under the declared home still refuses when the read spells it literally (`read H?ME <<< x; cat ~/<deny row>`, and a `cd ~` destination joins the deny floor; `floorSpelled`, never a target; the same view is given to a home word inside a value the plan does not place, rebinding text or not — `A="$HOME/<deny row>"`, `dd if="$HOME/<deny row>"`, which 7.29.0 asked and, approved, read — while a glob, a recursive read or a symlink through the home is NOT resolved on that view and still asks: #1064 ⑬, 7.30.1) —, and a substitution body or re-entered text inherits the doubt: an assignment at any nesting depth (`{ HOME=/x; }`, a function body, a `case` arm, an `&&` group), a declaration builtin's operand however reached (`export 'HOME=/x'`, `command export …`, `builtin declare -x …`), a `for` / `select` variable, a `read` / `printf -v` / `mapfile` / `unset` / nameref target, `${HOME:=…}`, a `cd ~`. 7.29.0 asked for leading assignments and bare declaration operands but vouched at zero cards `HOME=/x; cat ~/f`, `for HOME in /x; do cat "$HOME/f"; done`, `read HOME`, `printf -v HOME /x` and `unset HOME` before a home read — the file under the reassigned value was read. **Pre-release correction:** this slice's first cut read the reassignment off each segment's top-level argv and vouched `{ HOME=/x; }; cat "$HOME/f"` and the function / `case` / `&&`-group forms where 7.29.0 asked (measured on the real lane: the deny-row file was read); the lexical rule closes those and the older forms together (`test/backlog7300-shell-home-reassign.test.ts`: ten forms at the station on both seats, five on the real lane headless with a sentinel, controls, the cost). Who needs it: every deny row under a directory a command could point `HOME` at. Who is hurt: a text that merely spells the name while reading through the home costs a card (`echo HOME; cat ~/x`, `env HOME=/x cat $HOME/f` — which 7.28.1 and 7.29.0 also asked —, `MY_HOME=/x; cat ~/y`; KL-994h). Compensation: none needed; a text without the name reads the declared home as before. A name the shell COMPUTES spells no `HOME`: `printf -v` (the one assigning builtin whose words are no read candidate) whose name is not a literal identifier — `printf -v "H$x"`, `printf -v H{O,X}ME`, `printf -v HO"$(:)"ME`, `-vH$x` — counts as a rebinding too (7.29.0 vouched all of these; a literal `printf -v X` is unaffected); so does an unquoted GLOB word whose pattern can match the string `HOME` — `read H?ME`, `unset [H]OME`, `unset *`, `mapfile -t H?ME`, `getopts a H?ME` hand the builtin the name when the working directory holds an entry named `HOME` (measured on the real lane: 7.29.0 read the deny-row file at zero cards) — and an extglob opener; a pattern that cannot match `HOME` (`ls *.txt`) is unaffected, an unquoted `*` beside a home read costs a card (KL-994h). A computed name elsewhere is already its own unresolvable candidate. A glob that expands to an assignment TARGET `HOME=<dir>` (through `export` / `declare` / `local`, an entry of that name in the working directory) is NOT read this wave — KL-994j / #1064 ③.
307
+ - **NARROWING ④ (glob-computed names, extended before release).** The glob reading of NARROWING ④ now covers five more ways a computed word becomes the name `HOME`, each measured against a real bash sentinel read at zero cards on 7.29.0: (1) a bracket the lexer under-carves — a POSIX class / equivalence class / collating element (`read H[[:upper:]]ME`, `[[=O=]]`, `[[.O.]]`); (2) an array element (`read H?ME[[]0]` names `HOME[0]`, and `HOME[0]=…` rebinds `$HOME`); (3) an OPTION-attached target — a glob that can begin `-` and end in the name, OR in an array element `HOME[…]`, matches an entry `-aHOME` or `-pHOME[0]`, read as `read -a HOME` / `wait -p HOME[0]` (`read -aH?ME`, `?aH?ME`, `[-]aH?ME`, `wait -pH*]`); (4) a `nocaseglob` fold, so the pattern is matched case-insensitively on its positive members (`read h?me`, `read [h]ome`); (5) an unquoted BRACE list whose expansion is the name (`read H{O,X}ME`). A bracket is read as a REAL character class (`[abc]` cannot match `H`, so `ls [abc].log` still runs) — positive members folded for `nocaseglob`, a negated class read case-sensitive (folding a negation would exclude the other case and MISS) — and a bracket the reader cannot translate faithfully (a body carrying `$` / a backtick / a brace / any quote character, a POSIX class beside a plain member, or a positive range) is read on the fail-closed side (a match). The EXTGLOB opener (`?(` `*(` `+(` `@(` `!(`) is read off the lexer, so an operator inside a comment, a here-document body, or quotes (`grep -E 'a+(b)'`) is the ordinary text it is, and an unparseable text is read as a rebinding. Who is hurt: the accepted-cost forms — a positive-range bracket, a computed/quoted bracket body, a brace list, a wildcard that can expand to `-…HOME` or `-…HOME[…]`, a glob that merely ends in `]` (`ls -d *]`), a bare `ls *` — cost a card beside a home read; the forms that stay placed (`ls *.txt`, `ls src/*.ts`, `ls *.[ch]`, `find . -name '*.ts'`, `grep -E 'a+(b)'`) and the whole list are in KL-994j. `test/backlog7300-shell-home-reassign.test.ts` pins the five forms, the fidelity controls, and the real lane; the reading was hardened across three review legs (each reproducer verified against bash 5.3).
308
+ - **WIDENING ① — an unquoted word-start `#` is a comment (residual ① of the strict mode).** `ls #; rm x` classifies as the `ls` it runs (7.28.1 refused on the textual `rm` segment that was never a command); `cat # x` is a bare `cat` (stdin floor, refused). A `#` inside a word (`f#x`) or after an empty quote pair (`''#x`) is text, as bash reads it. The poll loop reads the same comment: `for … done #c` is a loop, `do ls # x; done` is bash's syntax error.
309
+ - **WIDENING ② — the rule lane's tolerate reading reads redirections as bash does (gap ①; ruling ⑤).** `>|` is one operator: `echo hi >|out && npm test` yields two segments (the first unnamed, the second offered), `2>|out cat nosuch` one; a redirection with no target (`echo > | out`, `-newer <file>`, `cat << EOF` with no body) is bash's syntax error — a parse failure, no offer (fail-closed). 7.28.1 refused the first class whole ("a redirection followed by `|`…") and read the second as text.
310
+ - **WIDENING ③ — the double-quoted home word is judged for any declared home value on the read station (ruling ②).** `"$HOME/notes"` under a home spelled `/home/u$x` or `/home/u"x` resolves to the path bash hands over (out of root: mandated; in root: vouched), where 7.28.1's text rewrite bailed and refused it as a substitution. The unquoted spelling keeps the one-word condition (a home bash would split or glob stays a substitution). The classify face keeps the refusal for a home value its leading-name re-read cannot carry (KNOWN-LIMITS).
311
+ - **WIDENING ④ — a line continuation is removed before anything is read (gap ⑥).** `cat ~\<newline>/.env` is `cat ~/.env` to bash and now to the classify face, which used to refuse the pair as a backslash and a line break; the read station judges the path it names.
312
+ - **Three reproductions of the adversarial round, fixed.** (1) `A=1 2>/dev/null HOME=/x` is two assignments: a redirection inside the assignment/redirection prefix no longer closes the lexer's command position (bash performs both, measured on 3.2 and 5.3), so the second is stripped and read as the reassignment it is. (2) A declaration builtin reached through a launcher or spelled quoted (`command export HOME=…`, `builtin declare -x HOME=…`, `"export" HOME=…`) reassigns: the lexical rule of NARROWING ④ reads it without asking where the program stands. (3) A program word that spells the home (`"$HOME/cat" /work/file`) is a candidate under its RESOLVED spelling (`/work/cat`), never the written `$HOME/cat`: a deny row on the executable is met.
313
+ - **Sentence changes, verdict unchanged.** `|&` is refused as "`|&` pipes standard error as well as standard output" (was the backgrounding sentence; bash 4 pipes both streams, bash 3.2 errors — the lexer records `pipesStderr`, the predicate refuses it by name). `time -- cmd` / `time -p -- cat f` read the words after `--` as the command (bash 5's `TIMEIGN`; bash 3.2 runs a program named `--` — the reading that judges more): the station vouches or judges `cat f` where 7.28.1 refused "`time` is given the option `--`". A subscript carrying a substitution (`A[$(cat .env)]=v true`) is an assignment word whose value is judged and whose body is a nested plan: refused on the real lane at zero cards when the body reads a deny row, vouched when it does not (7.28.1: an unknown program, a mandated card either way). KL-993e and KL-993f close.
314
+ - **Measured.** Eight lexical gaps pinned against bash 3.2.57 and 5.3.15 by xtrace in a sandbox (`test/backlog994-s1-gaps-real-shells.test.ts`; a missing bash is a red test). Dual run over 4383 inputs (every string literal of the shell-reading test files plus the adversarial lists, the HOME-rebinding family included) × 10 faces against the v7.28.1 dist: 221 inputs differ, every difference under one of twelve attributions on a closed list (`test/backlog994-s1-dual-run.test.ts`; the report with one before/after per class is in the car's intake record). A difference on an input that reads or may rebind the home (the rebinding question is the tree's own `homeMayBeRebound`, handed to the attribution — no second copy) is screened before any attribution: one that widens a verdict, or that places the declared home for a text that may rebind the name, is unrecorded whatever else the input carries (the first cut's regression was explained by a substring match on `HOME`; with the screen it reds the pin). Export surface +0/−0. Per-fixture mutation table: 13 mutations, each named by the pins it reds.
315
+
316
+
317
+ ### Shell text a builtin keeps for later is a shell re-entry, as `eval`'s text is; a keyword function definition no longer swallows its body (7.30.0 read boundary; behaviour NARROWING; +0 exports; +0 wire keys; @cli @test)
318
+
319
+ - **NARROWING — code-body operands join the re-entry set.** The action of `trap`, every `alias NAME=VALUE` value (an empty one included), and the `-C` callback of `mapfile` / `readarray` / `compgen` (separated or attached `-C'…'`; an empty one included) are shell text the shell runs later (compgen: at once). They are treated as `eval`'s operand is: the segment re-enters the shell, so the whole command is at least `unestablishable` (mandated on `bash_off` and `bash_classify`), and each body is its own nested plan whose deny hits refuse. The builtin's own words stay the candidates they were, and the body is read both as the text the shell runs (a quoted leading `~` stays the home) and as its folded path spelling, so no command's verdict falls below the pre-change tree (measured over 95 hand forms × both seats and the 4383-input dual run). Before: a body that computed a name (`x=OME; trap 'read H$x <<< DIR' DEBUG; cat ~/k`), moved the directory (`trap 'cd DIR' DEBUG; cat k`) or computed a path ran at zero cards and read a deny-listed file on the real lane. No body is no re-entry: `trap - SIG`, `trap '' SIG`, `mapfile` without `-C`, `compgen -F NAME` (a function name) stay as they were. The readers live in `src/tools/fs/shell-reentry-text.ts` (one table, `CODE_BODY_OPERANDS`).
320
+ - **NARROWING — `function NAME [()] { … }` is read as `NAME() { … }` is.** The keyword head (bare `function` at command position, its name of any spelling, an optional `()`) is dropped from the argv projection, and the lexer puts the body's compound command back at command position after the name, so a definition nested in a definition body is a head too. Before: the block-word branch returned on `function` and the body's first command was never read — `function f { cat <deny row>; }; f` ran at zero cards and printed the file. Now it refuses on both seats, nested definitions included (pinned to three levels). With the body at command position, a compound array assignment in it (`A=(…)`) is one word, as at the top level; its literal elements (and those of `local` / `declare` / `export` operands) reach the deny judge as a scalar value does, so `A=(package.json); head -c 64 "${A[0]}"` under a deny row refuses in a body and at the top level alike (the top-level form asked before; the list itself stays unread for everything else, so a harmless list still asks). The list is read across lines and comments (a `)`, a `(` or a quote inside such a comment still ends the list early — #1064 ⑮), and an element the lexer cannot read leaves its siblings readable (`A=(safe\npackage.json)`).
321
+ - **NARROWING — `coproc NAME { … }` keeps the command reading's re-entry.** The two readings (first word as the command / as the name) were merged for candidates and nested plans only; a shell re-entry found by the second (`coproc NAME { trap …; }`, `{ eval …; }`) is carried now.
322
+ - **Who is hurt / accepted cost (`docs/KNOWN-LIMITS.md` KL-994i).** Every body asks, a harmless one included: `trap 'echo bye' EXIT; cat ~/x` and `alias ll='ls -l'; cat ~/x` now show a card (headless: refused), exactly as `eval 'echo bye'; cat ~/x` did. A display option or a lone operand (`trap -p SIG`, `trap SIG`) also counts its operand as the body. A test that pinned these as vouched must re-pin. **Compensation:** one rule for all text the shell runs later instead of a per-body reading that would have to model what each body does. **Not covered** (pre-existing, ticketed): `BASH_ALIASES[…]=` assignments, `PS4` / arithmetic / `${x@P}` evaluation of variable values, `history -s`, `git -c core.pager=…`, `command -- trap …` (a launcher decline), an empty-expanding word in front of a body, and a `coproc` second reading's directory move. Pins: `test/backlog7300-shell-reentry.test.ts` (station on both seats, a real Runner lane for trap / alias / function / nested function). A function body is read at command position now (`function NAME { … }` like `NAME() { … }`), so a compound assignment inside it is read as one — `function f { local B=(1 2); }; cat ~/x` asks where v7.29.0 vouched (the same card `f() { local B=(1 2); }; cat ~/x` has always drawn).
323
+
324
+ ### A session log with no meta header is refused, and every JSONL replay reports its losses in levels (#1041; 7.30.1; behaviour NARROWING; export-surface kind change ×1; +0 / −0 exports; @server @cli @client-core @test)
325
+
326
+ - **New code `SessionError("corrupt_session")` (`SessionErrorCode` +1).** `FileSessionRepo` now answers
327
+ three ways: no file → `not_found`; a file with **no meta header** → `corrupt_session`; otherwise the
328
+ session, each skipped record disclosed. Every write path of this store lays the meta header down first,
329
+ so a headerless file is not a session it wrote, or is damaged throughout. Before, such a file (every line
330
+ unreadable, or only line 1) replayed as `createdAt: ""` + `entries: []`, the exact shape of a new empty
331
+ session, and a resume continued with no context and no signal. The message carries the reading
332
+ (`… has no meta header (4 of 4 records skipped as corrupt)`). It is **not** `invalid_session`, which stays
333
+ "a caller-submitted log failed validation": this one is stored data damaged on the server side. A resume
334
+ of such a session fails with `code: "corrupt_session"` whether or not `requireExistingSession` is set:
335
+ acquire rethrows it and never creates over the file. A meta-only file is still a legal empty session;
336
+ the listing skips a corrupt session with a disclosure instead of listing it with an empty `createdAt`.
337
+ - **Disclosures carry their level.** Every `onCorruptRead` payload (`FileStorageCorruptReadInfo` at the
338
+ root) is a union on `kind`: `"record"` = one JSONL record a replay skipped (`line`, 1-based; `form`, a
339
+ `CorruptBodyForm` word); `"replay"` = sent once per read that skipped anything, after that read's records
340
+ (`kept`, `dropped`, `total` = kept + dropped; `kept === 0` = nothing in the file was readable); `"file"` =
341
+ a file or directory as a whole. `path` and `reason` are on every level, so a sink that reads only those
342
+ two keys needs no change. No tally means nothing was skipped. The unterminated tail a crash leaves is
343
+ still dropped silently and counted in neither.
344
+ - **Reason strings stop claiming lines they have not read.** Each record reads `form: what at line N
345
+ (skipped)`. The "(skipped; the rest of the replay is intact)" clause is gone from the reader and from
346
+ the four stores' own judgments.
347
+ - **One reader counts every skip.** `readJsonlRecords(path, onCorrupt?, judge?)` takes the consuming
348
+ store's own shape floor (`JsonlRecordJudge { accepts, what }`), so a record the store rejects is a
349
+ `missing-required` record, with a line number, on the same count. The session repo, mailbox, workflow
350
+ journal and the shared ledger (checkpoint / background agent / workflow run) pass theirs and no longer
351
+ re-judge after the reader. One skip that used to be silent is now counted: a workflow-journal line
352
+ under a scope other than its run's first line (which `append` refuses to write) was dropped with no
353
+ disclosure. The mailbox's live fold applies the same judge as its replay, so an event the replay would
354
+ skip (`ack(…, NaN)`) changes nothing live either, as before.
355
+ - **Export face.** `FileStorageCorruptReadInfo` is now a **type alias** (was an interface). That breaks only
356
+ `interface … extends` / `implements` / declaration merging on it; a parameter annotation still compiles.
357
+ `SessionPolicyCorruptReadInfo` gains a required `kind: "file"`, which breaks only code that constructs one
358
+ (none outside core). `readJsonlRecords` gains an optional third parameter. The seat parameters of
359
+ `FileSessionRepoOptions`, `FileMailboxStoreOptions`, `FileFileHistoryStoreOptions` and `FileRosterStore`
360
+ now take the union, and a sink typed for `{ path, reason }` is still accepted. `handoff:diff` shows
361
+ +0 / −0 exports.
362
+
363
+ ### The run teardown sweeps the registry key its rows register under (#1051; behaviour fix + one prepare refusal; `config.*` +1; +0 exports; +0 wire keys; @server @test)
364
+
365
+ - **What was wrong.** A run's Bash / Monitor rows register under (host task id, mount domain), where the domain
366
+ is `RunInternals.registryScope ?? spec.principal ?? "default"`. The three teardown sweeps (settle, evict,
367
+ background-agent reap) each spelled the scope as `spec.principal ?? "default"`, and registry access is
368
+ scope-equality. The two spellings differ for a delegated child under a mount that declares `background.scope`
369
+ and runs without `principal`. There the child's teardown swept `"default"` while its rows lived under the
370
+ mount domain. Four effects: its background sub-agents kept running after it ended (no stop attribution, model
371
+ call not aborted); its background Bash row on a retain-declared env was never marked retained; on a dying
372
+ isolated per-task env that row was left `running` over a destroyed sandbox; an ended monitor row was never
373
+ evicted.
374
+ - **Fix.** `Prepared.registryAccess` (engine-internal, not a root export) is minted once at prepare from the
375
+ host task id and the mount domain, and all three sweeps read it. The teardown no longer derives a scope of
376
+ its own.
377
+ - **Observable.** In that shape the grandchild's `task_notification` now ends `killed` / `stoppedBy: "parent"`
378
+ when the child's run ends (`"user"` when the child was interrupted live). Top-level runs and runs whose
379
+ principal is the mount domain are unchanged.
380
+ - **Ops.** On a deployment that declares `background.scope` on a delegation mount and runs without `principal`,
381
+ background sub-agents spawned by a sub-agent now stop when that sub-agent finishes, as they already did
382
+ everywhere else.
383
+ - **One registry domain per run.** A run given both `spec.principal` and a different `RunInternals.registryScope`
384
+ now fails at prepare with `config.registry_scope_mismatch`, naming both values, before any session is acquired
385
+ or any child is spawned. The delegation lane always sets the registry scope to the principal when there is
386
+ one — read from the SAME source the child's principal comes from (the parent spec seat), so a direct
387
+ `createSubagentTool` mount whose ctx carries the principal on the seat alone, under a mount-declared
388
+ `background.scope`, no longer hands its child a domain other than its principal — so no first-party lane
389
+ builds this pair; only a caller driving `runTask(spec, internals)` directly can.
390
+ Such a run used to split its rows between the two domains (delegation-tool rows under the principal, Bash /
391
+ Monitor rows under the registry scope), and no single teardown key reached both.
392
+ - Pins: `test/backlog1051-teardown-registry-scope.test.ts` (nine cells: seven teardown cells, where reverting
393
+ any one sweep reddens its own cells, and two door cells). Pickup: `docs/wire-consumption/unreleased-1051.md`.
394
+ KL-1051a closed.
395
+
3
396
  ## 7.29.0 — 2026-09-23
4
397
 
5
398
  ### Added — the provider's own model name reaches the user face (#1029)
@@ -62,15 +62,17 @@ export declare function childThinkingSeat(explicit: ThinkingLevel | undefined, d
62
62
  export declare function deploymentSubagentTierModel(deps: {
63
63
  models?: Record<string, Model>;
64
64
  roles?: ModelRoles;
65
- } | undefined): Model | undefined;
65
+ } | undefined): InheritedModelPick | undefined;
66
66
  /**
67
67
  * The child run's model seat — ONE rule for every engine-spawned run (the delegation child at
68
68
  * `buildChildSpec`, the observer sidecar, the workflow launch site): an EXPLICIT model for this spawn wins
69
69
  * (per-call `model`, the agent definition's `model`, the tool-level `model`); else a deployment that STATED
70
70
  * a subagent tier gets it (no model on the seat — the role chain resolves the stated entry, `select` forms
71
- * included); else the child runs on the PARENT run's effective model (the caller's current `Model` object,
72
- * so per-model routing/credentials ride along); else — no parent model readable, which only happens outside
73
- * a Runner-enriched ctx — the bare role, whose chain ends at the deployment default.
71
+ * included); else the child runs on the PARENT run's effective model — taken as the REF that chose it and
72
+ * resolved against the catalog generation in force at THIS spawn ({@link inheritedParentModel}), so
73
+ * per-model routing/credentials ride along and a hot-swapped catalog reaches the child; else — no parent
74
+ * model readable, which only happens outside a Runner-enriched ctx — the bare role, whose chain ends at the
75
+ * deployment default.
74
76
  *
75
77
  * THE ROLE IDENTITY IS CONSTANT: every seat carries `modelRole: "subagent"`, the explicit-model seat too.
76
78
  * `resolveTaskModel` keys the role's `systemPrompt` / `thinking` presets on `modelRole` in BOTH of its
@@ -93,7 +95,57 @@ export type ChildModelSeat = {
93
95
  modelRole: "subagent";
94
96
  model?: ModelRef;
95
97
  };
96
- export declare function childModelSeat(explicit: ModelRef | undefined, deploymentStated: boolean, parent: Model | undefined): ChildModelSeat;
98
+ /**
99
+ * The PARENT rung's value, resolved at THIS spawn tick. The parent leg travels as the REF that chose its
100
+ * model (`ToolExecuteContext.modelRef`), not as the object it has been holding since its own prepare, so:
101
+ * · a catalog word resolves against the catalog generation in force NOW — a child spawned after a hot
102
+ * swap runs on the generation its own prepare would have seen, which is what the swap law already
103
+ * promises every string ref ("resolves against the new catalog at the next prepare");
104
+ * · a word the swap RETIRED throws here, loudly, under the same unknown-ref policy a caller-written
105
+ * retired word meets — never a silent fall back to the retired object, which is the shape that let a
106
+ * child run for its whole life on a generation the deployment had withdrawn;
107
+ * · a `Model` OBJECT is its own ref and passes through pinned, the swap law's other existing arm.
108
+ * Resolved lazily by {@link childModelSeat}, on the arm actually taken: a spawn that names its own model,
109
+ * or a deployment that stated a subagent tier, never consults the parent rung and so never inherits its
110
+ * refusal. A word with NO catalog to ask refuses under that same policy rather than travelling on as a
111
+ * word: a seat that cannot name the model it seats is not a seat, and the caller learns it here.
112
+ *
113
+ * @contract child_seat.parent_model_travels_as_its_catalog_word — an engine-spawned run that inherits its
114
+ * parent's model inherits the parent's CURRENT model BY THE REF THAT CHOSE IT, resolved once against the
115
+ * catalog generation in force at the child's own spawn: a catalog word therefore lands the child on the
116
+ * generation its own prepare would have seen (and refuses loudly, under the shared unknown-ref policy, when
117
+ * the word has been retired), while a parent whose model is a `Model` object travels as that object and is
118
+ * never re-resolved. The resolution happens at the spawn, not in the child's prepare, so the model the spawn
119
+ * frames name and the model the child runs on stay one value.
120
+ */
121
+ export declare function inheritedParentModel(parent: ModelRef | undefined, catalog: Record<string, Model> | undefined): Model | undefined;
122
+ /**
123
+ * What a spawn seats, and WHICH catalog word chose it.
124
+ *
125
+ * The two travel on different channels ON PURPOSE. `seat` is spread onto the child SPEC and carries the
126
+ * RESOLVED object, so the model the spawn frames name is the model the child runs — nothing re-resolves
127
+ * between the judgement and the child's prepare. `modelRef` is the word that object came from, and it rides
128
+ * the TRUSTED internals channel (`RunInternals.inheritedModelRef`) so the child is not silently PINNED by
129
+ * having been handed an object: its own children inherit the word in turn, and the deployment's per-model
130
+ * window overlay — which is keyed by word — still reaches it. Absent `modelRef` = there is no word (an
131
+ * object the caller pinned, a `select` criteria), and a leg with no word is never re-resolved and never
132
+ * overlaid, which is exactly what pinning means.
133
+ */
134
+ /** A resolved model together with the catalog WORD that chose it — the shape a lane hands its launch site
135
+ * when the two travel separately (the object onto the child spec, the word onto its trusted channel). */
136
+ export interface InheritedModelPick {
137
+ readonly model: Model;
138
+ readonly modelRef?: ModelRef;
139
+ }
140
+ export interface ChildModelSeatPick<S> {
141
+ readonly seat: S;
142
+ readonly modelRef?: ModelRef;
143
+ }
144
+ export declare function childModelSeat(explicit: ModelRef | undefined, deploymentStated: boolean, parent: ModelRef | undefined, catalog?: Record<string, Model>,
145
+ /** The word the CALLER resolved `explicit` from, when the caller resolved one (a per-call model word a
146
+ * delegation gate judged into an object before the seat ever saw it). An explicit STRING needs none — it
147
+ * travels as itself and the child's own prepare resolves it, which already leaves the child its word. */
148
+ explicitRef?: ModelRef): ChildModelSeatPick<ChildModelSeat>;
97
149
  /**
98
150
  * The FORK's model seat — the caller CONTINUED, not a subagent to the role chain. A fork always runs on the
99
151
  * caller's model (the caller's current `Model`, else the delegation tool's own `model`), and it carries NO
@@ -103,6 +155,6 @@ export declare function childModelSeat(explicit: ModelRef | undefined, deploymen
103
155
  * stated for its workers). No caller model readable (only outside a Runner-enriched ctx) ⇒ an empty seat: the
104
156
  * role chain's `default` start, exactly what a caller run with no `modelRole` of its own resolves.
105
157
  */
106
- export declare function forkModelSeat(callerModel: ModelRef | undefined): {
158
+ export declare function forkModelSeat(callerModel: ModelRef | undefined, inheritedRef?: ModelRef, catalog?: Record<string, Model>): ChildModelSeatPick<{
107
159
  model?: ModelRef;
108
- };
160
+ }>;
@@ -1,4 +1,4 @@
1
- import { resolveTaskModel } from "../core/roles.js";
1
+ import { resolveModel, resolveTaskModel } from "../core/roles.js";
2
2
  export function runnerRoleMap(runner) {
3
3
  try {
4
4
  return runner?.agentCatalog?.roles;
@@ -39,12 +39,38 @@ export function childThinkingSeat(explicit, deploymentStated, parent) {
39
39
  return level !== undefined ? { thinking: level } : {};
40
40
  }
41
41
  export function deploymentSubagentTierModel(deps) {
42
- return deps !== undefined && deploymentStatesSubagentModel(deps.roles) ? resolveTaskModel({ modelRole: "subagent" }, deps).model : undefined;
42
+ if (deps === undefined || !deploymentStatesSubagentModel(deps.roles))
43
+ return undefined;
44
+ const resolved = resolveTaskModel({ modelRole: "subagent" }, deps);
45
+ return { model: resolved.model, ...(inheritableRef(resolved.modelRef, resolved.model, deps.models) !== undefined ? { modelRef: resolved.modelRef } : {}) };
46
+ }
47
+ export function inheritedParentModel(parent, catalog) {
48
+ return typeof parent === "string" ? resolveModel(parent, catalog) : parent;
49
+ }
50
+ function inheritableRef(word, seated, catalog) {
51
+ if (typeof word !== "string")
52
+ return undefined;
53
+ try {
54
+ return resolveModel(word, catalog) === seated ? word : undefined;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
43
59
  }
44
- export function childModelSeat(explicit, deploymentStated, parent) {
45
- const model = explicit ?? (deploymentStated ? undefined : parent);
46
- return { modelRole: "subagent", ...(model !== undefined ? { model } : {}) };
60
+ export function childModelSeat(explicit, deploymentStated, parent, catalog, explicitRef) {
61
+ const inheritArm = explicit === undefined && !deploymentStated;
62
+ const model = explicit ?? (inheritArm ? inheritedParentModel(parent, catalog) : undefined);
63
+ const word = inheritArm ? parent : typeof explicit === "string" ? undefined : explicitRef;
64
+ const modelRef = model !== undefined && typeof model !== "string" ? inheritableRef(word, model, catalog) : undefined;
65
+ return {
66
+ seat: { modelRole: "subagent", ...(model !== undefined ? { model } : {}) },
67
+ ...(modelRef !== undefined ? { modelRef } : {}),
68
+ };
47
69
  }
48
- export function forkModelSeat(callerModel) {
49
- return callerModel !== undefined ? { model: callerModel } : {};
70
+ export function forkModelSeat(callerModel, inheritedRef, catalog) {
71
+ const modelRef = callerModel !== undefined && typeof callerModel !== "string" ? inheritableRef(inheritedRef, callerModel, catalog) : undefined;
72
+ return {
73
+ seat: callerModel !== undefined ? { model: callerModel } : {},
74
+ ...(modelRef !== undefined ? { modelRef } : {}),
75
+ };
50
76
  }