@sema-agent/core 5.8.0 → 5.10.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 (68) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/agents/cascade.js +24 -0
  3. package/dist/agents/roster-store.d.ts +1 -0
  4. package/dist/agents/send-message-tool.js +6 -0
  5. package/dist/agents/subagent.d.ts +33 -0
  6. package/dist/agents/subagent.js +125 -33
  7. package/dist/agents/teacher.js +15 -3
  8. package/dist/agents/team.js +10 -0
  9. package/dist/agents/verify.js +7 -0
  10. package/dist/brain/anthropic.js +27 -10
  11. package/dist/brain/open-responses.d.ts +11 -0
  12. package/dist/brain/open-responses.js +736 -0
  13. package/dist/brain/openai.js +32 -5
  14. package/dist/brain/request-params.d.ts +1 -0
  15. package/dist/brain/request-params.js +16 -0
  16. package/dist/core/a2a.js +1 -1
  17. package/dist/core/fs-write-gate-policy.js +2 -2
  18. package/dist/core/lsp-diagnostics.d.ts +3 -2
  19. package/dist/core/lsp-diagnostics.js +20 -7
  20. package/dist/core/memory-recall.js +8 -3
  21. package/dist/core/memory.d.ts +5 -0
  22. package/dist/core/memory.js +6 -4
  23. package/dist/core/runner/assemble-result.d.ts +1 -0
  24. package/dist/core/runner/assemble-result.js +14 -7
  25. package/dist/core/runner/prepare-task.d.ts +9 -1
  26. package/dist/core/runner/prepare-task.js +51 -14
  27. package/dist/core/runner/runtask.d.ts +12 -0
  28. package/dist/core/runner/runtask.js +153 -42
  29. package/dist/core/runner/session-file-state-replay.d.ts +7 -0
  30. package/dist/core/runner/session-file-state-replay.js +56 -0
  31. package/dist/core/runner/session-rule-policy.d.ts +1 -0
  32. package/dist/core/runner/session-rule-policy.js +4 -3
  33. package/dist/core/runner/synthetic-tools.js +1 -1
  34. package/dist/core/runner/tool-output-projection.js +5 -4
  35. package/dist/core/session-reconcile.d.ts +7 -3
  36. package/dist/core/session-reconcile.js +3 -2
  37. package/dist/core/strategy-store.d.ts +1 -1
  38. package/dist/core/strategy-store.js +27 -4
  39. package/dist/core/task-registry-shared.d.ts +0 -1
  40. package/dist/core/tool-policy.d.ts +8 -0
  41. package/dist/core/tool-policy.js +11 -0
  42. package/dist/core/tools.js +9 -1
  43. package/dist/core/trace.d.ts +0 -2
  44. package/dist/core/types.d.ts +8 -1
  45. package/dist/engine/harness/agent-harness.d.ts +1 -0
  46. package/dist/engine/harness/agent-harness.js +3 -0
  47. package/dist/engine/harness/types.d.ts +1 -0
  48. package/dist/engine/llm/types.d.ts +2 -73
  49. package/dist/engine/loop/agent-loop.js +168 -22
  50. package/dist/engine/loop/types.d.ts +1 -0
  51. package/dist/engine/session/repo-utils.d.ts +1 -2
  52. package/dist/engine/session/repo-utils.js +0 -7
  53. package/dist/index.d.ts +3 -2
  54. package/dist/index.js +2 -1
  55. package/dist/internal/llm.d.ts +1 -1
  56. package/dist/orchestration/run-workflow-tool.js +1 -1
  57. package/dist/orchestration/workflow-governance.js +19 -0
  58. package/dist/orchestration/workflow-primitives.d.ts +1 -1
  59. package/dist/orchestration/workflow-primitives.js +4 -1
  60. package/dist/orchestration/workflow.js +15 -6
  61. package/dist/prompts/coordinator.d.ts +1 -1
  62. package/dist/prompts/coordinator.js +1 -1
  63. package/dist/stores/file/memory-store.js +3 -7
  64. package/dist/tools/fs/fs-bash.js +3 -3
  65. package/dist/tools/fs/fs-shared.d.ts +1 -0
  66. package/dist/tools/fs/fs-shared.js +4 -0
  67. package/dist/tools/web.js +20 -20
  68. package/package.json +5 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.10.0 — 2026-08-04
4
+
5
+ ### Added
6
+
7
+ - **`TaskSpec.checkpointStore` is three-valued**: `null` explicitly gives THIS run no durable store, even on a deployment that wired one (`undefined` still inherits `RunnerDeps.checkpointStore`; a store still overrides). All four legs into `status:"suspended"` (approval park / resource-slice / platform `env_lifetime`·`usage_window` / `plan_review`) are gated on a store being present, so a store-null run cannot reach `suspended` by construction and can never leave a pending checkpoint nobody resumes — the machine-started-run form (notification redelivery, maintenance sweeps). Existing no-store semantics apply: asks resolve at a live `onAsk` or fail-closed deny; limits are loud terminals; the plan/question durable faces are not mounted. Delegated children inherit it (sync/bg/fork/workflow spawn lanes). New Runner-filled `ToolExecuteContext.checkpointStoreDisabledForChildren` tells custom delegation tools to copy it. **Consumer notes**: `preempt()` becomes a documented no-op on store-null runs (existing `preempt.ignored` trace — "preemptible" is now two-valued; schedulers keep runs preemptible by leaving the field unset); a resume config carrying `null` refuses loudly (`checkpoint.not_found`) instead of silently resuming against deps.
8
+
9
+ ### Fixed
10
+
11
+ - **Eight `tool_end.structured` cards were minted and then dropped before the wire.** The projection allowlist (`details.type`) had never been reconciled against the tools that mint cards, so a host got nothing structured for: `ReportFindings` (worst case — its model face is deliberately compressed to `"N findings reported."`, so the findings reached *neither* the model *nor* the host), `ScheduleWakeup`, `SendMessage`, `AgentTranscript`, the A2A peer tools, the PDF `document` card (whose own 48K base64 budget was written against a size gate it never reached), the `bash_readonly` out-of-root refusal, and the **completed** delegation report — the one card `tool_end.structured`'s own contract names by name, while only the `async_launched` receipt could actually arrive. Registered words added: `report-findings`, `schedule-wakeup`, `send-message`, `agent-transcript`, `a2a`, `document`, `readonly_out_of_root`. Two mint sites gained the `type` discriminant they lacked (`ReportFindings`; the three `bash_readonly` refusal arms, following the `path_not_in_root` precedent) — `details.code` is untouched for existing readers. Model faces are byte-unchanged everywhere; this is purely the host face that was missing. **Consumer notes**: a shell that prose-matched any of these can now read fields; a probe asserting `structured === undefined` for them reds.
12
+
13
+ - **An interrupted tool call's synthetic `tool_end` finally carries a body — and the wake/crash leg emits one at all.** Both reconcile legs closed orphan calls in the transcript with the full `[INTERRUPTED]` explanation the model reads, while the event stream got `isError: true` and nothing else: a consumer rendering tool output from frames showed an empty result for every interrupted call, and the two faces of one call disagreed. The frames now carry `output` (the exact persisted text, same projection as every live tool result) and `errorCode` (`interrupted_never_started` | `interrupted_outcome_unknown` — the persisted `details.errorKind`, so a consumer discriminates on a code instead of prose-matching), plus the tool's display `label` and the usual `eventId`/subagent attribution. Separately, the **wake/crash** leg (a previous PROCESS died holding the calls) used to repair the transcript and tell the stream *nothing*: no `tool_end`, no `message_committed`. It now replays the same pair at run open — the only channel through which that news can ever arrive, since the process that owned those calls is gone. `ReconcileReport.recovered` entries gained `text` and `errorKind` (new exported type `RecoveredOrphan`, `ReconciledErrorKind`). **Consumer flips**: a probe pinning "the interrupted `tool_end` has no `output`" reds; a resumed run on a crashed session now emits one `tool_end` + one `message_committed` per orphan before its first turn, so an exact event-count or first-event assertion on that path reds (there is deliberately no matching `tool_start` — the start belonged to the dead process's stream).
14
+
15
+ - **The idle-park notification redelivery carries task identity.** A notification parked while the session sat idle is redelivered on the next run's stream as the same `task_notification` family — but that push shipped with no `eventId`, and for a subagent no `parentToolCallId`/`sourceTaskId`, so a consumer keying a child's frames by task silently attributed every redelivered frame to the wrong task. The cause was structural rather than a missing field: the identity minter was declared later in the run body than the redelivery drain, so the drain could not call it. It is now minted as soon as the prepared run exists. Live-lane frames are unchanged (they always had identity).
16
+
17
+ - **Read-before-write state follows the SESSION, not just the task.** An ordinary continuation turn used to start with an empty read-state (the seed lane existed only for durable-checkpoint resume), so turn 2 paid a forced re-read for every file turn 1 had read whole. The prepare step now replays the session transcript on continuation turns: whole-file-provable Read/Write records seed entries whose hash comes from what the transcript recorded — never from disk — so staleness is unchanged by construction (a file changed between turns is still refused), and Edit/NotebookEdit records retract their path (a self-edit is never misread as a foreign change). Resume legs, first turns, and post-Edit paths are byte-identical to before. **Consumer flip**: a probe pinning "a continuation-turn edit is always refused as not-read" reds.
18
+
19
+ - **Memory recall: an unknown write time is incomparable, not 1970.** A note whose `ts` failed to parse used to sort as epoch 0 — it lost every `[[name]]` collision (permanently unreachable through links) and rendered a fabricated age ("written 678 months ago"). `MemoryNoteHeader.timestampMissing` (new, optional) is minted at the single parse point; the one recency comparison treats missing as a tie (the incumbent keeps the name — an unknown-time note neither sinks nor poses as newest), and the render face says "write time unknown — verify it's still current". A literal 1970 timestamp string stays a real time (the numeric value is never the signal; negative pins lock this). Bonus: a wire row with NO `ts` key used to throw and kill the whole scope's manifest build. **Consumer flip**: a probe pinning the fabricated-age wording or the sink-to-bottom collision outcome reds.
20
+ - **Three awaited rescue timers are REF'd + cleared-on-settle** (subagent observer drain, teacher abortFallback, WebFetch deadline): unref'd, the process could exit before the rescue fired when the wedged arm held no live handles — the settle never completed. The bg notify drain window keeps its unref by design (a background lane must not pin the process).
21
+ - **A harness-side throw around ONE tool call becomes that call's disclosed error result — the batch survives.** Worker exceptions that are not cancellation (a rejected `tool_execution_update` delivery, a sink failure) used to reject the whole parallel batch, orphan the sibling calls, and on the worst path rewrite a COMMITTED side effect into `[tool execution harness failed: …]` — inviting the model to re-run an already-effective write. Progress deliveries now settle at one point; a delivery failure on an executed tool keeps `content`/`isError` untouched and rides `details.toolProgressDeliveryFailed` (total by construction: hostile accessors or exotic `details` decline the annotation, never the outcome; `afterToolCall` cannot erase it). Cancellation — including one wrapped in the harness's own `AgentHarnessError` (`cause`-chain walk) — stays a hard batch failure on every lane, and the in-stream lane closes admission immediately. The sequential lane shares the same disclosure band. **Consumer flips**: probes pinning the old `[tool execution harness failed]` rewrite or `isError:true` on a delivery-failed success red — re-pin on the new shape plus a retired-dialect tripwire.
22
+ - **openai lane: an evidence-free `tool_calls` fragment no longer fabricates an unnamed-call disclosure.** The evidence gate sits at the finalize disclosure point (slot assignment also routes id-less continuations, so gating there corrupted two real calls); the stream's own verdict wins — `finish_reason:"tool_calls"` with zero survivors, or an empty slot beside real calls, discloses loudly; an isolated terminal fragment with `stop`/`length` stays silent.
23
+ - **A throwing tool keeps its machine-readable marks on BOTH legs.** Two strip points, one class: the `defineTool` adapter rewrapped a ToolSpec throw as a bare `Error` (marks died even LIVE — a ToolSpec throw carrying `details.code` got no `errorCode` lift while a native tool's identical error did), and the durable-resume batch's catch stringified the message alone (breaking the live/resume frame-parity contract). Both carry `details`/`errorKind` through now; message text unchanged.
24
+ - **Cache-break attribution: an aborted / usage-missing row is "unknown", not a cold cache.** Feeding it to the design/31 detector minted a false `server-or-ttl` finding and poisoned the warm baseline for the next real turn. Such rows are skipped; a genuine cacheRead collapse still fires.
25
+ - **A usage-ledger charge that does not settle discloses loudly — and is never abandoned.** Both commit points (turn boundary, end-of-task flush) fire a single deployment-sink disclosure after 10s and keep awaiting (abandoning a sequential ledger write would double-charge on the next flush). A wedged ledger store still wedges what it always wedged, now visibly.
26
+ - **The `abortResultDetails` seam is total.** A throwing host callback degrades to "no details" (the abort text is the contract; the marker is additive) instead of escalating a per-tool abort into an unpaired `tool_execution_start` plus an uncaught task-level rejection.
27
+
28
+ ### BREAKING
29
+
30
+ - **The `tool_end.structured` card vocabulary is now a two-way contract — three words leave it, and `type:"agent"` gains a second shape.** Removed: `multiedit`, `memory-saved`, `memory-recall`. No tool in the tree has ever minted any of the three (a batch MultiEdit replay mints `type:"edit"` with an `edits[]` array; core mounts no memory tool at all), so they were a promise to consumers that nothing could keep — a host switching on the closed set had dead branches. Closed-set consumers: **drop those three arms**; a fixture that fabricates one of them to assert it passes the allowlist reds. Separately, `type:"agent"` used to imply "background launch receipt" because the completed delivery was silently dropped; it now carries **both** Agent shapes, told apart by `status` — `"async_launched"` is the receipt, any other value is the completed landing report (`result` / `stats` / `resolvedModel` / `toolStats` / kept `worktreePath` / failure attribution). **Branch on `status`, never on which keys are present.** The completed card is an explicit field whitelist over the child `TaskResult`, not a spread: `checkpointToken` (the resume capability) and `checkpointGate` never leave the process, and a future `TaskResult` field does not join the wire by default. A new test gate walks the tree in both directions (every minted card registered; every registered word produced), so neither direction can drift back.
31
+
32
+ - **Brain lanes disclose an unnamed tool call instead of skipping it in silence.** A `tool_use` block / `tool_calls` slot / `function_call` item arriving with NO tool name now rides the same disclosure path as a call with truncated arguments on all three lanes (anthropic / openai / open-responses): a `[note: N tool call(s) arrived with no tool name …]` block in the assistant content, and — when the turn produced nothing else usable — `stopReason: "error"` with the cause named. Previously such a turn finalized as `[{type:"text",text:""}]` with `stopReason: "stop"` and no error code, so a run whose only action was dropped reported `completed` with an empty answer. **Consumer flips**: a probe pinning the silent empty-`completed` shape reds; a probe asserting assistant content exact-equality gains a note block; "dropped tool call" is now two-cause (`not valid JSON` truncation | `no tool name`) — judge by the named cause, never by the single historic wording; the two causes may be joined with `" | "` in one errorMessage.
33
+ - **`StrategyStore` refuses non-honorable knobs instead of folding them to 0.** `find(scope, query, limit)` requires a non-negative integer or `Infinity` — `Infinity` now means "no cap" and returns everything (it used to return NOTHING); NaN/negative/fractional throw `config.strategy_find_limit_invalid`. `prune(scope, maxSize)` and the `InMemoryStrategyStore` constructor require a non-negative integer and refuse `Infinity` by name (a capacity cap can be widened, not turned off): `config.strategy_max_size_invalid`. A NaN capacity used to silently disable eviction (unbounded growth). Vocabulary additions (closed-set consumers admit two members): `config.strategy_find_limit_invalid`, `config.strategy_max_size_invalid`.
34
+
35
+ - **The three stop clocks gained a crossing contract: `usage_window` > `env_lifetime` > stall.** When the governance window and the environment lifetime come due at the SAME turn boundary, the window wins (loud terminal `usage.window_exhausted` + `retryAfterMs`, or a `usage_window` checkpoint with its resume hint) — it is the only cause carrying an actionable wait; env-first reporting dropped it. With an enforceable env lifetime, the ledger READ races an absolute deadline (a stalled ledger stops the run for the env and disclosingly skips window enforcement for that boundary); the CHARGE is never raced. **Probes pinning "both due ⇒ `env.lifetime_expired`" red.**
36
+ - **Durable-resume deferred siblings are fully paired: each `[DEFERRED]` `tool_end` is preceded by a synthetic `tool_start`** (same id/name/label, the model's real arguments recovered from the transcript, adjacent and unconditional; deliberately NOT in `startedToolCallIds` — the call never executed and interrupt-reconcile still reads it as safe-to-reissue). Exact event-count / first-frame probes on the resume leg red. Delegation tick counters count issued `tool_use` blocks (their declared semantics), so a deferred sibling's start now counts once as issued — the old shape UNDERcounted.
37
+ - **Manual compaction gains two mooted disclosure frames** (`compaction_outcome{mooted, manual, cancelled}` on parked-caller cancel; `{mooted, manual, task_ending}` at run settle/teardown with a parked request), and `TaskStream.compact()` called in a failed run's teardown window returns `"mooted"` instead of hanging forever. "No compaction frame on this path" probes red.
38
+ - **Delegation inheritance seats (one-way tighten, narrowings enumerable):** ① a read-only parent's delegated children carry NO write hands (`handsReadOnly` is a trusted ctx seat, one-way); ② a hard-headless parent's children no longer mount `AskUserQuestion` at all (the content-ask seat `onQuestion` is resolved once at prepare and handed down UNWRAPPED — parent and child share the SAME function object; identity questions read `AskQuestionRequest.sourceTaskId`/`principal`, never closure identity); ③ teacher helper/advisor legs inherit deployment auth/principal/clientContext/promptProfile plus the one-way clamps (a read-only/headless student's teacher legs lose write hands and questions); ④ the verify leg runs hard-headless always (`interactiveTools:false`) with principal + tool-face controls; ⑤ `oneShot` propagates true-ward. The child roster went from "never has AskUserQuestion" to "has it iff the parent face allows" — closed-set tool-roster probes on children red. Legs that OUTLIVE the spawning request (session-scoped/fork background, durable revival, SendMessage wake) never take turn-bound faces; resume rows take the union of clamps.
39
+ - **Every limits door refuses unevaluable values (`config.limit_invalid`), fourth wave of the no-silent-folding rule:** `runTeamDiscussion`'s cumulative `maxTokens`/`maxCostUsd` (NaN silently disarmed the only cumulative stop; refused before any dispatch, 0 stays an honest exhausted window); workflow governance's TRUSTED half (a baseline/`WorkflowChildCaps` NaN/Infinity read as the 0/negative "no ceiling" sentinel and could silently drop an axis — refused loudly, sentinel semantics unchanged); `TaskSpec.resourceSuspend.{totalBudgetUsd,totalTokens,maxSlices,ttlMs}` + non-empty `scope` (NaN blinded even the validated per-slice window through `Math.min`) — refused at the RB-458 door as a failed result before any resource acquisition.
40
+ - **Agent completed card vocabulary additions** (closed-set consumers admit members): `retryAfterMs` (machine-actionable pair of `errorCode` — `retryable` answers "re-issue as-is NOW?", `retryAfterMs` answers "when does the refusal lift"), `degraded` (reduced-quality attribution), and `modelFallback:"inherit_no_tier_binding"` (the requested model word did not bind — no roster / not on the roster — and the child ran on its inherited default; fallback behavior itself unchanged; also stamped on background roster rows). Workflow `agent_end` gains `modelResolved` (the served model, from `TaskResult.model`; `agent_start.model` stays the requested word verbatim — absent on replayed rows and model-less runs).
41
+ - **Two runtime prose strings changed with the retired-scenario vocabulary sweep** (an A2A fence reason and two prompt lines); probes byte-pinning them red. CC-anchored verbatim material (loop sentinels, prompt sections) is exempt by an explicit per-file allowance in the new vocabulary ratchet gate.
42
+
43
+ ## 5.9.0 (2026-08-04)
44
+
45
+ ### Added
46
+
47
+ - **A delegated child's permission ask reaches the parent's approval channel.** The parent run's effective approver (`spec.onAsk ?? deps.onAsk`) rides the trusted tool context into every child spec (sync / background / fork lanes share the one construction point), wrapped once with frozen provenance: `AskRequest.delegation` (new, optional, readonly) carries `{ parentToolCallId, depth, agentName? }` so an approval UI can render *which* delegation is asking — the innermost (true-origin) frame wins on grandchildren. New exported type `AskDelegationProvenance`. An approver is consulted once per gated call: the single-frame dedup compares approver identity *through* the wrapper (`askApproverIdentity`), so the provenance wrapper cannot split one consultation into two approval cards. **Consumer flips**: a probe pinning "a child's ask resolves at `deps.onAsk` when the parent supplied `spec.onAsk`" now resolves at the parent's approver; the `delegation` field is additive and ignorable.
48
+ - **`tool_end.errorCode` (new, optional, additive)** — present iff `isError` is true and the harness result's `details` carried a string `code`; a machine-readable discriminator so a consumer never parses the result text. Engine vocabulary today: `"tool.not_found"` and `"gate.parked"` (an abort short-circuit poisoned this call because a durable gate parked the batch — the "Operation aborted" family; a plain user interrupt carries NO marker, pinned both ways). The abort texts are byte-identical to before — an exact-equality anchor on `"Operation aborted"` is unaffected. Frame-shape change, additive: this is the [2513]-process disclosure. Library embedders get the seam directly as `abortResultDetails` on the harness options / loop config.
49
+
50
+ - `createOpenResponsesBrain` (+ `OpenResponsesBrainConfig`) — a Brain for the Open Responses wire form (`POST /responses`: typed input/output items, an SSE stream terminated by `response.completed` / `response.incomplete` / `response.failed` with no `[DONE]` sentinel). One adapter for the whole family of endpoints that speak it, instead of one per vendor. It is **stateless by contract**: `store` / `previous_response_id` / `conversation` are never sent, cannot be injected through `Model.extraBody` (all three are reserved for this API family), and every turn replays the full transcript — this engine stays the single transcript authority. Reasoning arrives as first-class `reasoning` items and is stored as normal `thinking` blocks (item id in `thinkingSignature`), replayed on tool-call turns. Terminal events map onto the existing vocabulary — no new `errorKind` or brain-error code is minted: a `max_output_tokens` cut with no answer text is `length_empty`, a content-filter cut and a streamed refusal are `[refusal]`, a stream with no terminal event is `[stream_torn]`, and `response.failed` maps by code onto `rate_limit` / `auth` / `invalid_request` / `server`. Because the wire form offers no capability discovery and endpoints have been observed to accept-and-ignore parameters, the adapter audits the `response.created` echo against what it requested and records divergences (state held that was never asked for, a reasoning tier not honored, serialized tool calls) as a `responses_capability` diagnostic on the final message. `StreamOptions.stop` has no field in this wire form and is REFUSED rather than silently dropped. Type-surface correction from the landing review: `OpenAIResponsesCompat` now declares exactly the knobs the adapter consumes (`supportsReasoningEffort` / `reasoningEffortLevels`); its two former fields (`sendSessionIdHeader` / `supportsLongCacheRetention`) had zero consumers anywhere in the engine and are removed — type-level only, no runtime behavior existed behind them.
51
+
52
+ ### BREAKING
53
+
54
+ - **Declared-only fields removed from the public type surface (type-level only — no runtime behavior existed behind any of them).** A new gate, `gate:field-liveness`, walks every interface reachable from `src/index.ts` (directly exported, or reachable through a field of something that is) and reds on a member no production code under `src/` mentions. Its first run found 59; 34 are removed here. Setting any of them compiled, type-checked, and did nothing.
55
+ - `OpenAICompletionsCompat`: `supportsStore`, `supportsDeveloperRole`, `supportsUsageInStreaming`, `requiresToolResultName`, `requiresAssistantAfterToolResult`, `requiresThinkingAsText`, `openRouterRouting`, `vercelGatewayRouting`, `zaiToolStream`, `supportsStrictMode`, `cacheControlFormat`, `sendSessionAffinityHeaders`, `supportsLongCacheRetention`. Several documented a "Default: auto-detected from URL" that no code performs. The knobs the completions brain does read are unchanged: `supportsReasoningEffort`, `reasoningEffortLevels`, `maxTokensField`, `requiresReasoningContentOnAssistantMessages`, `thinkingFormat`.
56
+ - `AnthropicMessagesCompat`: `supportsEagerToolInputStreaming`, `supportsLongCacheRetention`, `sendSessionAffinityHeaders`, `supportsCacheControlOnTools`. The Anthropic brain performs each of those behaviours unconditionally or not at all, so declaring the opposite changed nothing. `thinkingMode`, `effortLevels`, `supportsTemperature`, `contextManagement`, `interleavedThinking` are unchanged.
57
+ - `OpenRouterRouting` and `VercelGatewayRouting` are removed entirely: both existed only as the types of the two removed routing fields, so nothing they described (fallbacks, ZDR, quantizations, price ceilings, throughput/latency floors, provider order) was ever sent on any request.
58
+ - `TextContent.textSignature` and its payload type `TextSignatureV1` — no brain wrote it and no replay path read it. The live equivalent is `ThinkingContent.thinkingSignature`.
59
+ - `ToolCall.thoughtSignature` — Google-specific, and there is no Google brain in this tree.
60
+ - `Model.mediaInput` — claimed to carry provider media limits "used by attachment preprocessing"; the image pipeline's caps come from `RunnerDeps.mediaByteCapBytes` and the resizer seam.
61
+ - `TraceEvent` (`kind: "brain.call"`): `callCap` and `capThinkingSkipped`, orphans of the design/130 per-call deadline shrink retired in 5.8.0 — the emitter went with the mechanism, the declaration stayed. `stopReason` is unchanged (its JSDoc no longer instructs consumers to judge cap binding by a field that is never emitted).
62
+ - `SemaTaskHandle.outputOffset` — never written by the registry and never read by anything.
63
+ - Migration: setting any removed field is now a compile error at the assignment. There is no runtime change to migrate — the behaviour a consumer sees is exactly what it saw before, because none of these fields ever reached code. The remaining 25 declared-only members are registered with a reason and an owner in `test/field-liveness-allowlist.json` (deployment-implemented adapter contracts, foreign on-disk formats, nominal brand phantoms, and the published recall seam); two of them carry a `PENDING VERDICT` marker with a review date rather than a clearance.
64
+ - `LspDiagnosticsRegistry` is DEPLOYMENT-scoped (one per LSP manager, shared by every task), but its delivered-set had no run boundary: a diagnostic injected into one run was filtered out of every later run's drain for the lifetime of the process — a second session could not see it at all unless it happened to edit that file — and the set grew without bound. The delivered set is now keyed by run: `drain(runIdent)` and `fileEdited(runIdent, uri)` take the run key (both signatures changed), and the new `releaseRun(runIdent)` drops a finished run's keys. The engine passes the session id and releases at the run's terminal; direct callers of the registry must pass a key of their own.
65
+
66
+ ### Changed
67
+
68
+ - Session permission rules: an `allowDirs` confinement now PATH-CONFINES `NotebookEdit` instead of denying it outright. Previously `createSessionRulePolicy` only knew the `file_path`-keyed write tools, so while `allowDirs` was active every `NotebookEdit` call fell into the "mutating but not path-confinable" arm and was denied — even for a notebook inside an allowed directory — while `createFsWriteGatePolicy` had always confined the same tool by its `notebook_path`. Both faces now share one covered set and one target extractor, so a notebook inside `allowDirs` is allowed, one outside is denied, and a decoy `file_path` never outranks the real `notebook_path` target. **A probe pinning "NotebookEdit is denied under `allowDirs`" will now flip** — re-pin it as a path verdict (in-dir allow / out-of-dir deny). Deliberately unchanged: every other mutating tool that cannot be path-confined (bash, deployment-authored write tools, tools with an unknown effect) is still fail-closed denied under `allowDirs`, and the skill-manifest `allowPaths` face still denies `NotebookEdit` — widening that one is a separate decision.
69
+
70
+ ### Changed
71
+
72
+ - **A subagent's durable pause is reported as what it is.** The sync and fork report arms minted `status: failed` with "treat this delegation as failed; do not retry" when the child parked at an approval gate — a fabrication that buried the recovery path. They now mint one honest constructor: `status: suspended`, the checkpoint named, the deployment's approval channel named as the only release path, anti-retry guidance kept, `isError: true` now explicit on the report. `details.error` vocabulary: `unexpected.suspended` → `suspended.awaiting_approval`, `unexpected.needs_review` → `suspended.needs_review` (the TaskResult-level `unexpected.*` codes elsewhere are unchanged). The ineligible-background settle keeps its mechanism byte-for-byte but its row/summary/notification faces now say which disposition happened: new `errorCode` values `suspended.awaiting_approval` (checkpoint still pending, resolvable out-of-band) and `suspended.checkpoint_expired` (destroyed by the no-orphans rule — stop waiting). **Consumer flips**: probes pinning the old `status: failed` wording or the `unexpected.*` codes on delegation reports will red; anything pinning a closed set of background-failure `errorCode`s must admit the two new members.
73
+ - Resumed-batch `tool_end` frames (durable-approval resume) carry the result body — `output` / `truncated` / `totalChars` / `structured` from the same projection the live loop uses; the deferred-sibling closes carry the `[DEFERRED]` body the transcript records. A client rendering tool output from frames no longer shows an empty card for every approved call ([2513] W2; zero shape change — these are the frame's existing optional fields, previously absent). The same frames now also carry `eventId` (+ `parentToolCallId`/`sourceTaskId` when running as a subagent) and reach a parent's `forwardSubagentEvents` pane — a child's durable-approved call no longer leaves the parent's viewing pane spinner open forever.
74
+
75
+ ### Fixed
76
+
77
+ - Workflow resilience knobs (`stallMs` / `agentMaxRetries` / `throttleBackoffMs` / `totalTimeoutMs`) refuse non-finite values loudly instead of silently changing behavior — a NaN used to disarm the stall watchdog, zero the retry budget, or turn the total deadline into an instant abort (`stallMs <= 0` stays the documented explicit-off). `WebFetchConfig.maxBytes` likewise refuses non-finite/non-positive values (`config.web_max_bytes_invalid`) — NaN/Infinity used to disable the byte budget entirely, streaming unbounded into memory (±Infinity gets its own message: the cap cannot be turned off, only widened).
78
+ - The WebFetch binary sniffer consumes the shared magic-byte table (`BINARY_MAGIC_SIGNATURES`) instead of a private eight-format if-chain — one data source; edge behavior tightens slightly (full-length signatures) and labels adopt the table's human-readable names.
79
+ - Platform terminals (`env.lifetime_expired` / `usage.window_exhausted`) now carry `salvagedOutput` like every other salvage-eligible terminal (the eligibility set is one closed construction point) — **a consumer pinning "salvagedOutput is empty on every non-completed result" will flip**. `TaskResult.retryAfterMs` (new, optional) rides a `usage.window_exhausted` terminal with the window's reopen hint; presence condition documented on the field — judge the cause by `errorCode`, never by this field's presence.
80
+ - `assertWorkflowDeterminism` reaches the package root alongside its two conformance siblings (the determinism battery an out-of-repo workflow runner needs; additive).
81
+
82
+
83
+ - `runCascade` no longer reaches its tail with nothing dispatched. A non-finite `config.maxEscalations` is refused at the door (`Error.code = "config.cascade_invalid"`) instead of making the iteration bound `NaN` and running zero rungs; a `totalTimeoutMs` already spent at entry returns the cascade's own terminal (`status: "failed"`, `errorCode: "cascade.budget_exhausted"`, empty `attempts`, `finalRung: -1`) instead of throwing a bare `TypeError`. `CascadeRunResult.finalRung` can therefore be `-1` (no rung produced the result).
84
+
3
85
  ## 5.8.0 (2026-08-04)
4
86
 
5
87
  _The limits restructure: time leaves the task-limit axis, tokens become the primary budget, and every default is empty. One release, five construction stages — there is no intermediate version. Old usage fails loudly (compile errors for removed fields; a typed refusal for unknown limit keys at runtime) — nothing is silently ignored._
@@ -3,6 +3,13 @@ import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
3
3
  import { buildCumulativeStats } from "./cumulative-stats.js";
4
4
  import { createSafeNotifier } from "../core/safe-notify.js";
5
5
  const CASCADE_ON_RUNG_SITE = "cascade.onRung";
6
+ const CASCADE_CONFIG_ERROR_CODE = "config.cascade_invalid";
7
+ const CASCADE_NO_DISPATCH_ERROR_CODE = "cascade.budget_exhausted";
8
+ function cascadeConfigError(message) {
9
+ const e = new Error(message);
10
+ e.code = CASCADE_CONFIG_ERROR_CODE;
11
+ return e;
12
+ }
6
13
  function createDefaultGate(spec) {
7
14
  const requiresStructured = spec.outputSchema != null;
8
15
  return (result) => result.status === "completed" && (!requiresStructured || result.structuredOutput !== undefined);
@@ -13,6 +20,9 @@ export async function runCascade(runner, spec, config) {
13
20
  throw new Error("runCascade: config.ladder must have at least one rung");
14
21
  }
15
22
  const maxEscalations = config.maxEscalations ?? ladder.length - 1;
23
+ if (typeof maxEscalations !== "number" || !Number.isFinite(maxEscalations)) {
24
+ throw cascadeConfigError(`runCascade: config.maxEscalations must be a finite number (got ${String(config.maxEscalations)}) — an unevaluable escalation ceiling is not a ceiling, and folding it to a default would run the cascade under a bound nobody chose.`);
25
+ }
16
26
  const maxRungs = Math.min(ladder.length, Math.max(0, maxEscalations) + 1);
17
27
  const gate = config.gate ?? createDefaultGate(spec);
18
28
  const startedAt = Date.now();
@@ -179,6 +189,20 @@ export async function runCascade(runner, spec, config) {
179
189
  break;
180
190
  }
181
191
  }
192
+ if (lastResult === undefined) {
193
+ return {
194
+ taskId: spec.taskId ?? "",
195
+ sessionId: "",
196
+ status: "failed",
197
+ result: `runCascade: the overall wall-clock budget (totalTimeoutMs ${config.totalTimeoutMs}ms) was already spent when the ladder was entered — no rung dispatched`,
198
+ errorCode: CASCADE_NO_DISPATCH_ERROR_CODE,
199
+ stats: { tokens: 0, turns: 0, costMicroUsd: 0 },
200
+ cascadeOutcome: "exhausted",
201
+ escalated: false,
202
+ finalRung: -1,
203
+ attempts,
204
+ };
205
+ }
182
206
  const base = lastResult;
183
207
  const finalRung = attempts.length - 1;
184
208
  return {
@@ -5,6 +5,7 @@ export interface RosterEntry {
5
5
  toolUseId?: string;
6
6
  rootSessionId?: string;
7
7
  model?: string;
8
+ modelFallback?: "inherit_no_tier_binding";
8
9
  owner: string;
9
10
  scope: string;
10
11
  sessionScoped?: boolean;
@@ -528,6 +528,12 @@ export function createSendMessageTool(opts) {
528
528
  ...(row.createdAt !== undefined ? { rowSpawnedAt: row.createdAt } : {}),
529
529
  ...(opts.onNotifyError !== undefined ? { onNotifyError: opts.onNotifyError } : {}),
530
530
  ...(opts.notify ? { currentParentNotify: opts.notify } : {}),
531
+ ...(ctx.onQuestion !== undefined && row.sessionScoped !== true ? { currentOnQuestion: ctx.onQuestion } : {}),
532
+ currentClamps: {
533
+ ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
534
+ ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
535
+ ...(ctx.oneShot === true ? { oneShot: true } : {}),
536
+ },
531
537
  });
532
538
  const fromPrefix = senderIsChild ? `(message from teammate "${senderLabel}")\n` : "";
533
539
  try {
@@ -43,6 +43,33 @@ export declare function classifySubagentError(child: {
43
43
  errorKind: SubagentErrorKind;
44
44
  retryable: boolean;
45
45
  } | undefined;
46
+ export declare function completedAgentCard(child: {
47
+ taskId: string;
48
+ sessionId: string;
49
+ status: string;
50
+ model?: string;
51
+ result: string;
52
+ salvagedOutput?: string;
53
+ blockedReason?: string;
54
+ errorMessage?: string;
55
+ errorCode?: string;
56
+ retryAfterMs?: number;
57
+ degraded?: unknown;
58
+ structuredOutput?: unknown;
59
+ stats: unknown;
60
+ }, extras: {
61
+ subagentType?: string;
62
+ error?: {
63
+ errorKind: SubagentErrorKind;
64
+ retryable: boolean;
65
+ } | undefined;
66
+ worktreePath?: string;
67
+ toolStats?: SubagentToolStats;
68
+ modelFallback?: "inherit_no_tier_binding";
69
+ }): Record<string, unknown>;
70
+ export declare const SUBAGENT_SUSPENDED_AWAITING_APPROVAL = "suspended.awaiting_approval";
71
+ export declare const SUBAGENT_SUSPENDED_NEEDS_REVIEW = "suspended.needs_review";
72
+ export declare const SUBAGENT_SUSPENDED_CHECKPOINT_EXPIRED = "suspended.checkpoint_expired";
46
73
  export interface SubagentSteerHandle {
47
74
  parentToolCallId: string;
48
75
  agentName?: string;
@@ -63,6 +90,12 @@ export declare function createSubagentResume(deps: {
63
90
  currentParentNotify?: (n: TaskNotificationPayload, opts?: {
64
91
  priority?: "now" | "next" | "later";
65
92
  }) => void;
93
+ currentOnQuestion?: import("../core/ask-question.js").OnQuestion;
94
+ currentClamps?: {
95
+ handsReadOnly?: true;
96
+ interactiveTools?: false;
97
+ oneShot?: true;
98
+ };
66
99
  taskId?: string;
67
100
  taskAccess?: import("../core/task-registry.js").TaskAccess;
68
101
  bgSink?: (event: import("../core/types.js").BackgroundChildEvent) => void;
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { isAbsolute } from "node:path";
3
+ import { withDelegationProvenance } from "../core/tool-policy.js";
3
4
  import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
4
5
  import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
5
6
  import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
@@ -231,6 +232,64 @@ export function classifySubagentError(child) {
231
232
  : "logic";
232
233
  return { errorKind, retryable: errorKind !== "logic" };
233
234
  }
235
+ export function completedAgentCard(child, extras) {
236
+ return {
237
+ type: "agent",
238
+ ...(extras.subagentType !== undefined ? { subagent_type: extras.subagentType } : {}),
239
+ status: child.status,
240
+ taskId: child.taskId,
241
+ sessionId: child.sessionId,
242
+ result: child.result,
243
+ ...(child.salvagedOutput !== undefined ? { salvagedOutput: child.salvagedOutput } : {}),
244
+ ...(child.structuredOutput !== undefined ? { structuredOutput: child.structuredOutput } : {}),
245
+ ...(child.blockedReason !== undefined ? { blockedReason: child.blockedReason } : {}),
246
+ ...(child.errorMessage !== undefined ? { errorMessage: child.errorMessage } : {}),
247
+ ...(child.errorCode !== undefined ? { errorCode: child.errorCode } : {}),
248
+ ...(child.retryAfterMs !== undefined ? { retryAfterMs: child.retryAfterMs } : {}),
249
+ ...(child.degraded !== undefined ? { degraded: child.degraded } : {}),
250
+ ...(extras.error ? { error_kind: extras.error.errorKind, retryable: extras.error.retryable } : {}),
251
+ stats: child.stats,
252
+ ...(child.model !== undefined ? { model: child.model, resolvedModel: child.model } : {}),
253
+ ...(extras.modelFallback !== undefined ? { modelFallback: extras.modelFallback } : {}),
254
+ ...(extras.worktreePath !== undefined ? { worktreePath: extras.worktreePath } : {}),
255
+ ...(extras.toolStats !== undefined ? { toolStats: extras.toolStats } : {}),
256
+ };
257
+ }
258
+ export const SUBAGENT_SUSPENDED_AWAITING_APPROVAL = "suspended.awaiting_approval";
259
+ export const SUBAGENT_SUSPENDED_NEEDS_REVIEW = "suspended.needs_review";
260
+ export const SUBAGENT_SUSPENDED_CHECKPOINT_EXPIRED = "suspended.checkpoint_expired";
261
+ function durablePauseDelegationReport(reportLabel, child) {
262
+ const needsReview = child.status === "needs_review";
263
+ const token = child.checkpointToken;
264
+ return {
265
+ isError: true,
266
+ content: `[Sub-agent report${reportLabel}]\n` +
267
+ `status: suspended\n` +
268
+ `This delegation is PARKED at an approval gate — it did not fail, and it is not finished. ` +
269
+ `${needsReview ? "A human review" : "An approval"} it cannot grant itself is pending` +
270
+ `${token !== undefined ? `, held by checkpoint ${token}` : " (the run reported no checkpoint token)"}. ` +
271
+ `The gated action has NOT run.\n` +
272
+ `Only the deployment's approval channel can release it: you cannot approve it from here, and no ` +
273
+ `result from it will arrive in this turn. Do not re-issue the same approval-gated action — an ` +
274
+ `identical retry parks again and resolves nothing. Carry on with work that does not depend on ` +
275
+ `this delegation, or report that it is awaiting approval.`,
276
+ details: {
277
+ error: needsReview ? SUBAGENT_SUSPENDED_NEEDS_REVIEW : SUBAGENT_SUSPENDED_AWAITING_APPROVAL,
278
+ ...(token !== undefined ? { checkpointToken: token } : {}),
279
+ },
280
+ };
281
+ }
282
+ function unparkedDurablePauseReason(d) {
283
+ return d.kind === "no_park_lane"
284
+ ? `the agent hit an approval gate and paused durably, but this delegation cannot be parked ` +
285
+ `(parking needs an explicitly named child plus a durable agent row, a checkpoint store and the ` +
286
+ `durable-session capability). Its checkpoint ${d.checkpointToken} is still committed and the ` +
287
+ `approval must be resolved through the deployment's approval channel; the gated action never ran.`
288
+ : `the agent hit an approval gate and paused durably, but the park did not take (` +
289
+ `${d.cause === "capability_veto" ? "the durable-session capability refused" : "a concurrent stop won the arbitration"}` +
290
+ `), so its checkpoint was expired under the no-orphans rule. Nothing is pending approval and the ` +
291
+ `gated action never ran — re-run this delegation with an approver attached.`;
292
+ }
234
293
  function errorKindClause(c) {
235
294
  return c !== undefined ? ` (error_kind: ${c.errorKind}, retryable: ${c.retryable})` : "";
236
295
  }
@@ -338,6 +397,10 @@ export function createSubagentResume(deps) {
338
397
  objective: createResumePrompt(marker, content),
339
398
  sessionId: entry.childSessionId,
340
399
  requireExistingSession: true,
400
+ ...(deps.currentOnQuestion !== undefined ? { onQuestion: deps.currentOnQuestion } : {}),
401
+ ...(entry.specSnapshot.handsReadOnly === true || deps.currentClamps?.handsReadOnly === true ? { handsReadOnly: true } : {}),
402
+ ...(entry.specSnapshot.interactiveTools === false || deps.currentClamps?.interactiveTools === false ? { interactiveTools: false } : {}),
403
+ ...(entry.specSnapshot.oneShot === true || deps.currentClamps?.oneShot === true ? { oneShot: true } : {}),
341
404
  signal: abort.signal,
342
405
  };
343
406
  if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
@@ -844,7 +907,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
844
907
  `- Finding a known symbol/class/definition, or searching within 2-3 known files → use Grep/Glob/Read.\n` +
845
908
  `- A lookup where you already know what you want and roughly where it is → just do it. Delegate open-ended INVESTIGATIONS (you don't know where the answer is), not lookups.\n\n` +
846
909
  `Writing the 'prompt' — brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters:\n` +
847
- `- Make it fully self-contained and highly detailed: the sub-agent does the whole task autonomously in one shot — you can't course-correct mid-run.\n` +
910
+ `- Make it fully self-contained and highly detailed: the sub-agent does the whole task end-to-end in one shot — you can't course-correct mid-run.\n` +
848
911
  `- State exactly what it must RETURN (e.g. "report the file:line of each call site", not "look into the call sites").\n` +
849
912
  `- Say explicitly whether you want it to WRITE CODE or only RESEARCH/REPORT — otherwise it may guess wrong.\n` +
850
913
  `- Never delegate understanding. Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.\n\n` +
@@ -1035,6 +1098,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1035
1098
  }
1036
1099
  }
1037
1100
  }
1101
+ const modelFallback = !wantsFork && requestedModel !== undefined && perCallModel === undefined ? "inherit_no_tier_binding" : undefined;
1038
1102
  let worktreeDir;
1039
1103
  if (reviveClaim === undefined && (a.isolation ?? def?.isolation) === "worktree") {
1040
1104
  const iso = ctx.worktreeIsolation;
@@ -1297,11 +1361,17 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1297
1361
  };
1298
1362
  const drainAndReleaseObserver = async () => {
1299
1363
  if (observerPairing) {
1364
+ let drainRescue;
1300
1365
  const timeout = new Promise((resolve) => {
1301
- const t = setTimeout(() => resolve("timeout"), 60_000);
1302
- t.unref?.();
1366
+ drainRescue = setTimeout(() => resolve("timeout"), 60_000);
1303
1367
  });
1304
- const outcome = await Promise.race([observerPairing.drain().then(() => "drained"), timeout]);
1368
+ let outcome;
1369
+ try {
1370
+ outcome = await Promise.race([observerPairing.drain().then(() => "drained"), timeout]);
1371
+ }
1372
+ finally {
1373
+ clearTimeout(drainRescue);
1374
+ }
1305
1375
  observerPairing.retire(outcome === "timeout" ? "stopped" : "retired");
1306
1376
  if (outcome === "timeout")
1307
1377
  noteObserverFailure(new Error("observer unresponsive at settle (delivery drain timed out)"));
@@ -1403,6 +1473,14 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1403
1473
  ...(ctx.onSubagentSpawn ? { onSubagentSpawn: ctx.onSubagentSpawn } : {}),
1404
1474
  };
1405
1475
  const childThinking = def?.thinking ?? ctx.thinkingLevel;
1476
+ const provenanceAgentName = agentName ?? def?.name;
1477
+ const childOnAsk = ctx.onAsk !== undefined
1478
+ ? withDelegationProvenance(ctx.onAsk, {
1479
+ parentToolCallId: ctx.toolCallId,
1480
+ depth: depth + 1,
1481
+ ...(provenanceAgentName !== undefined ? { agentName: provenanceAgentName } : {}),
1482
+ })
1483
+ : undefined;
1406
1484
  const buildChildSpec = (signal) => ({
1407
1485
  objective: prompt,
1408
1486
  ...(childModel ? { model: childModel } : { modelRole: "subagent" }),
@@ -1412,6 +1490,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1412
1490
  ...(def?.memory ? { memory: def.memory } : {}),
1413
1491
  ...(def?.skills?.length ? { skills: def.skills } : {}),
1414
1492
  ...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
1493
+ ...(ctx.checkpointStoreDisabledForChildren === true ? { checkpointStore: null } : {}),
1494
+ ...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
1495
+ ...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
1496
+ ...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
1497
+ ...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {}),
1498
+ ...(ctx.oneShot === true ? { oneShot: true } : {}),
1415
1499
  ...(ctx.clientContext !== undefined ? { clientContext: ctx.clientContext } : {}),
1416
1500
  ...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
1417
1501
  ...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
@@ -1428,7 +1512,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1428
1512
  if (!ledger)
1429
1513
  return undefined;
1430
1514
  const childSessionId = preMintedSessionId ?? uuidv7();
1431
- const { signal: _spawnSignal, ...plainSpec } = buildChildSpec(undefined);
1515
+ const { signal: _spawnSignal, onQuestion: _spawnTurnQuestionFace, ...plainSpec } = buildChildSpec(undefined);
1432
1516
  let entry = ledger.register(ctx.toolCallId, {
1433
1517
  childSessionId,
1434
1518
  runner: opts.runner,
@@ -1508,7 +1592,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1508
1592
  if (ledger !== undefined && !ledger.disposed) {
1509
1593
  if (opts.background)
1510
1594
  ensureSessionReapHook(opts.background.registry);
1511
- const { signal: _spawnSignal, ...plainSpec } = buildChildSpec(undefined);
1595
+ const { signal: _spawnSignal, onQuestion: _spawnTurnQuestionFace, ...plainSpec } = buildChildSpec(undefined);
1512
1596
  const entry = ledger.register(ctx.toolCallId, {
1513
1597
  childSessionId,
1514
1598
  runner: opts.runner,
@@ -1626,6 +1710,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1626
1710
  else if (!sessionScopedBg)
1627
1711
  ctx.signal.addEventListener("abort", onHostAbort, { once: true });
1628
1712
  }
1713
+ const dropTurnBoundQuestionFace = sessionScopedBg ? { onQuestion: undefined } : {};
1629
1714
  const shortDesc = `fork: ${(typeof a.description === "string" && a.description.trim() ? a.description.trim() : prompt).slice(0, 180)}`;
1630
1715
  const bgOwner = sessionScopedBg ? ctx.sessionId : ctx.taskId ?? bg.owner;
1631
1716
  const bgScope = treeScope;
@@ -1690,7 +1775,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1690
1775
  }, { priority: "later" }), "subagent.reapTerminalNotify");
1691
1776
  });
1692
1777
  if (agentName !== undefined) {
1693
- recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, sessionId: forkedId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
1778
+ recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, sessionId: forkedId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(modelFallback !== undefined ? { modelFallback } : {}), ...((ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
1694
1779
  }
1695
1780
  const bgSink = ctx.onBackgroundChildEvent;
1696
1781
  const sinkEmit = (event) => {
@@ -1718,7 +1803,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1718
1803
  startedAt: Date.now(),
1719
1804
  });
1720
1805
  const forkBgStartedAt = Date.now();
1721
- const bgForkSpec = { ...buildChildSpec(abort.signal), sessionId: forkedId, requireExistingSession: true, objective: forkObjective };
1806
+ const bgForkSpec = { ...buildChildSpec(abort.signal), sessionId: forkedId, requireExistingSession: true, objective: forkObjective, ...dropTurnBoundQuestionFace };
1722
1807
  const s2ForkNotifyReady = (inject) => {
1723
1808
  bg.registry.attachAgentNotify(taskId, inject);
1724
1809
  };
@@ -1971,11 +2056,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1971
2056
  costMicroUsd: rollupDelegatedCost(forkChild.stats, nsf),
1972
2057
  });
1973
2058
  if (isDurablePause(forkChild.status)) {
1974
- return {
1975
- content: `[Sub-agent report · ${FORK_SUBAGENT_TYPE}]\nstatus: failed\nerror: unexpected durable pause (${forkChild.status}, checkpoint ` +
1976
- `${String(forkChild.checkpointToken ?? "?")}) — treat this fork as failed; do not retry the same gated action.`,
1977
- details: { error: forkChild.status === "needs_review" ? "unexpected.needs_review" : "unexpected.suspended", checkpointToken: forkChild.checkpointToken },
1978
- };
2059
+ return durablePauseDelegationReport(` · ${FORK_SUBAGENT_TYPE}`, forkChild);
1979
2060
  }
1980
2061
  const wtLine = await finishWorktree();
1981
2062
  const forkErr = classifySubagentError(forkChild);
@@ -1998,16 +2079,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1998
2079
  ].filter(Boolean);
1999
2080
  return {
2000
2081
  content: forkLines.join("\n"),
2001
- details: {
2002
- ...forkChild,
2003
- ...(forkErr ? { error_kind: forkErr.errorKind, retryable: forkErr.retryable } : {}),
2082
+ details: completedAgentCard(forkChild, {
2083
+ subagentType: FORK_SUBAGENT_TYPE,
2084
+ error: forkErr,
2004
2085
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2005
- ...(forkChild.model !== undefined ? { resolvedModel: forkChild.model } : {}),
2006
2086
  ...(() => {
2007
2087
  const ts = toolStatsCounter.snapshot();
2008
2088
  return ts !== undefined ? { toolStats: ts } : {};
2009
2089
  })(),
2010
- },
2090
+ }),
2011
2091
  };
2012
2092
  }
2013
2093
  if (wantsBackground) {
@@ -2017,12 +2097,13 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2017
2097
  const reviveRow = reviveClaim?.row;
2018
2098
  const parkedResume = reviveClaim?.parkedResume;
2019
2099
  const sessionScopedBg = reviveRow !== undefined ? reviveRow.sessionScoped : ctx.backgroundScope === "session" && ctx.sessionId !== undefined;
2100
+ const outlivesThisRequest = sessionScopedBg || reviveRow !== undefined;
2020
2101
  const onHostAbort = () => abort.abort();
2021
2102
  const dropHostAbortListener = () => ctx.signal?.removeEventListener("abort", onHostAbort);
2022
2103
  if (ctx.signal) {
2023
2104
  if (ctx.signal.aborted)
2024
2105
  abort.abort();
2025
- else if (!sessionScopedBg && reviveRow === undefined)
2106
+ else if (!outlivesThisRequest)
2026
2107
  ctx.signal.addEventListener("abort", onHostAbort, { once: true });
2027
2108
  }
2028
2109
  const shortDesc = reviveRow?.description ?? String(a.description ?? "sub-agent").slice(0, 200);
@@ -2188,6 +2269,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2188
2269
  sessionId: bgChildSessionId,
2189
2270
  ...(forwardDurableApproval ? { durableApproval: { ...ctx.durableApprovalForChildren } } : {}),
2190
2271
  ...(reviveRow !== undefined ? { requireExistingSession: true } : {}),
2272
+ ...(outlivesThisRequest ? { onQuestion: undefined } : {}),
2191
2273
  };
2192
2274
  let reviveAttachedResolve;
2193
2275
  const reviveAttached = reviveRow !== undefined ? new Promise((r) => (reviveAttachedResolve = r)) : undefined;
@@ -2408,6 +2490,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2408
2490
  else
2409
2491
  bgRetainLedger?.markSettled(ctx.toolCallId);
2410
2492
  }
2493
+ let unparkedPause;
2411
2494
  if (isDurablePause(child.status) && child.checkpointToken !== undefined) {
2412
2495
  const cpStore = bg.checkpointStore;
2413
2496
  const expireByStoreScope = async (token2) => {
@@ -2452,14 +2535,27 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2452
2535
  if (parked === "parked")
2453
2536
  return;
2454
2537
  await expireByStoreScope(token);
2538
+ unparkedPause = { kind: "checkpoint_expired", cause: attested ? "park_lost" : "capability_veto" };
2455
2539
  }
2456
2540
  }
2541
+ else {
2542
+ unparkedPause = { kind: "no_park_lane", checkpointToken: child.checkpointToken };
2543
+ }
2457
2544
  }
2458
2545
  const ok = child.status === "completed";
2459
2546
  const reaped = !ok && abort.signal.aborted;
2460
2547
  const collateral = reaped && bg.registry.getAccessibleTask(taskId, { ...(bgOwner !== undefined ? { owner: bgOwner } : {}), ...(bgScope !== undefined ? { scope: bgScope } : {}) })?.status === "running";
2461
2548
  const failedBg = !ok && !reaped;
2462
- const errCodeBg = failedBg ? (child.errorCode ?? extractErrorCode(child.errorMessage)) : undefined;
2549
+ const unparkedPauseReason = unparkedPause !== undefined && failedBg ? unparkedDurablePauseReason(unparkedPause) : undefined;
2550
+ const errCodeBg = failedBg
2551
+ ? unparkedPause !== undefined
2552
+ ? unparkedPause.kind === "no_park_lane"
2553
+ ? child.status === "needs_review"
2554
+ ? SUBAGENT_SUSPENDED_NEEDS_REVIEW
2555
+ : SUBAGENT_SUSPENDED_AWAITING_APPROVAL
2556
+ : SUBAGENT_SUSPENDED_CHECKPOINT_EXPIRED
2557
+ : (child.errorCode ?? extractErrorCode(child.errorMessage))
2558
+ : undefined;
2463
2559
  const errClassBg = failedBg
2464
2560
  ? classifySubagentError({ status: "failed", ...(child.errorCode !== undefined ? { errorCode: child.errorCode } : {}), ...(child.errorMessage !== undefined ? { errorMessage: child.errorMessage } : {}) })
2465
2561
  : undefined;
@@ -2468,7 +2564,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2468
2564
  status: ok ? "completed" : reaped ? "killed" : "failed",
2469
2565
  seq: seqAtSettle ?? 1,
2470
2566
  ...resultSettleFields(child.result),
2471
- ...(!ok ? { error: reaped ? (collateral ? BG_AGENT_COLLATERAL_REAP_REASON : BG_AGENT_REAP_STOP_ERROR) : child.errorMessage ?? String(child.status) } : {}),
2567
+ ...(!ok ? { error: reaped ? (collateral ? BG_AGENT_COLLATERAL_REAP_REASON : BG_AGENT_REAP_STOP_ERROR) : unparkedPauseReason ?? child.errorMessage ?? String(child.status) } : {}),
2472
2568
  ...(errCodeBg !== undefined ? { errorCode: errCodeBg } : {}),
2473
2569
  ...(errClassBg !== undefined ? { retryable: errClassBg.retryable, errorKind: errClassBg.errorKind } : {}),
2474
2570
  }) ??
@@ -2486,7 +2582,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2486
2582
  const observerNote = observerNoteFor(await startBoundedObserverDrain());
2487
2583
  const residual = residualFields();
2488
2584
  const resumableBg = bgRetain !== undefined && settled !== "killed";
2489
- const failReasonBg = settled === "failed" ? child.errorMessage : undefined;
2585
+ const failReasonBg = settled === "failed" ? unparkedPauseReason ?? child.errorMessage : undefined;
2490
2586
  const bgTerminalSummary = failReasonBg !== undefined
2491
2587
  ? `Agent "${shortDesc}" failed: ${failReasonBg}${ccElapsedTag(Date.now() - bgStartedAt)}${observerNote}`.slice(0, 300) + errorKindClause(errClassBg)
2492
2588
  : `${ccCompletionText(shortDesc, settled, String(child.status), Date.now() - bgStartedAt)}${observerNote}`;
@@ -2674,6 +2770,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2674
2770
  sink: ctx.onSubagentSpawn,
2675
2771
  ...(opts.background ? { registry: opts.background.registry } : {}),
2676
2772
  ...(opts.onObserverError !== undefined ? { onNotifyError: (f) => opts.onObserverError?.(f.error, { site: f.site }) } : {}),
2773
+ ...(ctx.onQuestion !== undefined ? { currentOnQuestion: ctx.onQuestion } : {}),
2677
2774
  });
2678
2775
  notifier.notify(() => ctx.onSubagentSpawn?.(createSteerHandle(stream, ctx.toolCallId, childAgentName, settled, {
2679
2776
  resume,
@@ -2728,12 +2825,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2728
2825
  if (isDurablePause(child.status)) {
2729
2826
  if (retainEntry)
2730
2827
  ctx.subagentRetain?.abandon(ctx.toolCallId);
2731
- return {
2732
- content: `[Sub-agent report${def ? ` · ${def.name}` : ""}]\n` +
2733
- `status: failed\nerror: unexpected durable pause (${child.status}, checkpoint ${String(child.checkpointToken ?? "?")}) — ` +
2734
- `treat this delegation as failed; do not retry with the same approval-gated action.`,
2735
- details: { error: child.status === "needs_review" ? "unexpected.needs_review" : "unexpected.suspended", checkpointToken: child.checkpointToken },
2736
- };
2828
+ return durablePauseDelegationReport(def ? ` · ${def.name}` : "", child);
2737
2829
  }
2738
2830
  let failureRetained = false;
2739
2831
  if (!retainEntry) {
@@ -2804,16 +2896,16 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2804
2896
  ].filter(Boolean);
2805
2897
  return {
2806
2898
  content: lines.join("\n"),
2807
- details: {
2808
- ...child,
2809
- ...(errClass ? { error_kind: errClass.errorKind, retryable: errClass.retryable } : {}),
2899
+ details: completedAgentCard(child, {
2900
+ ...(def !== undefined ? { subagentType: def.name } : {}),
2901
+ error: errClass,
2902
+ ...(modelFallback !== undefined ? { modelFallback } : {}),
2810
2903
  ...(worktreeKeptPath !== undefined ? { worktreePath: worktreeKeptPath } : {}),
2811
- ...(child.model !== undefined ? { resolvedModel: child.model } : {}),
2812
2904
  ...(() => {
2813
2905
  const ts = toolStatsCounter.snapshot();
2814
2906
  return ts !== undefined ? { toolStats: ts } : {};
2815
2907
  })(),
2816
- },
2908
+ }),
2817
2909
  };
2818
2910
  },
2819
2911
  };