@sema-agent/core 7.31.1 → 7.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/agents/inherited-tool-pool.d.ts +28 -0
  3. package/dist/agents/inherited-tool-pool.js +15 -0
  4. package/dist/agents/list-agents-tool.d.ts +7 -0
  5. package/dist/agents/list-agents-tool.js +9 -2
  6. package/dist/agents/peer-session-drain.d.ts +2 -0
  7. package/dist/agents/peer-session-drain.js +1 -0
  8. package/dist/agents/send-message-tool.d.ts +17 -0
  9. package/dist/agents/send-message-tool.js +11 -4
  10. package/dist/agents/subagent.d.ts +10 -1
  11. package/dist/agents/subagent.js +24 -8
  12. package/dist/core/ask-origin.d.ts +15 -6
  13. package/dist/core/ask-origin.js +15 -9
  14. package/dist/core/background-shell.d.ts +45 -0
  15. package/dist/core/background-shell.js +13 -0
  16. package/dist/core/checkpoint-store.d.ts +5 -0
  17. package/dist/core/checkpoint-store.js +3 -2
  18. package/dist/core/engine-notice.d.ts +9 -0
  19. package/dist/core/gate-lanes.js +1 -1
  20. package/dist/core/governance-codes.d.ts +1 -1
  21. package/dist/core/governance-codes.js +4 -0
  22. package/dist/core/hooks.d.ts +2 -0
  23. package/dist/core/read-root-grant-screen.d.ts +23 -0
  24. package/dist/core/read-root-grant-screen.js +17 -0
  25. package/dist/core/runner/contracts.d.ts +1 -1
  26. package/dist/core/runner/prepare-artifact.d.ts +12 -7
  27. package/dist/core/runner/prepare-artifact.js +10 -8
  28. package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
  29. package/dist/core/runner/prepare-ask-lane.js +5 -1
  30. package/dist/core/runner/prepare-boundary-parks.js +5 -5
  31. package/dist/core/runner/prepare-delegation-surface.d.ts +18 -0
  32. package/dist/core/runner/prepare-delegation-surface.js +6 -25
  33. package/dist/core/runner/prepare-hands-readface.d.ts +12 -89
  34. package/dist/core/runner/prepare-hands-readface.js +14 -48
  35. package/dist/core/runner/prepare-inherited-gate.d.ts +3 -1
  36. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  37. package/dist/core/runner/prepare-park-ask.js +6 -4
  38. package/dist/core/runner/prepare-policy-chain.js +4 -0
  39. package/dist/core/runner/prepare-task.js +5 -3
  40. package/dist/core/runner/read-root-grant.d.ts +66 -0
  41. package/dist/core/runner/read-root-grant.js +77 -0
  42. package/dist/core/runner/run-notification-lane.d.ts +1 -1
  43. package/dist/core/runner/run-settle-and-teardown.js +3 -9
  44. package/dist/core/runner/runtask.js +1 -1
  45. package/dist/core/runner/send-message-continuation.d.ts +45 -0
  46. package/dist/core/runner/send-message-continuation.js +27 -0
  47. package/dist/core/runner/session-read-file-states.d.ts +91 -0
  48. package/dist/core/runner/session-read-file-states.js +43 -0
  49. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
  50. package/dist/core/store-contracts/checkpoint-store-contract.js +19 -0
  51. package/dist/core/task-registry-monitor.js +3 -0
  52. package/dist/core/task-registry-shared.d.ts +16 -3
  53. package/dist/core/task-registry.d.ts +65 -29
  54. package/dist/core/task-registry.js +78 -28
  55. package/dist/core/task-spec.d.ts +5 -3
  56. package/dist/core/tool-policy.d.ts +23 -0
  57. package/dist/core/tool-policy.js +15 -4
  58. package/dist/core/tool-spec.d.ts +2 -0
  59. package/dist/engine/execution-env/node-execution-env.d.ts +13 -3
  60. package/dist/engine/execution-env/node-execution-env.js +26 -0
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.js +2 -1
  63. package/dist/scenarios/full-body.d.ts +5 -2
  64. package/dist/scenarios/full-body.js +14 -7
  65. package/dist/tools/fs/fs-bash-background-text.d.ts +16 -0
  66. package/dist/tools/fs/fs-bash-background-text.js +22 -0
  67. package/dist/tools/fs/fs-bash.d.ts +6 -0
  68. package/dist/tools/fs/fs-bash.js +9 -27
  69. package/dist/tools/fs/fs-read.d.ts +2 -2
  70. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  71. package/dist/tools/fs/index.d.ts +8 -1
  72. package/dist/tools/fs/index.js +2 -3
  73. package/dist/tools/fs/repo-map.d.ts +2 -1
  74. package/dist/tools/fs/safety.d.ts +3 -1
  75. package/dist/tools/fs/safety.js +2 -1
  76. package/dist/tools/fs/shell-read-check.d.ts +3 -0
  77. package/dist/tools/fs/shell-read-check.js +1 -1
  78. package/dist/tools/fs/shell-read-establish.d.ts +3 -1
  79. package/dist/tools/fs/shell-read-establish.js +2 -2
  80. package/dist/tools/monitor.d.ts +5 -4
  81. package/dist/tools/monitor.js +5 -4
  82. package/dist/tools/todo.d.ts +3 -3
  83. package/dist/tools/todo.js +5 -4
  84. package/package.json +1 -1
  85. package/test/export-surface.snapshot.json +13 -1
  86. package/wire-changes/core@7.32.0.json +850 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.32.0 — 2026-09-25
4
+
5
+ ### The ask names WHICH mandate it stands on — `AskRequest.mandate` + `PendingAction.tool_approval.mandate` + `CheckpointSummary.mandate` (#1092; additive; +3 exports `PERSISTED_RULE_MANDATES` / `PersistedRuleMandate` / `isPersistedRuleMandate`; +4 optional type keys (`AskRequest`, the `PermissionResult` ask arm, the park row, the summary); no checkpoint-version bump) (@cli @client-core @server)
6
+
7
+ - **Who needs it.** A surface that treats mandated cards differently by their reason: `mandated: true` alone could not tell a read outside the session's directories (`probe_mandate`) from an org demand, a hook's screen or a protected write.
8
+ - **What it is.** One word of the closed six-word table (`probe_mandate` = a read outside the session's directories, `probe_unanswered`, `operator_always`, `tool_marks`, `write_protection`, `write_protection_unresolved`), present ⇔ that word is the `mandated` bit's WHOLE reason. Any other door — governance (`requiresRealApproval`), a hook's ask, an ancestor's approver, a policy's own `mandated` claim — leaves it ABSENT beside `mandated: true`, also when a table word holds too: absence names no word. One derivation (`ruleClearanceOf`'s fourth reading), stamped by the gate beside the bit (a policy's self-declared word is dropped), spread by the four mint stations through `askMandateMember`; the park row carries the same word (a stored-shape re-judgment that adds its own mandate drops it), the summary echoes it screened to the closed set.
9
+ - **Who is hurt / compensation.** Nobody: `mandated` and `ruleOffersAbsence` read exactly as before (pinned card by card), the gate decides nothing on the word, and a consumer that ignores it renders what it rendered. A consumer that pins the card's exact key list sees one more key. **Read-deny note:** the built-in read-deny tiers default to NONE (`READ_DENY_DEFAULT_TIERS = []`), so on an unconfigured deployment a read of e.g. `~/.claude/settings.json` outside the roots is an ordinary out-of-root card carrying `mandate: "probe_mandate"`, not a refusal; the word does not distinguish a credential-looking path — the deny tiers do (`readDenyBuiltinTiers`).
10
+ - Pins: `test/backlog1092-mandate-word.test.ts`. Wire sheet: `docs/wire-consumption/unreleased-1092.md`.
11
+
12
+ ### "Yes, and add this directory" takes effect in the SAME run — `AskOutcome.readRootGrant` (#1093; additive; +1 optional outcome member; +2 `ENGINE_NOTICE_CODES` members `approval.read_root_granted` / `approval.read_root_grant_rejected`; +1 export `ReadRootGrantScreen`; +1 optional `ResolvedAsk` member) (@cli @client-core @server)
13
+
14
+ - **Who needs it.** The person on an out-of-root read card: it already named the one directory that would clear it (`readRootCandidate`), but acting on that meant declaring it for a LATER run; the run that asked kept asking about every read of that directory.
15
+ - **What it does.** An approver answers `{ allow: true, readRootGrant: <the card's readRootCandidate.dir, byte for byte> }`: this run's READ directories include it from then on — the shell read boundary (the read-only shell face too) and Read / Grep / Glob / RepoMap read one live set (never a copy); `covers: "exact"` adds that one path. The write tools' roots are never touched. Announced once, `approval.read_root_granted` (`detail: { dir, covers?, root, toolName, toolCallId, sessionId, runId, taskId? }`, audience user).
16
+ - **Bad values are loud.** No candidate on the card, another string, a non-string, a grant riding a refusal, a directory the environment can no longer resolve (or whose lookup outlives the call — bounded like the gate's other awaits and cut with it), an offered path that no longer resolves to itself (swapped for a link while the card waited), or a leg with no read face: nothing changes, the verdict the grant rode stands, and `approval.read_root_grant_rejected` says why (`reason` ∈ `on_refusal` | `not_a_string` | `no_candidate` | `mismatch` | `unresolvable` | `resolves_elsewhere` | `admits_artifact_store` | `no_read_roots`, with `offered` / bounded `requested`). The grant is screened against the engine's own copy of the card's offer, so an approver rewriting the object it was shown moves nothing.
17
+ - **Roots are identities.** The Runner's shell read station no longer re-resolves its roots when it builds an establisher for a root set: a root swapped for a link after it was declared or granted brings in nothing (this closes the same hole for a DECLARED `additionalReadDirectories` / `additionalDirectories` root swapped before the run's first shell judgment). The Artifact tool's local-file reader reads the live set too (a granted directory's file publishes in the same run). A policy's `createApprovalPolicy` `approve` seat has no card and refuses the member (fail-closed deny naming it).
18
+ - **The grant never admits the artifact store.** A granted directory that contains the wired local artifact store (`capabilities.storageDir`), lies inside it or is it widens nothing: `approval.read_root_grant_rejected` with `reason: "admits_artifact_store"` — otherwise Read reached the stub's `ledger.json` and every stored file, including what earlier runs published into a reused store. It is the judgment the Artifact mount already made (`rootAdmittingArtifactStore`, one rule at both admission points), now two-way at both — behaviour tightening: a declared root INSIDE the store (e.g. `additionalReadDirectories: [<store>/sub]`) now refuses the Artifact mount (`config.artifact_host_invalid`), where before only a store inside a root did (KL-1093e closed).
19
+ - **Who is hurt / compensation.** Nobody without the member (pinned: two reads, two cards, zero notices, as before). The grant lasts for THIS run: the next run, a durable resume and children spawned afterwards start from the declared `additionalReadDirectories` (KL-1093a–c) — keeping the directory is the host's write of that seat. The gate's judgment of the asking call is unchanged.
20
+ - Pins: `test/backlog1093-read-root-grant.test.ts`, `test/backlog1093-grant-store-exclusion.test.ts`. Wire sheet: `docs/wire-consumption/unreleased-1093.md`.
21
+
22
+ ### Fix — one retain predicate for background processes; a synchronous exit-boundary hard face (#1107; behavior: the session reap no longer settles a retain-declared row without killing it; +2 type exports; +1 optional env seat; @cli @server)
23
+
24
+ - **Before.** Three places answered "is this background process kept alive" three ways. The Bash tool's description and launch receipt read only the env capability, so a task-level `TaskSpec.retainBackgroundProcesses` process was told "background processes do NOT survive the session" and then outlived it. The run teardown read spec OR env. The session reap read the row's mark, then the env capability again; for a retain-declared env row that no teardown had marked yet, it settled the row `killed` but left the process running. The only exit face was `reapAllSessionBackground()`, whose kills go through the env's `killBackground`. When that is a ladder (TERM now, KILL after a timer), a host exiting in the same tick never runs the KILL, and a shell that ignores SIGTERM survives with ppid 1.
25
+ - **One predicate.** `retainsBackgroundProcesses(lanes)` (`env` / `task` / row `marked`; `envDying` overrides): any declared lane retains. A shell-lane row (`background_bash`, `monitor`) is stamped `retained` at registration from the env lane and the launching task's lane (new optional `retainBackgroundProcesses` on `RegisterBackgroundBashInput` / `RegisterMonitorInput`). Passing the lane at registration (true or false) states it, and the Runner's Bash and Monitor always do. A teardown's own spec lane re-judges only rows whose launcher did not state one, such as a host tool registering directly. Every face calls it: Bash description and receipts, the Monitor receipt, teardown settle and dispose, the park-only sweeps (now one helper that also spares rows already retained on the env), the session reap, the hard face, and `list()`. The Runner builds the hands band per run, so the spec lane is known when Bash is mounted: new `HandsToolkitOptions.retainBackgroundProcesses`, which the Runner fills from the spec. A retained process is never described as not surviving.
26
+ - **Session reap.** A retained row is left alone (row, watcher, process). Every other session shell row is killed and settled. The settle-without-kill outcome is gone.
27
+ - **Hard face.** `TaskRegistry.killAllBackgroundShellsNow({ includeRetained, reason? })`: synchronous, idempotent, no default for `includeRetained`.
28
+ - **Rows.** It visits every shell row except `completed` — running rows, rows a softer stop settled `killed`, and `failed` rows — session-resident or not. A retained row is `retained_skipped` under `includeRetained: false` unless an explicit stop already settled it `killed`.
29
+ - **Escaped descendants.** A descendant that left the shell's group (its own session) is outside the seat. The session reap's ordinary kill walks the tree, so a session-resident row keeps that coverage. A row only the hard face reaches does not: a non-persistent Monitor, or a session-less direct mount. This is a residual, recorded, of never signalling an unproven pid.
30
+ - **Kill.** Each goes through the new optional seat `BackgroundShellCapability.killBackgroundNow(shellId)`: a group SIGKILL with no TERM and no timer. The seat's outcomes are `killed` / `already_gone` / `no_pgid`.
31
+ - **Receipts.** One `BackgroundShellHardKillReceipt` per row: `killed` / `already_gone` / `retained_skipped` / `no_pgid`. Only a `killed` running row is settled `killed`.
32
+ - **`NodeExecutionEnv` seat.** Judged from the child handle, never the entry status: one `kill(-pid, SIGKILL)` while the leader is unreaped, with no descendant-pid snapshot. Its own landed kill, or an observed exit, answers `already_gone`. A refused signal (EPERM) answers `no_pgid` and a later call retries. win32 has no synchronous hard kill: the seat answers `no_pgid` and the hard face dispatches the ordinary kill, the ladder with its MSYS group discovery. A same-tick exit on win32 can leave a job running.
33
+ - **No seat.** An env without the seat reads `no_pgid`, and its `killBackground` is dispatched best-effort.
34
+ - **Session-level.** An optional `sessionId` + `scope` pair narrows the visit to the rows `reapSessionBackground(sid, scope)` keys on, for a session that ends while the engine stays up: a session kill on a shared engine, or a failed worker. The host calls `reapSessionBackground(sid, scope)` first, then `killAllBackgroundShellsNow({ sessionId: sid, scope, includeRetained: false })`. A run named with its own task id is not reached. Omitted means the whole registry, as before.
35
+ - **Listing.** `list()` rows gain `retained?: true` on retained shell rows. This is the "keeps running" half of an exit listing.
36
+ - **Run teardown dispose (F10).** A run's teardown disposes only shells it can attribute to itself. The env-wide dispose used to spare three kinds: this session's resident rows, in-window Monitors, and retained rows. It now spares every registered shell row still running on the env (`TaskRegistry.runningShellIds`), any owner. The settle sweep has already killed this run's rows that end with it. Unregistered env processes are still disposed. So a synchronous delegated child that shares its parent's env no longer disposes the parent session's background shells.
37
+ - **Removed (type surface).** `TaskRegistry.sessionResidentShellIds(sessionId, env)` and `TaskRegistry.timeoutResidentShellIds(env)` are gone. They were the teardown's except-list lanes, and after F10 nothing in `src` calls them. No caller was found in the server / cli / client-core trees. Read `runningShellIds(env)` (every running shell row on the env) or `list(access)` instead.
38
+ - **Contract.** `docs/background-process-lifetime.md` covers the three lifetimes, which exit face to call when, the retain contract (rows are in-memory and gone with the engine; task-level retain on a piped env may die of a broken pipe after the host exits), and the seat contract.
39
+ - **Who is hurt.**
40
+ - **Unmarked retain-env row.** One reaped before any teardown marked it now stays running with its watch. It used to be settled `killed`, falsely, since its process stayed alive.
41
+ - **Mixed-lane session.** A row launched by the Runner's Bash or Monitor under a non-retaining run is no longer marked retained by a later retaining run's teardown, whether or not it is session-resident. It is reaped as its receipt said.
42
+ - **Task-retain wording.** A task-level retain run's Bash card and receipts drop the "do NOT survive" sentence. A task-retained daemon on an isolated env reads "Background processes are retained here" instead of "This environment declares retention".
43
+ - **Stop attribution.** `markStopSourceForOwner` skips a task-retained row from registration on. If `envDying` then kills it, the stop source is the teardown's own ("user" / "parent"), not a host's earlier mark.
44
+ - **Controlled exit mid-run.** A controlled exit in the middle of a retaining run now leaves that run's in-flight rows running. This covers rows whose launcher stated the task lane, which the Runner's Bash and Monitor always do.
45
+ - **Park sweeps.** A park-only sweep no longer reaps an earlier run's retained row on the same env.
46
+ - **Helper shells on a shared env (F10).** A synchronous helper's `run_in_background` shell is registered under the helper's own session. On 7.31.1 the parent run's teardown took it down as a side effect of the env-wide dispose. Now it outlives the parent run. The root session's end (`reapSessionBackground(root)` plus the session-level hard face) does not reach it; only the whole-registry hard face at host exit does. This applies to the SDK's static-env teardown lane and to server's session-level lane (KL-1107a; #1109 ties helper shells to the root session).
47
+ - **Pickup.** @server: implement `killBackgroundNow` on the host background driver (unimplemented ⇒ `no_pgid` for every row). Call `killAllBackgroundShellsNow` after `reapAllSessionBackground()` on the exits with no later tick. @cli: `list()`'s `retained` splits "will stop" from "keeps running".
48
+ - Pins: `test/backlog1107-retain-predicate.test.ts`, `test/backlog1107-exit-same-tick.test.ts`, `test/backlog1107-adoption-and-seat.test.ts`, `test/backlog1107-child-teardown.test.ts`; #1109 baseline (current behavior) `test/backlog1109-helper-shell-lifetime.test.ts`.
49
+
50
+ ### Fix — SendMessage / ListAgents promise continuation by the subagent transcript tier (#1108; model-facing text; +0 exports; @server)
51
+
52
+ - **Before.** With the durable revival lane wired, SendMessage said a finished agent was continuable "even across restarts", and ListAgents labelled recorded agents "agent (durable record)". With a process-local transcript store (tier `rows`), the record survived a restart but the conversation did not, and the revival answered "no transcript found".
53
+ - **Now.** Both faces read one helper, `sendMessageContinuationTier`, which is `resolveSubagentTranscriptTier` over the runner's session store, gated on all three revival seats: row store, mailbox, reviver. `full` keeps the old wording. `rows` says the record outlives a restart and the conversation does not. `none` means session retention only. `createListAgentsTool` / `mountListAgents` take `continuationTier`, which the Runner fills from the same seats as its SendMessage. A direct mount without it gets the `none` wording. `createSendMessageTool` now reads the session store's subagent placement declaration at construction, and a junk declaration throws `config.store_durability_invalid`.
54
+ - Pins: `test/backlog1108-continuation-tier.test.ts`, `test/backlog1108-listagents-tier-mount.test.ts`.
55
+
56
+ ### Fix — an Agent child inherits the leader's scenario-layer tools: the task-list family on the leader's one list, WebFetch, WebSearch, TodoWrite (#1110; behavior: widening for children, narrowed to what they inherit — a teammate's allowlist is widened for the inherited task-list family only, never for a host-composed pool; `SubagentToolOptions` +1 optional `inheritedTools`, `ToolExecuteContext` +1 optional Runner-filled `mountsCallerTool`; +0 exports; @server @cli @test)
57
+
58
+ - **Before.** `assembleCodeTools` put the task-list family (and WebFetch / WebSearch / TodoWrite) in the leader's list only, and handed its Agent tool nothing (`childPool = opts.tools ?? []`). A host that does not pass `subagent.tools` — the server's default and code scenarios — gave every child an empty pool: a teammate's `TaskList` / `TaskCreate` answered "Tool TaskList not found", so a team could not share its task list, and no child could fetch a URL.
59
+ - **One default, two spawn-time laws.** With no explicit `subagent.tools`, the Agent tool's pool is the bundle's surface as the SAME instances the leader mounts (`SubagentToolOptions.inheritedTools`, filled by `assembleCodeTools`): the task-list family on the bundle's one `TaskListStore`, so a teammate's task shows in the leader's `TaskList` and the other way round. (1) ⊆ parent: an entry reaches a child only when the spawning run mounts THAT instance — `ToolExecuteContext.mountsCallerTool`, identity membership in the run's `spec.tools` as the caller handed them, before the model gate. A host that removes a tool from the leader's `spec.tools` (the server drops WebFetch on a multi-tenant deployment) removes it from every child; a same-named replacement (a host's own tool, a restricted tool injected per spawn through `extraTools`) never re-admits the bundle's original, at any depth. A delegation tool driven outside a Runner (no seat) inherits nothing. (2) Spawn tier (upstream 2.1.280): a synchronous child and a named teammate get the whole surface; an anonymous background child gets WebFetch / WebSearch / TodoWrite but not the task-list family. Model-gate stamps travel with the instances, so each child's own prepare trims the family for a gate-table child model and keeps it for any other, whatever the leader's model.
60
+ - **Teammate definitions.** A named child whose agent definition (or the tool-level) `allowTools` is a real allowlist also keeps the task-list family it INHERITED (upstream teammate shape: the root's own first-party tools). A pool the host composed — an explicit `subagent.tools`, an `extraTools` product — is judged by the allowlist as written, named child or not: a model-chosen name never lifts a host's restriction (upstream widens only for an in-process teammate under the agent-teams switch, over the root's own tools; sema has no such switch and reads any spawn `name` as a teammate — an accepted divergence (design/969) — so the widening is confined to the inherited surface, where it keeps the upstream shape). A `denyTools` entry still removes it.
61
+ - **TodoWrite** (the `taskList: false` arm) keeps one list per calling session instead of one per instance, so a child's write never replaces the leader's plan. A call outside a Runner keeps one list, as before.
62
+ - **Precedence and what stays as it was.** An explicit `subagent.tools` is used verbatim, with no intersection and no tier trim: the one channel through which a host hands a child a tool its parent does not mount (`[]` = children inherit nothing). A per-spawn `extraTools` product still loses to an explicit `tools` entry of the same name, and now REPLACES a same-named inherited entry (explicit > injected > inherited); when the factory does not evaluate (it throws, or the spawn tree is over its call budget) the spawn inherits nothing, since the missing product may have been an override, and the existing note says so. A fork gets the inherited surface untrimmed (upstream runs a fork on the parent's exact tools); it had an empty pool before. The allow/deny subset law and the nested delegation tool (depth) are unchanged. A host's copy of a bundle tool (`{ ...tool, … }`) is another instance: the child does not inherit it. Host tools composed beside the bundle are not inherited: inheriting a host's whole `spec.tools` would carry host tools past the host's own per-spawn gates (a file-send tool gated per spawn, an environment selector).
63
+ - **Who is affected.** Children of `assembleCodeTools` hosts get more tools: they can call WebFetch / WebSearch, and a teammate or synchronous child can read and write the leader's task list. A child's delegation result can now carry fetched web content into the session: the memory content-origin face reads the whole inherited surface (a superset of what a given spawn is handed, so it can mark a delegation external when the leader does not mount WebFetch). A child that parks on an inherited tool's durable approval cannot be redeemed with `allow` through a host leg that drives the Agent tool without the seat: the drive refuses before it consumes anything (the approval stays pending, the refusal names the missing seat, and `deny` still resolves it); an unreadable checkpoint at that check refuses the same way. See the pickup note. `tool_end` shapes and card vocabulary are unchanged.
64
+ - **Pickup.** @server: nothing to change for the inherited family, WebFetch and WebSearch on the run lanes. The host's `Now` tool is not in the bundle: add `nowTool()` to the `subagentExtraTools` factory to hand it to children. The parked-revive leg (`/decide` → the Agent tool's execute) drives the tool directly with no Runner seat, so a teammate revived there inherits nothing, and an `allow` on an inherited tool is refused as above. Do NOT fill the seat from the boot-time bundle the revive tool comes from: that bundle is built with no session, so its task-list store is one private list, and filling the seat from it would make every revived teammate, across sessions and tenants, share that one list. The per-row fix (resolve the scenario for the row's root session, then pass `mountsCallerTool` over that list) is the ticket filed with this change. @cli: nothing to change. @test: A-arm shape (server-shaped assembly) for a teammate's `TaskList` vs the leader's, per tier.
65
+ - Pins: `test/backlog1110-agent-child-inherits-scenario-surface.test.ts`.
66
+
3
67
  ## 7.31.1 — 2026-09-25
4
68
 
5
69
  ### Fix — a credential slot in the host shell's `SEMA_` namespace is withheld from spawned children (#1099; security; `SecretEnvFindingKind` +1 member `namespace-rule`; +0 exports; @cli @server)
@@ -0,0 +1,28 @@
1
+ import type { ToolSpec } from "../core/types.js";
2
+ /**
3
+ * What a delegated child inherits when its delegation tool has no explicit pool (`SubagentToolOptions.inheritedTools`):
4
+ * the parent surface, narrowed at spawn by two laws. The mint of the parent bound ({@link callerToolMembership}) and both
5
+ * reads live here, so the law has one home.
6
+ */
7
+ /** The team task-list family: the pool-borne members of the upstream teammate exception set (the rest of that set,
8
+ * SendMessage and the Cron trio, is mounted by a child's own prepare and never rides a delegation pool). */
9
+ export declare const TEAM_TASK_LIST_TOOL_NAMES: readonly string[];
10
+ /** The run's `mountsCallerTool` seat: membership BY IDENTITY in `spec.tools` as the caller handed it (a copy of the
11
+ * array taken now; nothing is read off the entries, so no entry can make the mint throw). */
12
+ export declare function callerToolMembership(tools: readonly ToolSpec[] | undefined): (tool: ToolSpec) => boolean;
13
+ /**
14
+ * The inherited pool for one spawn.
15
+ * · ⊆ parent: an entry survives only when the spawning run mounts THAT instance — a same-named replacement (a host's
16
+ * own tool, a per-spawn injection) never re-admits the surface's original. No seat (the tool is driven outside a
17
+ * Runner) = no parent bound is known = nothing is inherited; a host driving the tool directly passes the seat itself.
18
+ * · tier (upstream `filterToolsForAgent`): an anonymous background child gets the async allowlist there — WebFetch /
19
+ * WebSearch / TodoWrite, not the task-list family. A synchronous child, a named teammate and a fork keep it.
20
+ */
21
+ export declare function inheritedChildPool(surface: readonly ToolSpec[], mountsCallerTool: ((tool: ToolSpec) => boolean) | undefined, anonymousBackground: boolean): ToolSpec[];
22
+ /**
23
+ * The task-list members of a named child's INHERITED pool that stand past its allowlist (upstream: a teammate built from a
24
+ * custom agent type gets `[...type.tools, SendMessage, ...the four when the root has them]` — the root's own first-party
25
+ * tools). Only what the child inherited is widened: a pool the host composed (`tools`, `extraTools`) is judged by the
26
+ * allowlist as written, since a model-chosen name must not lift a host's restriction. A deny still wins.
27
+ */
28
+ export declare function teammateInheritedFamily(inherited: readonly ToolSpec[], denyTools: ReadonlyArray<string> | undefined): ToolSpec[];
@@ -0,0 +1,15 @@
1
+ import { resolveToolSubset } from "./tool-filter.js";
2
+ import { TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME, TASK_LIST_TOOL_NAME, TASK_UPDATE_TOOL_NAME } from "../core/tool-catalog-entries.js";
3
+ export const TEAM_TASK_LIST_TOOL_NAMES = Object.freeze([TASK_CREATE_TOOL_NAME, TASK_GET_TOOL_NAME, TASK_LIST_TOOL_NAME, TASK_UPDATE_TOOL_NAME]);
4
+ export function callerToolMembership(tools) {
5
+ const mounted = new Set(Array.isArray(tools) ? tools : []);
6
+ return (tool) => mounted.has(tool);
7
+ }
8
+ export function inheritedChildPool(surface, mountsCallerTool, anonymousBackground) {
9
+ if (mountsCallerTool === undefined)
10
+ return [];
11
+ return surface.filter((t) => mountsCallerTool(t) && !(anonymousBackground && TEAM_TASK_LIST_TOOL_NAMES.includes(t.name)));
12
+ }
13
+ export function teammateInheritedFamily(inherited, denyTools) {
14
+ return resolveToolSubset(inherited.filter((t) => TEAM_TASK_LIST_TOOL_NAMES.includes(t.name)), undefined, denyTools);
15
+ }
@@ -2,6 +2,7 @@ import { Type } from "typebox";
2
2
  import type { TaskAccess } from "../core/task-registry.js";
3
3
  import { type RosterEntry } from "./roster-store.js";
4
4
  import { type PeerDirectory, type PeerPresence, type PeerSessionRecord } from "./peer-directory.js";
5
+ import type { SubagentTranscriptTier } from "../core/wiring-manifest.js";
5
6
  export declare const LIST_AGENTS_TOOL_NAME = "ListAgents";
6
7
  export declare const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
7
8
  /** CC `maxResultSizeChars` for this tool. */
@@ -55,6 +56,12 @@ export interface ListAgentsToolOptions {
55
56
  };
56
57
  /** The resolved freshness window ({@link isFreshPeerRow}); absent ⇒ {@link PEER_LISTING_FRESH_WINDOW_MS}. */
57
58
  freshWindowMs?: number;
59
+ /** The continuation reach of the SendMessage mounted beside this tool (`sendMessageContinuationTier` —
60
+ * the same answer from the same seats, so the two faces cannot disagree): what a recorded agent's label
61
+ * may promise. `full` = the record and the conversation survive a restart; `rows` = the record survives,
62
+ * the conversation does not; `none` = only the recorded name. Absent (a direct mount that did not say) ⇒
63
+ * the `none` wording — a label never promises more than the mount can show. */
64
+ continuationTier?: SubagentTranscriptTier;
58
65
  /** The clock (tests). */
59
66
  now?: () => number;
60
67
  }
@@ -149,15 +149,22 @@ export function createListAgentsTool(opts) {
149
149
  catch {
150
150
  }
151
151
  }
152
+ const tier = opts.continuationTier ?? "none";
153
+ const rosterLabel = tier === "full" ? "agent (durable record)" : tier === "rows" ? "agent (record kept; conversation not kept across restarts)" : "agent (recorded name)";
154
+ const finishedReach = tier === "full"
155
+ ? "while its session is retained or a durable record exists"
156
+ : tier === "rows"
157
+ ? "while its session is retained or this engine process still holds its conversation"
158
+ : "while its session is retained";
152
159
  const lines = rows.map((r) => {
153
160
  const label = r.kind === "peer_session" ? formatPeerNameRef(inlineUntrusted(r.name, 64), r.ref) : inlineUntrusted(r.name, 64);
154
161
  const age = r.kind !== "peer_session" ? undefined : r.lastActiveAt !== undefined ? `active ${formatAgo(now - r.lastActiveAt)} ago` : r.offlineAt !== undefined ? `offline ${formatAgo(now - r.offlineAt)} ago` : "offline";
155
- const cols = [label, r.state, age, r.cwd !== undefined ? inlineUntrusted(r.cwd, 64) : undefined, r.presence !== undefined ? renderPresence(r.presence) : undefined, r.kind === "peer_session" ? "peer session" : r.kind === "roster" ? "agent (durable record)" : "agent", r.address];
162
+ const cols = [label, r.state, age, r.cwd !== undefined ? inlineUntrusted(r.cwd, 64) : undefined, r.presence !== undefined ? renderPresence(r.presence) : undefined, r.kind === "peer_session" ? "peer session" : r.kind === "roster" ? rosterLabel : "agent", r.address];
156
163
  return cols.filter((c) => c !== undefined).join(" · ");
157
164
  });
158
165
  const finishedCount = rows.filter((r) => r.state === "finished").length;
159
166
  const header = rows.length === 0 ? "No agents or peer sessions are listed right now." : `${rows.length} listed:`;
160
- const finishedNote = finishedCount > 0 ? `\n(${finishedCount} finished agent(s): continuable by SendMessage only while its session is retained or a durable record exists — otherwise SendMessage answers with an honest refusal)` : "";
167
+ const finishedNote = finishedCount > 0 ? `\n(${finishedCount} finished agent(s): continuable by SendMessage only ${finishedReach} — otherwise SendMessage answers with an honest refusal)` : "";
161
168
  const fault = (directoryFault !== undefined ? `\n(peer-session directory unreadable: ${inlineUntrusted(directoryFault, 200)} — peer rows may be missing)` : "") +
162
169
  (registryCapped ? "\n(the live task list is at its 500-row window: older background agents may be missing from this listing — address them by task_id)" : "");
163
170
  let content = `${header}${lines.length > 0 ? `\n${lines.join("\n")}` : ""}${finishedNote}${fault}`;
@@ -237,6 +237,8 @@ export declare function mountListAgents(args: {
237
237
  parentSessionId?: string;
238
238
  registry: ListAgentsToolRegistry;
239
239
  roster?: NonNullable<Parameters<typeof createListAgentsTool>[0]["roster"]>;
240
+ /** The continuation reach of the SendMessage beside it — what a recorded agent's label may promise (see the tool option). */
241
+ continuationTier?: NonNullable<Parameters<typeof createListAgentsTool>[0]["continuationTier"]>;
240
242
  specTools: ReadonlyArray<{
241
243
  name: string;
242
244
  aliases?: readonly string[];
@@ -698,6 +698,7 @@ export function mountListAgents(args) {
698
698
  access: { owner: args.hostTaskId, scope: args.scope, sessionId: args.sessionId },
699
699
  ...(args.parentTaskId !== undefined ? { parentAccess: { owner: args.parentTaskId, scope: args.scope, ...(args.parentSessionId !== undefined ? { sessionId: args.parentSessionId } : {}) } } : {}),
700
700
  ...(args.roster !== undefined ? { roster: args.roster } : {}),
701
+ ...(args.continuationTier !== undefined ? { continuationTier: args.continuationTier } : {}),
701
702
  });
702
703
  }
703
704
  export function bindPeerSessionDrain(harness, opts) {
@@ -8,6 +8,7 @@ import { type PeerDirectory } from "./peer-directory.js";
8
8
  import { SubagentRetainLedger } from "./retain-ledger.js";
9
9
  import { type SubagentSteerHandle } from "./subagent.js";
10
10
  import { type PeerLaneConfig, type PeerInboundChainRef, type PeerSelfRef } from "./peer-admission.js";
11
+ import { type SubagentTranscriptTier } from "../core/wiring-manifest.js";
11
12
  /** Model-facing name of the background-agent continuation tool (CC 2.1.2xx `SendMessage`). */
12
13
  export declare const SEND_MESSAGE_TOOL_NAME = "SendMessage";
13
14
  export interface SendMessageToolOptions {
@@ -237,6 +238,22 @@ export declare function clipSendMessageSummary(raw: string): string;
237
238
  * second revive). The auto-mode peer-referral tighten keys on the WIRE NAME and applies to a direct
238
239
  * mount all the same.
239
240
  */
241
+ /**
242
+ * The continuation promise's REACH — how far "continue a finished agent" goes on a SendMessage mount — by the
243
+ * deployment's subagent transcript tier: the ONE derivation (`resolveSubagentTranscriptTier`, the wiring
244
+ * manifest's `subagentTranscripts` cell) over the mount's own seats. Without all three tier-3 seats (row
245
+ * store, mailbox, reviver) there is no durable revival at all — session retention is the only lane (`none`).
246
+ * With them, the session store the revival runs through (`runner.sessions`) decides: `full` keeps the
247
+ * conversation across a restart, `rows` keeps the record but not the conversation (a process-local
248
+ * transcript store), so only `full` may say "across restarts". Exported so every face that describes the
249
+ * same reach (the ListAgents mount beside this tool) reads the same answer from the same seats.
250
+ */
251
+ export declare function sendMessageContinuationTier(seats: {
252
+ agentStore?: unknown;
253
+ mailbox?: unknown;
254
+ reviveSpawn?: unknown;
255
+ runner: Pick<RunnerSelfSeat, "sessions">;
256
+ }): SubagentTranscriptTier;
240
257
  export declare function createSendMessageTool(opts: SendMessageToolOptions): import("../internal/harness.js").AgentTool<Type.TObject<{
241
258
  to: Type.TString;
242
259
  message: Type.TString;
@@ -18,6 +18,7 @@ import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerLaneConfi
18
18
  import { toolFace } from "../core/tool-catalog-entries.js";
19
19
  import { thrownText } from "../core/thrown-value.js";
20
20
  import { assertNoRetiredKeys } from "../core/retired-keys.js";
21
+ import { resolveSubagentTranscriptTier } from "../core/wiring-manifest.js";
21
22
  export const SEND_MESSAGE_TOOL_NAME = "SendMessage";
22
23
  let uplinkSeqGlobal = Date.now();
23
24
  const UPLINK_RESULT_MAX = 8000;
@@ -60,9 +61,13 @@ function targetLaneKey(scope, targetId) {
60
61
  return JSON.stringify([scope ?? "", targetId]);
61
62
  }
62
63
  const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
64
+ export function sendMessageContinuationTier(seats) {
65
+ const tier3Capable = seats.agentStore !== undefined && seats.mailbox !== undefined && seats.reviveSpawn !== undefined;
66
+ return tier3Capable ? resolveSubagentTranscriptTier(true, seats.runner.sessions) : "none";
67
+ }
63
68
  export function createSendMessageTool(opts) {
64
69
  assertNoRetiredSendMessageOptions(opts);
65
- const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
70
+ const continuationTier = sendMessageContinuationTier(opts);
66
71
  const notifyWhenIdleParameter = {
67
72
  notify_when_idle: Type.Optional(Type.Boolean({
68
73
  description: "Ask a peer SESSION of this engine to send you ONE notice when it next goes idle (finishes its turn with nothing queued) or exits — opt-in, one-shot, no polling. With a message: deliver it now AND subscribe. Without a message (omit it): a pure subscription that costs the other session nothing. Peer sessions only (not agents or teammates), and only from the main conversation.",
@@ -166,10 +171,12 @@ export function createSendMessageTool(opts) {
166
171
  : `A finished agent is NOT announced on this mount${retrievalMounted === false
167
172
  ? ", and its result cannot be collected by this run (no TaskOutput is mounted beside this tool) — do not end your turn to wait for a notification that never comes"
168
173
  : " — check on it with TaskOutput(task_id) where mounted rather than ending your turn to wait for a notification that never comes"}. `) +
169
- (tier3Capable
174
+ (continuationTier === "full"
170
175
  ? `Continuing a finished agent works for agents with a durable record — by name or task_id, even across restarts — and for runs that retain sub-agent sessions; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
171
- : `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
172
- `retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`) +
176
+ : continuationTier === "rows"
177
+ ? `Continuing a finished agent works by name or task_id while this engine process still holds its conversation, and for runs that retain sub-agent sessions — its record outlives a restart but its conversation does not, so after a restart it cannot be continued; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
178
+ : `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
179
+ `retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`) +
173
180
  (peerLane.active
174
181
  ? ` Peer SESSIONS — other sessions of this engine for the same user${opts.peerListingMounted === false ? "" : opts.peerListingMounted === true ? " (list them with ListAgents)" : " (list them with ListAgents where that tool is mounted)"} — are addressed by name, by \`name [ref]\` when a name is ambiguous, or by their \`session.<id>\` address. A message to a peer is parked durably in its session box and read at its next turn boundary (an offline peer reads it when it next runs); the receipt confirms the box accepted it, not that the peer acted on it.`
175
182
  : ""),
@@ -702,8 +702,17 @@ export interface SubagentToolOptions {
702
702
  * assembly time but still resolves at the child's own prepare through the runner's live table.
703
703
  */
704
704
  models?: Record<string, Model>;
705
- /** Tools available to the child (a deliberately narrowed subset). */
705
+ /** Tools available to the child: the deployment's EXPLICIT pool, used verbatim — it replaces {@link inheritedTools}
706
+ * and is the one channel that hands a child a tool its parent does not mount. `[]` = the child inherits nothing. */
706
707
  tools?: ToolSpec[];
708
+ /**
709
+ * The parent surface a child inherits when {@link tools} is absent (`assembleCodeTools` passes its bundle's own
710
+ * instances, so the task-list family shares the leader's store). Narrowed per spawn by `inheritedChildPool`: only
711
+ * the instances the spawning run mounts (`ToolExecuteContext.mountsCallerTool`; a host's copy of an entry is another
712
+ * instance and is not inherited), no task-list family for an anonymous background child, and a same-named `extraTools`
713
+ * product replaces the entry. Model-gate stamps travel with the entries; each child's prepare re-judges them.
714
+ */
715
+ inheritedTools?: ToolSpec[];
707
716
  /**
708
717
  * Tool-level allowlist (design/38 1A), used when NO `agent` is selected (a selected agent definition's
709
718
  * own `allowTools`/`denyTools` REPLACE these). When set, the child sees only these tools from `tools`
@@ -68,6 +68,7 @@ import { LAUNCH_RECEIPT_OWN_WORDS_CLAUSE, launchReceiptNoQuoteClause } from "./l
68
68
  import { ObserverDigestTap, ObserverPairing, createObserverReportToolSpec, markObserverTaskId, unmarkObserverTaskId, isObserverTaskId, ObserverResumeStateError, ObserverStoppedByUserError, observerFramingPrompt, observerSlug, resolveObserverDeclaration, } from "./observer.js";
69
69
  import { SubagentStepRecorder } from "./subagent-steps.js";
70
70
  import { toolFace } from "../core/tool-catalog-entries.js";
71
+ import { inheritedChildPool, teammateInheritedFamily } from "./inherited-tool-pool.js";
71
72
  import { readMember, thrownText } from "../core/thrown-value.js";
72
73
  const BG_AGENT_RESULT_MAX = 4_000;
73
74
  const BG_AGENT_RESULT_FULL_MAX = 200_000;
@@ -1390,7 +1391,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1390
1391
  ];
1391
1392
  const agentToolPool = opts.extraTools !== undefined
1392
1393
  ? undefined
1393
- : (opts.tools ?? []).map((t) => ({
1394
+ : (opts.tools ?? opts.inheritedTools ?? []).map((t) => ({
1394
1395
  name: t.name,
1395
1396
  ...(t.aliases !== undefined ? { aliases: t.aliases } : {}),
1396
1397
  ...(t.contentOrigin !== undefined
@@ -1807,7 +1808,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1807
1808
  };
1808
1809
  }
1809
1810
  }
1810
- let childPool = opts.tools ?? [];
1811
+ const inherited = opts.tools === undefined && extraToolsNote === undefined ? inheritedChildPool(opts.inheritedTools ?? [], ctx.mountsCallerTool, wantsBackground && agentName === undefined && !wantsFork) : [];
1812
+ if (extraToolsNote !== undefined && opts.tools === undefined && (opts.inheritedTools?.length ?? 0) > 0)
1813
+ extraToolsNote += " The inherited tools were withheld for this spawn as well.";
1814
+ let childPool = opts.tools ?? inherited;
1811
1815
  if (injectedTools.length > 0) {
1812
1816
  const taken = new Set();
1813
1817
  const reserve = (t) => {
@@ -1818,20 +1822,19 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1818
1822
  for (const t of opts.tools ?? [])
1819
1823
  reserve(t);
1820
1824
  taken.add(toolName);
1821
- const merged = [...(opts.tools ?? [])];
1825
+ const accepted = [];
1822
1826
  for (const t of injectedTools) {
1823
1827
  const names = [t.name, ...(t.aliases ?? [])];
1824
1828
  if (names.some((n) => taken.has(n)))
1825
1829
  continue;
1826
1830
  for (const n of names)
1827
1831
  taken.add(n);
1828
- merged.push(t);
1832
+ accepted.push(t);
1829
1833
  }
1830
- childPool = merged;
1834
+ childPool = [...(opts.tools ?? inherited.filter((t) => ![t.name, ...(t.aliases ?? [])].some((n) => taken.has(n)))), ...accepted];
1831
1835
  }
1832
- const childTools = def
1833
- ? resolveToolSubset(childPool, def.allowTools, def.denyTools)
1834
- : resolveToolSubset(childPool, opts.allowTools, opts.denyTools);
1836
+ const allowed = new Set([...resolveToolSubset(childPool, def ? def.allowTools : opts.allowTools, def ? def.denyTools : opts.denyTools), ...(agentName !== undefined ? teammateInheritedFamily(childPool.filter((t) => inherited.includes(t)), def ? def.denyTools : opts.denyTools) : [])]);
1837
+ const childTools = childPool.filter((t) => allowed.has(t));
1835
1838
  const childSystemPrompt = def?.systemPrompt ?? opts.systemPrompt;
1836
1839
  const childDefaultPersona = childSystemPrompt === undefined && !wantsFork ? SUBAGENT_PROMPT : undefined;
1837
1840
  const defMaxTurns = typeof def?.maxTurns === "number" && Number.isFinite(def.maxTurns) && def.maxTurns > 0 ? def.maxTurns : undefined;
@@ -3330,6 +3333,19 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3330
3333
  }
3331
3334
  return rollbackAndThrow(e);
3332
3335
  }
3336
+ if (opts.tools === undefined && (opts.inheritedTools?.length ?? 0) > 0 && ctx.mountsCallerTool === undefined && pr.outcome.gate === "policy_ask" && pr.outcome.decision === "allow") {
3337
+ let pending;
3338
+ try {
3339
+ pending = (await childParkStore.get(pr.ticket.token))?.pendingAction;
3340
+ }
3341
+ catch (e) {
3342
+ return rollbackAndThrow(e);
3343
+ }
3344
+ const name = pending?.kind === "tool_approval" ? pending.toolName : undefined;
3345
+ if (name !== undefined && (opts.inheritedTools ?? []).some((t) => t.name === name) && !(bgSpec.tools ?? []).some((t) => t.name === name)) {
3346
+ return rollbackAndThrow(new Error(`the approved tool "${name}" is one the agent inherits from its spawning run, and this resume was driven without the \`mountsCallerTool\` seat, so the resumed agent would not have it. The host must pass the seat built from the row's own session (never a boot-time bundle: its task list has no session and would be shared by every revived agent). Deny still resolves this approval.`));
3347
+ }
3348
+ }
3333
3349
  {
3334
3350
  let seat;
3335
3351
  try {
@@ -183,7 +183,7 @@ export declare function stampAskOrigin<T extends Extract<PermissionResult, {
183
183
  * the reason is that a RULE is already speaking, not that the question is beyond rules, and a surface
184
184
  * that auto-answers by posture is not crossing a mandate when it answers one.
185
185
  *
186
- * THREE named readings of the one pass, because three callers need three different things and taking
186
+ * FOUR named readings of the one pass, because four callers need four different things and taking
187
187
  * the wrong one is how this boundary drifts:
188
188
  * · `word` — the DISCLOSED reason, first door wins, in the display order the card has always used
189
189
  * (governance, the shadow, the hook, the ask rule, the two ancestor doors, then the marks). Display
@@ -195,12 +195,16 @@ export declare function stampAskOrigin<T extends Extract<PermissionResult, {
195
195
  * person's explicit `ask` rule, and deliberately NOT `word !== undefined`: `persistedRuleShadowed`
196
196
  * is an ACCOUNT of a refusal that already happened, display-only by contract, and reading it as a
197
197
  * blocker would let a policy forwarding that metadata refuse a call nothing is blocking. A
198
- * display word is never an authority.
198
+ * display word is never an authority;
199
+ * · `mandate` — WHICH table word the bit stands on, only when the MARKS family is its whole reason (no
200
+ * governance, hook or ancestor door, and no bit stamped without a word); absent, the bit's reason is
201
+ * not one word.
199
202
  */
200
203
  export declare function ruleClearanceOf(ask: RuleClearanceFacts | undefined, marks: Omit<MandateMarks, "probeMandated">): {
201
204
  word?: "mandated" | "shadowed";
202
205
  mandated: boolean;
203
206
  ruleMayClear: boolean;
207
+ mandate?: PersistedRuleMandate;
204
208
  };
205
209
  /** The facts one ask carries about whether a rule could clear it — the members {@link ruleClearanceOf}
206
210
  * reads beside the tool seat's marks. Every one is either engine-stamped or, where a caller can
@@ -209,6 +213,8 @@ export declare function ruleClearanceOf(ask: RuleClearanceFacts | undefined, mar
209
213
  export interface RuleClearanceFacts {
210
214
  /** The bit a gate already stamped (see {@link import("./tool-policy.js").AskRequest.mandated}). */
211
215
  mandated?: true;
216
+ /** The table word a gate stamped beside that bit (see {@link import("./tool-policy.js").AskRequest.mandate}). */
217
+ mandate?: PersistedRuleMandate;
212
218
  requiresRealApproval?: boolean;
213
219
  /** A matching persisted allow rule was found and did not clear the ask — carries its text. */
214
220
  persistedRuleShadowed?: string;
@@ -225,13 +231,14 @@ export interface RuleClearanceFacts {
225
231
  * pass it literally) — this task's rule lane never adjudicates it. */
226
232
  ancestorResolved?: boolean;
227
233
  }
228
- /** The ask FACE's mandate member — what an approval request carries, spread at the four mint stations.
229
- * The stations hand it the SAME facts object they hand the rule-offer factory, so the card's word and
230
- * this bit are two readings of one derivation and cannot drift. The tool SEAT is not re-judged here:
231
- * its marks were folded into the stamped bit at the gate, and a station minting a request from an
234
+ /** The ask FACE's mandate members — the bit and its table word, spread at the four mint stations and the
235
+ * park row. The stations hand it the SAME facts object they hand the rule-offer factory, so the card's word
236
+ * and these members are readings of one derivation and cannot drift. The tool SEAT is not re-judged here:
237
+ * its marks were folded into the stamped bit and word at the gate, and a station minting a request from an
232
238
  * ANCESTOR's decision holds no seat of this task's to judge. */
233
239
  export declare function askMandateMember(ask: RuleClearanceFacts | undefined): {
234
240
  mandated?: true;
241
+ mandate?: PersistedRuleMandate;
235
242
  };
236
243
  /** The clearance facts of an ask the engine did not construct (a policy's verdict), each member read ONCE into
237
244
  * an engine-owned value — for a station that keeps the answer past its own side effects, where the verdict's
@@ -341,6 +348,8 @@ export declare function classifierMayAnswer(origin: AskOrigin): boolean;
341
348
  */
342
349
  export declare const PERSISTED_RULE_MANDATES: readonly ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "write_protection_unresolved"];
343
350
  export type PersistedRuleMandate = (typeof PERSISTED_RULE_MANDATES)[number];
351
+ /** Membership test for {@link PersistedRuleMandate} — the screen a row reader applies to a stored word. */
352
+ export declare function isPersistedRuleMandate(v: unknown): v is PersistedRuleMandate;
344
353
  /**
345
354
  * May a LIVE automatic lane answer an ask carrying this word? (The persisted-rule lane is not among
346
355
  * them — every word binds it.) TWO COLUMNS, because the two lanes are not asking one question: the
@@ -18,19 +18,20 @@ export function askOriginOf(decision, facts) {
18
18
  const TIGHTEN_ONLY_ORIGINS = new Set(["shell_gate_tighten", "safety_tighten"]);
19
19
  export function stampAskOrigin(decision, facts) {
20
20
  const origin = askOriginOf(decision, facts);
21
- const mandated = ruleClearanceOf(decision, facts.mandateMarks).mandated && decision.mandated !== true ? { ...decision, mandated: true } : decision;
21
+ const { mandate: _selfDeclared, ...unstamped } = decision;
22
+ const clearance = ruleClearanceOf(unstamped, facts.mandateMarks);
23
+ const mandated = { ...unstamped, ...(clearance.mandated ? { mandated: true } : {}), ...(clearance.mandate !== undefined ? { mandate: clearance.mandate } : {}) };
22
24
  if (mandated.probeReadRootCandidate === undefined || TIGHTEN_ONLY_ORIGINS.has(origin))
23
25
  return { ...mandated, origin };
24
26
  const { probeReadRootCandidate: _withdrawn, ...kept } = mandated;
25
27
  return { ...kept, origin };
26
28
  }
27
29
  export function ruleClearanceOf(ask, marks) {
28
- const mandated = ask?.mandated === true ||
29
- ask?.requiresRealApproval === true ||
30
- ask?.decisionReason === "hook" ||
31
- ask?.inheritedUnresolved === true ||
32
- ask?.ancestorResolved === true ||
33
- persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true }) !== undefined;
30
+ const marksWord = persistedRuleMandateOf({ ...marks, probeMandated: ask?.probeMandated === true });
31
+ const doorOutsideTable = ask?.requiresRealApproval === true || ask?.decisionReason === "hook" || ask?.inheritedUnresolved === true || ask?.ancestorResolved === true;
32
+ const mandated = ask?.mandated === true || doorOutsideTable || marksWord !== undefined;
33
+ const stampedWord = ask?.mandated === true && isPersistedRuleMandate(ask.mandate) ? ask.mandate : undefined;
34
+ const mandate = doorOutsideTable || (ask?.mandated === true && stampedWord === undefined) ? undefined : (stampedWord ?? marksWord);
34
35
  const word = (() => {
35
36
  if (ask?.requiresRealApproval === true)
36
37
  return "mandated";
@@ -46,10 +47,11 @@ export function ruleClearanceOf(ask, marks) {
46
47
  return "mandated";
47
48
  return mandated ? "mandated" : undefined;
48
49
  })();
49
- return { ...(word !== undefined ? { word } : {}), mandated, ruleMayClear: !mandated && ask?.matchedAskRule === undefined };
50
+ return { ...(word !== undefined ? { word } : {}), mandated, ruleMayClear: !mandated && ask?.matchedAskRule === undefined, ...(mandate !== undefined ? { mandate } : {}) };
50
51
  }
51
52
  export function askMandateMember(ask) {
52
- return ruleClearanceOf(ask, {}).mandated ? { mandated: true } : {};
53
+ const { mandated, mandate } = ruleClearanceOf(ask, {});
54
+ return mandated ? { mandated: true, ...(mandate !== undefined ? { mandate } : {}) } : {};
53
55
  }
54
56
  export function ownedClearanceFacts(d) {
55
57
  const { mandated, requiresRealApproval, decisionReason, matchedAskRule, probeMandated } = d;
@@ -114,6 +116,10 @@ export function classifierMayAnswer(origin) {
114
116
  return CLASSIFIER_MAY_ANSWER[origin];
115
117
  }
116
118
  export const PERSISTED_RULE_MANDATES = ["operator_always", "tool_marks", "probe_mandate", "probe_unanswered", "write_protection", "write_protection_unresolved"];
119
+ const PERSISTED_RULE_MANDATE_SET = new Set(PERSISTED_RULE_MANDATES);
120
+ export function isPersistedRuleMandate(v) {
121
+ return PERSISTED_RULE_MANDATE_SET.has(v);
122
+ }
117
123
  const MANDATE_LANE_ANSWERS = {
118
124
  operator_always: { classifier: true, sandboxAdmission: true },
119
125
  tool_marks: { classifier: true, sandboxAdmission: true },
@@ -111,6 +111,19 @@ export interface BackgroundShellCapability {
111
111
  pollBackground(shellId: BackgroundShellId): Promise<Result<BackgroundPoll, BackgroundShellError>>;
112
112
  /** 杀一个后台进程(幂等:杀已死的是 no-op,返回 ok)。`shellId` 同 {@link pollBackground} 的越界校验。 */
113
113
  killBackground(shellId: BackgroundShellId): Promise<Result<void, BackgroundShellError>>;
114
+ /**
115
+ * OPTIONAL — the SYNCHRONOUS hard kill for an exit boundary with no later tick, where a ladder-style
116
+ * {@link killBackground} (TERM, then a timer-driven KILL) never finishes a shell that ignores SIGTERM.
117
+ * Contract (docs/background-process-lifetime.md): complete the kill before returning — POSIX one
118
+ * `kill(-pgid, SIGKILL)` over the shell's process group (an env with no synchronous hard kill, e.g. win32,
119
+ * answers `no_pgid` and the registry falls back to the ordinary kill); no TERM, no timer,
120
+ * no await; never signal a pid the env cannot prove is still its own; never throw; idempotent; the pid never
121
+ * leaves the env. Outcomes: `killed` (delivered now), `already_gone` (exited, or this seat already delivered
122
+ * its kill, or the id is not its shell any more), `no_pgid` (no synchronous kill landed — nothing host-local
123
+ * to signal, or the signal was refused; nothing claimed, retryable). Without this seat the registry's hard
124
+ * face reads `no_pgid` for every shell.
125
+ */
126
+ killBackgroundNow?(shellId: BackgroundShellId): BackgroundHardKillOutcome;
114
127
  /**
115
128
  * 杀掉并清理**本 env 的所有**后台进程。Runner 在每条退出路径调:finish/abort/throw 在 run-loop tail finally,**suspend/
116
129
  * review 必须在 `suspendVM` 之前**(design/103 §3.7;detached job 不在 suspendVM in-flight 契约内,不能指望 adapter 隐式处理)。
@@ -125,6 +138,28 @@ export interface BackgroundShellCapability {
125
138
  except?: readonly BackgroundShellId[];
126
139
  }): Promise<void>;
127
140
  }
141
+ /** The closed outcome set of {@link BackgroundShellCapability.killBackgroundNow}. */
142
+ export type BackgroundHardKillOutcome = "killed" | "already_gone" | "no_pgid";
143
+ /** The retain lanes a face can see (absent = not declared). */
144
+ export interface BackgroundRetainLanes {
145
+ /** The env's own declaration: `backgroundCapabilities.retainBackgroundProcesses`. */
146
+ readonly env?: boolean;
147
+ /** The task's declaration: `TaskSpec.retainBackgroundProcesses` of the run whose face is asking. */
148
+ readonly task?: boolean;
149
+ /** The row's `retained` mark (stamped at registration; see the registry row). */
150
+ readonly marked?: boolean;
151
+ /** The owning ISOLATED env is destroyed with this run: retention there is illusory; overrides every lane. */
152
+ readonly envDying?: boolean;
153
+ }
154
+ /**
155
+ * THE retain predicate — "is this background process kept alive past the automatic reaps?" Every face that
156
+ * answers it calls this function (Bash description + receipts, Monitor receipt, the row stamp, teardown settle
157
+ * and dispose, park-only sweeps, session reap, the exit hard face, the listing). Any declared lane retains,
158
+ * unless the owning isolated env is being destroyed. Contract: docs/background-process-lifetime.md.
159
+ */
160
+ export declare function retainsBackgroundProcesses(lanes: BackgroundRetainLanes): boolean;
161
+ /** The env lane of {@link BackgroundRetainLanes}, read off an env (false for an env without the capability). */
162
+ export declare function envDeclaresRetain(env: ExecutionEnv): boolean;
128
163
  /**
129
164
  * 结构 + 语义检测:`env` 是否暴露后台 shell 能力且声明 `supported:true`。挂载门控用此(对齐 `hasDestroy`)。
130
165
  *
@@ -156,3 +191,13 @@ export interface EnvSweepAttribution {
156
191
  export declare function sweepBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, attribution: EnvSweepAttribution | undefined, opts?: {
157
192
  except?: readonly string[];
158
193
  }): Promise<void>;
194
+ /**
195
+ * The park-only sweep (a durable park that does not pause the env), one entry point for every park lane:
196
+ * nothing is swept when this run's task or the env declares retain; otherwise every row already retained on
197
+ * this env (an earlier run's delivered service) is spared.
198
+ */
199
+ export declare function sweepParkOnlyBackgroundShells(env: ExecutionEnv & BackgroundShellCapability, registry: EnvSweepAttribution & {
200
+ retainedShellIds(env: unknown): readonly string[];
201
+ }, lanes: {
202
+ task: boolean;
203
+ }): Promise<void>;
@@ -8,6 +8,13 @@ export class BackgroundShellError extends Error {
8
8
  this.name = "BackgroundShellError";
9
9
  }
10
10
  }
11
+ export function retainsBackgroundProcesses(lanes) {
12
+ return (lanes.env === true || lanes.task === true || lanes.marked === true) && lanes.envDying !== true;
13
+ }
14
+ export function envDeclaresRetain(env) {
15
+ const caps = env.backgroundCapabilities;
16
+ return typeof caps === "object" && caps !== null && caps.retainBackgroundProcesses === true;
17
+ }
11
18
  export function hasBackgroundShell(env) {
12
19
  const e = env;
13
20
  return (typeof e.spawnBackground === "function" &&
@@ -26,3 +33,9 @@ export async function sweepBackgroundShells(env, attribution, opts) {
26
33
  }
27
34
  await env.disposeBackgroundShells(opts?.except !== undefined ? { except: opts.except } : undefined);
28
35
  }
36
+ export async function sweepParkOnlyBackgroundShells(env, registry, lanes) {
37
+ if (retainsBackgroundProcesses({ env: envDeclaresRetain(env), task: lanes.task }))
38
+ return;
39
+ const except = registry.retainedShellIds(env);
40
+ await sweepBackgroundShells(env, registry, except.length > 0 ? { except } : undefined);
41
+ }