@sema-agent/core 5.61.0 → 5.62.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 (40) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/brain/open-responses.js +8 -3
  3. package/dist/brain/openai.js +4 -4
  4. package/dist/brain/stream-engine.d.ts +13 -2
  5. package/dist/brain/stream-engine.js +3 -3
  6. package/dist/core/auto-mode-prompt-assets.js +1 -1
  7. package/dist/core/checkpoint-store.d.ts +36 -4
  8. package/dist/core/checkpoint-store.js +1 -0
  9. package/dist/core/governance-codes.d.ts +1 -1
  10. package/dist/core/governance-codes.js +2 -0
  11. package/dist/core/hooks.d.ts +83 -4
  12. package/dist/core/hooks.js +3 -3
  13. package/dist/core/park-selfcheck.js +2 -0
  14. package/dist/core/pricing.d.ts +24 -0
  15. package/dist/core/pricing.js +18 -0
  16. package/dist/core/runner/prepare-config-doors.d.ts +34 -0
  17. package/dist/core/runner/prepare-config-doors.js +55 -0
  18. package/dist/core/runner/prepare-task.d.ts +46 -7
  19. package/dist/core/runner/prepare-task.js +77 -42
  20. package/dist/core/runner/runtask.d.ts +7 -0
  21. package/dist/core/runner/runtask.js +198 -13
  22. package/dist/core/runner/turn-attachments.d.ts +137 -5
  23. package/dist/core/runner/turn-attachments.js +25 -2
  24. package/dist/core/store-contracts/checkpoint-store-contract.js +19 -0
  25. package/dist/core/tool-errors.d.ts +2 -1
  26. package/dist/core/tool-policy.d.ts +27 -0
  27. package/dist/core/types.d.ts +138 -12
  28. package/dist/core/untrusted-text.d.ts +5 -4
  29. package/dist/core/untrusted-text.js +8 -0
  30. package/dist/core/usage-window-store.d.ts +109 -8
  31. package/dist/core/usage-window-store.js +79 -12
  32. package/dist/orchestration/run-workflow-tool.d.ts +2 -2
  33. package/dist/orchestration/workflow.d.ts +2 -2
  34. package/dist/prompt-assembly/event-registry.js +2 -0
  35. package/dist/server/http.d.ts +1 -1
  36. package/dist/stores/file/usage-window-store.d.ts +1 -1
  37. package/dist/stores/file/usage-window-store.js +27 -6
  38. package/dist/tools/loop-tick.js +1 -1
  39. package/dist/tools/scheduler-tools.js +9 -1
  40. package/package.json +1 -1
@@ -705,10 +705,7 @@ export type ResumeOutcome = {
705
705
  * resolve CAS: a second wake of the same token loses the CAS (`checkpoint.already_resolved`). */
706
706
  | {
707
707
  gate: "wake";
708
- message?: {
709
- text: string;
710
- trusted: boolean;
711
- };
708
+ message?: PendingSteerInput;
712
709
  } | {
713
710
  gate: "task_done";
714
711
  result: TaskResult;
@@ -737,6 +734,17 @@ export type PendingAction = {
737
734
  * **UNTRUSTED, ADVISORY**: inboxes must escape contextually and surface `args` alongside.
738
735
  * NEVER read by the resume path (args + boundInputHash stay the sole execution contract). */
739
736
  preview?: unknown;
737
+ /** #457 ② (additive; no checkpoint-version bump — the `hasBidiControls` precedent below: an
738
+ * optional field an older reader ignores and the resume path never reads) — the PARK twin of
739
+ * `AskRequest.previewWithheld`: present ⇔ this tool declares an approval preview and the mint
740
+ * could not present a usable one, `"oversize"` (produced but past the 16KiB display bound — the
741
+ * stub still rides {@link preview}) or `"unavailable"` (threw / no JSON serialization). ABSENT
742
+ * covers "no preview declared", "the projection declined" and "it worked" — read presence, never
743
+ * absence. A DISCLOSURE for the approval inbox's card, never a verdict: {@link args} and
744
+ * {@link boundInputHash} are carried either way and the resume contract is untouched. Judged over
745
+ * THIS row's fidelity-projected args snapshot, so it can honestly differ from the synchronous
746
+ * face on a shape-changing backend — exactly as {@link hasBidiControls} can. */
747
+ previewWithheld?: "oversize" | "unavailable";
740
748
  /** design/179 §4, re-formed by design/375 §3.1/§3.3 (BREAKING — replaces the retired
741
749
  * `ruleSuggestions` seat; a pre-375 inbox reading a new row simply finds no offers and renders
742
750
  * no "don't ask again" option, fail toward asking) — the persistable allow-rule OFFERS this
@@ -1689,6 +1697,18 @@ export interface CheckpointSummary {
1689
1697
  * contextually escape), ECHO-ONLY (no gate/CAS/resume reads it), and surfaces SHOULD show the
1690
1698
  * bound raw args (`toolInput`/`pendingAction.args`) alongside — the preview never replaces them. */
1691
1699
  preview?: unknown;
1700
+ /** #457 ② — the row's `PendingAction.tool_approval.previewWithheld`, projected BESIDE {@link preview}
1701
+ * for the same reason that one is: without it the ONE-CALL inbox cannot tell "this tool declares no
1702
+ * preview" from "the projection failed", which is the exact collapse the determinant exists to
1703
+ * remove — and re-opening the row with an N+1 `get` just to answer it defeats `listByScope`.
1704
+ * `"oversize"` (produced but past the 16KiB mint bound — the truncation stub still rides
1705
+ * {@link preview}) or `"unavailable"` (threw / no JSON serialization). The {@link toolCallId}
1706
+ * ABSENCE-SHAPE contract applies: OMITTED otherwise, never `null`/`false`/present-but-`undefined`.
1707
+ * A pure echo of the row's own persisted value — unlike {@link hasBidiControls}, there is no
1708
+ * projection-time backfill, because whether a tool DECLARED a preview is not knowable from the
1709
+ * projection's own materials (a pre-#457 row therefore reads absent, which is honest: unreported).
1710
+ * ECHO-ONLY display metadata; nothing gates on it. */
1711
+ previewWithheld?: "oversize" | "unavailable";
1692
1712
  /**
1693
1713
  * #438: PRESENCE ONLY — this pending `tool_approval` row's payload carries at least one DIRECTIONAL
1694
1714
  * format control (the {@link import("./tool-policy.js").AskRequest.hasBidiControls} character class;
@@ -1799,6 +1819,12 @@ export declare class CheckpointError extends Error {
1799
1819
  * second operator's instruction — the exact silent loss the queue replaced. Retry with a fresh id.
1800
1820
  * Nothing is written on this path. */
1801
1821
  | "steering.duplicate_input_id"
1822
+ /** design/373 §4.3 (D2): the deployment's `userPromptSubmit` hook refused a wake resume's
1823
+ * `message` at the entrance screen (block verdict, or a fail-closed timeout/crash — the
1824
+ * message discriminates). Pre-CAS: the checkpoint stays `pending` and byte-identical, so the
1825
+ * SAME token is redeemable once the screen clears — a refusal burns nothing. The live steer
1826
+ * verb refuses under the same code word (its own Error face); one vocabulary, two entrances. */
1827
+ | "steering.blocked_by_hook"
1802
1828
  /** design/144 §3 (governance-gate purity, fail-closed pre-CAS): a `wake` outcome was supplied for a
1803
1829
  * checkpoint that awaits a PENDING GATE DECISION (tool approval / dry-run review / plan review /
1804
1830
  * resource-limit continue). Wake is a pure un-park verb and must never circumvent an approval — resume
@@ -1948,6 +1974,12 @@ export declare class CheckpointError extends Error {
1948
1974
  * second operator's instruction — the exact silent loss the queue replaced. Retry with a fresh id.
1949
1975
  * Nothing is written on this path. */
1950
1976
  | "steering.duplicate_input_id"
1977
+ /** design/373 §4.3 (D2): the deployment's `userPromptSubmit` hook refused a wake resume's
1978
+ * `message` at the entrance screen (block verdict, or a fail-closed timeout/crash — the
1979
+ * message discriminates). Pre-CAS: the checkpoint stays `pending` and byte-identical, so the
1980
+ * SAME token is redeemable once the screen clears — a refusal burns nothing. The live steer
1981
+ * verb refuses under the same code word (its own Error face); one vocabulary, two entrances. */
1982
+ | "steering.blocked_by_hook"
1951
1983
  /** design/144 §3 (governance-gate purity, fail-closed pre-CAS): a `wake` outcome was supplied for a
1952
1984
  * checkpoint that awaits a PENDING GATE DECISION (tool approval / dry-run review / plan review /
1953
1985
  * resource-limit continue). Wake is a pure un-park verb and must never circumvent an approval — resume
@@ -260,6 +260,7 @@ export function summarizeCheckpoint(cp) {
260
260
  ? { restoreMode: cp.state.workspaceHandle.restoreMode === "park_only" ? "park_only" : "snapshot" }
261
261
  : {}),
262
262
  ...(tool?.preview !== undefined ? { preview: tool.preview } : {}),
263
+ ...(tool?.previewWithheld === "oversize" || tool?.previewWithheld === "unavailable" ? { previewWithheld: tool.previewWithheld } : {}),
263
264
  ...(hasBidi ? { hasBidiControls: true } : {}),
264
265
  };
265
266
  }
@@ -93,7 +93,7 @@ export type NoticeAudience = "user" | "operator";
93
93
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
94
94
  * no longer minted.
95
95
  */
96
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "task.turn_interrupted", "tool_result.offload_put_failed"];
96
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "tool_result.offload_put_failed"];
97
97
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
98
98
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
99
99
  * sink is a supported shape, and narrowing that field would break it. */
@@ -113,6 +113,7 @@ export const ENGINE_NOTICE_CODES = [
113
113
  "route.base_url_changed_key_unchanged",
114
114
  "task.user_steer_undrained",
115
115
  "task.user_followup_undrained",
116
+ "steering.parked_input_blocked",
116
117
  "task.turn_interrupted",
117
118
  "tool_result.offload_put_failed",
118
119
  ];
@@ -126,6 +127,7 @@ const NOTICE_AUDIENCE_TABLE = {
126
127
  "task.user_steer_undrained": "user",
127
128
  "task.user_followup_undrained": "user",
128
129
  "task.turn_interrupted": "user",
130
+ "steering.parked_input_blocked": "user",
129
131
  "config.autocompact_window_clamped": "operator",
130
132
  "config.env_timeout_discarded": "operator",
131
133
  "config.materialize_env_discarded": "operator",
@@ -1,4 +1,4 @@
1
- import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
1
+ import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
3
  import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import { type AskClass } from "./ask-class.js";
@@ -85,9 +85,36 @@ export interface Hooks {
85
85
  */
86
86
  preToolUseObservational?: true;
87
87
  postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
88
+ /**
89
+ * design/373 §4.3 (D2) ⚠️ WIDENED INVOCATION SET: this seat now screens EVERY user-lane entrance,
90
+ * not just the objective — a live `TaskStream.steer`, a wake resume's message (pre-CAS, at the
91
+ * verb), and a parked steer's resume redelivery (any draining resume kind) all pass through it, discriminated by
92
+ * {@link UserPromptSubmitContext.source}. A hook that blocks unconditionally therefore starts
93
+ * refusing steers too — branch on `ctx.source` (+ `ctx.actor`) to scope a verdict. The screen's
94
+ * domain is caller/operator PROSE only: engine-authored frames (task notifications, diagnostics,
95
+ * recall) never pass here — external `notify()` text takes the sanitized DATA-frame lane instead
96
+ * (ruled: notification frames are not prompts; see the notify contract). The same input can
97
+ * legally arrive twice under two sources (live + parked redelivery) — idempotency is the hook's,
98
+ * keyed on `ctx.inputId`. Block semantics per entrance: objective ⇒ the task ends `blocked`;
99
+ * live steer ⇒ typed `steering.blocked_by_hook` to the steer caller; resume message ⇒ typed
100
+ * pre-CAS refusal (the checkpoint stays pending); parked redelivery ⇒ the frame is withheld
101
+ * with a `steering.parked_input_blocked` notice + a `delivery:"blocked"` human_input account,
102
+ * and the resume itself proceeds (a blocked instruction must never wedge a wake).
103
+ *
104
+ * ⚠️ RE-ENTRANT within one leg: the steer verb goes live the moment the run's handle publishes —
105
+ * BEFORE the objective seat runs — so a steer screen can overlap the objective screen (and a
106
+ * resume-message screen, which fires pre-CAS at the verb) in flight. A stateful implementation
107
+ * (per-call temp files, a single in-flight latch, a shared accumulator) must tolerate concurrent
108
+ * entry; only steer-vs-steer is serialized. And that serialization has a LATENCY face: queued
109
+ * steers screen strictly FIFO, so each waits behind its predecessor's ENTIRE hook bound (the
110
+ * resolved `timeoutMs`, default 600s) — a slow or wedged screen delays every steer behind it by
111
+ * up to the full bound, which is a reason to declare a short `timeoutMs` on deployments that
112
+ * screen steers.
113
+ */
88
114
  userPromptSubmit?(prompt: string,
89
115
  /** #281 件A (additive): the run/leg identity envelope — an implementation declaring only
90
- * `(prompt)` keeps working. Always supplied on the engine's emission. */
116
+ * `(prompt)` keeps working. Always supplied on the engine's emission. design/373: carries the
117
+ * entrance discriminator (`source`/`inputId`/`actor`) — see {@link UserPromptSubmitContext}. */
91
118
  ctx?: UserPromptSubmitContext): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
92
119
  /**
93
120
  * roadmap #5 (CC 198 Stop hook, :473831-): runs when the agent WOULD OTHERWISE END its run (no
@@ -819,12 +846,55 @@ export interface PostToolUseResult {
819
846
  additionalContext?: string;
820
847
  }
821
848
  /** #281 件A — the context of a {@link Hooks.userPromptSubmit} invocation (second parameter,
822
- * additive). Carries only the identity envelope today; an object so later additions stay additive. */
849
+ * additive). An object so later additions stay additive; design/373 §4.3 (D2) grew the
850
+ * entrance-discrimination trio below. */
823
851
  export interface UserPromptSubmitContext {
824
852
  /** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
825
853
  identity?: HookInvocationIdentity;
826
854
  /** {@link HookSeatSignal} — this invocation's own abort signal. */
827
855
  signal?: AbortSignal;
856
+ /**
857
+ * design/373 §4.3 (D2) — WHICH user-lane entrance this text is entering through. The screen now
858
+ * sits at every user-lane entrance, so a hook that used to see only the objective can (and, if it
859
+ * blocks unconditionally, MUST) branch on this: `"objective"` = the task's opening prompt (the
860
+ * pre-373 only caller); `"steer"` = a live mid-run TaskStream.steer; `"resume_message"` = the
861
+ * message riding a wake resume (screened at the verb, pre-CAS); `"parked_redelivery"` = a parked
862
+ * steer being re-screened on the CURRENT process's hook when a resume redelivers it — ANY resume
863
+ * kind that drains parked steers (policy_ask / dry_run_review / plan_review / resource_limit,
864
+ * wake included), because a park's screening cannot be carried across processes (hooks are
865
+ * process-local config). ⚠️ DOUBLE ARRIVAL IS CONTRACTUAL:
866
+ * the same input can reach a hook twice with different sources (screened live as `"steer"`,
867
+ * parked, re-screened as `"parked_redelivery"`); idempotency is the hook's to hold — a stateful
868
+ * hook keys on {@link inputId}. `"followUp"` is a RESERVED spelling (spec seat only): the Runner
869
+ * face has no user-lane followUp verb today, so no invocation carries it yet — if/when that face
870
+ * lands it arrives screened under this name (never a silent widening).
871
+ * Absent ⇒ an emission from a pre-373 engine (old engines stamped nothing); every current engine
872
+ * seat stamps its name, so treat absence as "version unknown", never as a sixth entrance.
873
+ */
874
+ source?: "objective" | "steer" | "resume_message" | "followUp" | "parked_redelivery";
875
+ /**
876
+ * design/373 §4.3 (D2) — the screened input's correlation id. Present on EVERY entrance except
877
+ * the objective seat (no id exists on that face — nothing is fabricated there):
878
+ * - `"steer"` — the effective `inputId` (caller-supplied or engine-minted — the SAME id the
879
+ * `human_input` event and any parked copy carry);
880
+ * - `"parked_redelivery"` — the parked entry's STORED id (stable across redeliveries);
881
+ * - `"resume_message"` — always present too: the caller's id verbatim when it supplied one,
882
+ * otherwise MINTED PER CALL by the wake validator. ⚠️ Per call, not per message: the screen
883
+ * sits above every retryable pre-CAS refusal, so a retried wake that supplies no caller id
884
+ * re-screens identical text under a FRESH id. A dedup/quota hook keying its ledger here gets
885
+ * cross-attempt stability only when the CALLER supplies the id — a host that retries wakes
886
+ * should mint its own.
887
+ * This is the dedup key for the double-arrival contract on {@link source}: a quota-charging hook
888
+ * that must not double-charge one input keys its ledger here.
889
+ */
890
+ inputId?: string;
891
+ /**
892
+ * design/373 §4.3 (D2) — the API-boundary actor snapshot attributed to this input (the same
893
+ * snapshot the `human_input` event reports), when the caller supplied one. Attribution only,
894
+ * never authority — a hook wanting to exempt its own supervisor lane branches on this PLUS
895
+ * {@link source}, and absence means "anonymous caller", exactly as on the event face.
896
+ */
897
+ actor?: ActorAssertion;
828
898
  }
829
899
  /** #281 件A — the context of a {@link Hooks.postToolBatch} invocation (third parameter, additive —
830
900
  * deliberately NOT folded into `meta`, whose presence already means "the engine injected here"). */
@@ -1199,7 +1269,16 @@ export interface ToolGateInput {
1199
1269
  /** design/375 §5.2②: the surviving ask's engine-stamped `segmentCoverage` — threaded so the park
1200
1270
  * mint's offer factory reads the same coverage snapshot on the durable route as the synchronous
1201
1271
  * mints read off the decision (absent ⇒ all segments uncovered ⇒ over-offer, the safe way). */
1202
- segmentCoverage?: readonly import("./permission-rule-model.js").SegmentCoverage[]) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
1272
+ segmentCoverage?: readonly import("./permission-rule-model.js").SegmentCoverage[],
1273
+ /** #457 ④: the surviving ask's `matchedAskRule` — threaded for the SAME errand as
1274
+ * `askDecisionReason`: the park mint's rule-suggestion door must know that an EXPLICIT `ask` rule
1275
+ * matched this call, because the persisted-rule lane refuses to clear such an ask (the
1276
+ * `decision.matchedAskRule === undefined` conjunct on its allow arm), so the parked card must not
1277
+ * offer to mint a rule that would never silence it. APPENDED at the tail deliberately: every
1278
+ * parameter here is positional and `probeReason` is also a `string`, so inserting this beside its
1279
+ * logical neighbour would silently re-bind three existing call sites' arguments with no type
1280
+ * error to catch it. */
1281
+ matchedAskRule?: string) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
1203
1282
  /**
1204
1283
  * design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
1205
1284
  * before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
@@ -841,7 +841,7 @@ export async function runToolGate(input) {
841
841
  ? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
842
842
  : undefined;
843
843
  if (suspendAsk && decision.action === "ask") {
844
- const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined, decision.action === "ask" ? decision.probeReason : undefined, decision.action === "ask" ? decision.probeCause : undefined, decision.action === "ask" ? decision.segmentCoverage : undefined);
844
+ const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined, decision.action === "ask" ? decision.probeReason : undefined, decision.action === "ask" ? decision.probeCause : undefined, decision.action === "ask" ? decision.segmentCoverage : undefined, decision.action === "ask" ? decision.matchedAskRule : undefined);
845
845
  if (suspended) {
846
846
  if ("parkFailed" in suspended)
847
847
  parkFailed = suspended.parkFailed;
@@ -860,7 +860,7 @@ export async function runToolGate(input) {
860
860
  req.args = outcome.presentedInput;
861
861
  }
862
862
  if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
863
- const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined, decision.action === "ask" ? decision.probeReason : undefined, decision.action === "ask" ? decision.probeCause : undefined, decision.action === "ask" ? decision.segmentCoverage : undefined);
863
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision), decision.action === "ask" ? decision.persistedRuleShadowed : undefined, decision.action === "ask" ? decision.decisionReason : undefined, decision.action === "ask" ? decision.probeReason : undefined, decision.action === "ask" ? decision.probeCause : undefined, decision.action === "ask" ? decision.segmentCoverage : undefined, decision.action === "ask" ? decision.matchedAskRule : undefined);
864
864
  if (suspended) {
865
865
  if ("parkFailed" in suspended)
866
866
  parkFailed = suspended.parkFailed;
@@ -892,7 +892,7 @@ export async function runToolGate(input) {
892
892
  askDenyResolution = resolved.resolution;
893
893
  decision = resolved;
894
894
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
895
- const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.segmentCoverage : undefined);
895
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.segmentCoverage : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.matchedAskRule : undefined);
896
896
  if (suspended) {
897
897
  if ("parkFailed" in suspended)
898
898
  parkFailed = suspended.parkFailed;
@@ -71,6 +71,8 @@ function syntheticCheckpoint(scope) {
71
71
  args: { probe: true, nested: { depth: 2, list: [1, 2, 3] } },
72
72
  boundInputHash: boundInputHashOf({ probe: true, nested: { depth: 2, list: [1, 2, 3] } }),
73
73
  hasBidiControls: true,
74
+ preview: { probe: true, note: "park wiring self-check display stub" },
75
+ previewWithheld: "oversize",
74
76
  batchToolCallIds: [`${scope}:call`],
75
77
  completedCallIds: [],
76
78
  },
@@ -37,6 +37,30 @@ export interface TokenCounts {
37
37
  * pricing the cache subset separately instead of trusting the brain's possibly-overstated cost total.
38
38
  */
39
39
  export declare function computeCostMicroUsd(counts: TokenCounts, pricing: ModelPricing): number;
40
+ /**
41
+ * RB-368's predicate, single-sourced: does a price table EXIST for this model — an id-keyed
42
+ * `RunnerDeps.pricing` entry, or the model's own `cost` declaration? When neither does,
43
+ * `modelCostToPricing(undefined)` yields an all-zero table and every computed cost is a fabricated 0,
44
+ * indistinguishable from "declared free". Every consumer that must keep those apart (the disclosure faces
45
+ * that go absent rather than lie; the deployment $ governance window that refuses rather than charge a
46
+ * fabricated 0) asks HERE, so the two can never drift into two different answers.
47
+ */
48
+ export declare function isModelPriced(model: {
49
+ id: string;
50
+ cost?: unknown;
51
+ }, pricing: Record<string, ModelPricing> | undefined): boolean;
52
+ /**
53
+ * The first UNEVALUABLE member of a price table (`"inputPer1M"`, …), or `undefined` when every declared
54
+ * price is a finite, non-negative number.
55
+ *
56
+ * {@link isModelPriced} answers "does a table EXIST", which is the RB-368 question. It is deliberately not
57
+ * the same question as "can this table price anything": a `NaN` rate silently computes a cost of ZERO (the
58
+ * `price && tokens` guard treats NaN as absent) and a negative rate computes a negative one, so a table of
59
+ * garbage looks priced and prices everything at nothing. Wherever a number from this table becomes a
60
+ * CEILING rather than a report, the caller must ask this too — a bad value has to be loud, never folded
61
+ * into a silent default.
62
+ */
63
+ export declare function malformedPricingField(pricing: ModelPricing): string | undefined;
40
64
  /** Map a vendor `Model.cost` (already per-1M absolute) to `ModelPricing`. We don't emit 1h cache
41
65
  * writes, so `cacheWriteLongPer1M` defaults to the 5-min write price (irrelevant while that count is 0). */
42
66
  export declare function modelCostToPricing(cost: {
@@ -11,6 +11,24 @@ export function computeCostMicroUsd(counts, pricing) {
11
11
  per1M(counts.outputTokens, pricing.outputPer1M);
12
12
  return Math.round(usd * 1e6);
13
13
  }
14
+ export function isModelPriced(model, pricing) {
15
+ return pricing?.[model.id] !== undefined || model.cost !== undefined;
16
+ }
17
+ export function malformedPricingField(pricing) {
18
+ for (const f of ["inputPer1M", "outputPer1M"]) {
19
+ const v = pricing[f];
20
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
21
+ return f;
22
+ }
23
+ for (const f of ["cacheReadPer1M", "cacheWritePer1M", "cacheWriteLongPer1M"]) {
24
+ const v = pricing[f];
25
+ if (v === undefined)
26
+ continue;
27
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
28
+ return f;
29
+ }
30
+ return undefined;
31
+ }
14
32
  export function modelCostToPricing(cost) {
15
33
  return {
16
34
  inputPer1M: cost?.input ?? 0,
@@ -46,6 +46,40 @@ export declare function limitConfigError(code: string, message: string): Error &
46
46
  * sentinel, and on the budget axes it is an exhausted window (absurd but honest).
47
47
  */
48
48
  export declare function resolveTaskLimits(limits: TaskLimits | undefined): TaskLimits | undefined;
49
+ /**
50
+ * RB-318 — validate the MODE-VALUED members of `TaskSpec.attachments` at the same door, and return the
51
+ * config unchanged. Today that is exactly one member, `totalTokensReminderMode`.
52
+ *
53
+ * WHY A DOOR AT ALL (坏值响亮度律): a knob whose bad value silently folds to the default is a knob the
54
+ * deployment believes is armed. `totalTokensReminderMode: "padded_countdown"` (underscore) or
55
+ * `"Countdown"` are the near-misses a hand-written config produces, and both would otherwise read as
56
+ * "absent" and take the default arm — a DIFFERENT readout than the one that was asked for, with nothing
57
+ * anywhere saying so.
58
+ *
59
+ * DELIBERATELY NARROW, twice over:
60
+ * - no unknown-KEY refusal (the `resolveTaskLimits` half that has no counterpart here): a wire caller
61
+ * that sends an attachment flag this engine does not know is asking for a lane that stays off, which
62
+ * is the family's own default posture — not a ceiling silently disarmed. Widening it would also be a
63
+ * behavior change for every existing caller, which this additive lane does not license.
64
+ * - the pre-existing `todoReminderMode` is NOT validated here. It has the same shape and the same
65
+ * weakness, but tightening it would refuse specs that run today; recorded as a follow-up rather than
66
+ * folded into this lane's change.
67
+ *
68
+ * A NON-OBJECT CONTAINER (`attachments: null` off a wire, above all) is neither refused nor crashed on
69
+ * (review finding, verified by reading): every consumer reads this config through `attachmentsCfg?.…`,
70
+ * so a null container already means "no lanes are wired" and the spec RUNS today. An unguarded property
71
+ * read here — the first cut — turned that running spec into an untyped `TypeError` from inside a door
72
+ * that promises a typed `config.attachment_invalid`, and refusing it instead would newly reject a spec
73
+ * that works. There is no container-shape door in this family yet; that is a separate widening, not
74
+ * something this lane gets to invent on the way past.
75
+ *
76
+ * The bypass is narrowed to values that CANNOT supply attachment fields (round-2 review finding): a
77
+ * plain-JS caller can hand this a FUNCTION carrying `totalTokensReminderMode` as a property, and every
78
+ * downstream `attachmentsCfg?.…` read would happily find it — so skipping a function here would fold a
79
+ * typo'd mode to the default through exactly the hole this door exists to close. Functions are
80
+ * property-bearing containers and are validated like objects; only `null` and primitives pass through.
81
+ */
82
+ export declare function resolveAttachmentsConfig(attachments: TaskSpec["attachments"]): TaskSpec["attachments"];
49
83
  /**
50
84
  * R2 双形轴(追加令 2026-07-18): CC 2.1.212's fable-variant prompt gate (b9e —
51
85
  * `fable_5_mitigations` capability / claude-mythos-5), ORTHOGONAL to the simple/classic profile.
@@ -1,12 +1,14 @@
1
1
  import { resolveBrainCallGuardrailMs } from "../../brain/timeout.js";
2
2
  import { assertReadFaceValue } from "../../tools/fs/index.js";
3
3
  import { resolveCheckpointStore } from "../checkpoint-store.js";
4
+ import { isModelPriced, malformedPricingField, modelCostToPricing } from "../pricing.js";
4
5
  import { preflightLockedConfig } from "../locked-config.js";
5
6
  import { assertRetentionCapability } from "../retention.js";
6
7
  import { resolveModel, resolveTaskModel, roleModelIfSet } from "../roles.js";
7
8
  import { applyToolModelGate, assertRestoreGatedToolsValue, modelIdTail } from "../tool-model-gate.js";
8
9
  import { resolveUsageWindows } from "../usage-window-store.js";
9
10
  import { deriveAskEffective, resolveAskSeamForm, resolveQuestionSeam } from "../wiring-manifest.js";
11
+ import { TOTAL_TOKENS_REMINDER_MODES } from "./turn-attachments.js";
10
12
  const TASK_LIMIT_KEY_DICT = {
11
13
  maxTokens: true,
12
14
  maxCostUsd: true,
@@ -31,6 +33,16 @@ export function limitConfigError(code, message) {
31
33
  e.code = code;
32
34
  return e;
33
35
  }
36
+ function describeRejectedValue(value) {
37
+ try {
38
+ const quoted = JSON.stringify(value);
39
+ if (quoted !== undefined)
40
+ return quoted;
41
+ }
42
+ catch {
43
+ }
44
+ return `a ${typeof value} value`;
45
+ }
34
46
  export function resolveTaskLimits(limits) {
35
47
  if (limits === undefined)
36
48
  return undefined;
@@ -73,6 +85,19 @@ export function resolveTaskLimits(limits) {
73
85
  }
74
86
  return limits;
75
87
  }
88
+ export function resolveAttachmentsConfig(attachments) {
89
+ if (attachments === null || (typeof attachments !== "object" && typeof attachments !== "function"))
90
+ return attachments;
91
+ const mode = attachments.totalTokensReminderMode;
92
+ if (mode !== undefined) {
93
+ const legal = TOTAL_TOKENS_REMINDER_MODES;
94
+ if (typeof mode !== "string" || !legal.includes(mode)) {
95
+ throw limitConfigError("config.attachment_invalid", `TaskSpec.attachments.totalTokensReminderMode must be one of ${TOTAL_TOKENS_REMINDER_MODES.map((m) => `"${m}"`).join(", ")} ` +
96
+ `(got ${describeRejectedValue(mode)}) — refused rather than folded to the default, which would publish a different readout than the one the deployment asked for.`);
97
+ }
98
+ }
99
+ return attachments;
100
+ }
76
101
  export function isFableFamilyModelId(id) {
77
102
  const tail = modelIdTail(id);
78
103
  return /^claude-fable-\d/.test(tail) || /^claude-mythos-5(?!\d)/.test(tail);
@@ -290,6 +315,7 @@ export function prepareConfigDoors(input) {
290
315
  }
291
316
  }
292
317
  resolveTaskLimits(spec.limits);
318
+ resolveAttachmentsConfig(spec.attachments);
293
319
  if (spec.resourceSuspend !== undefined) {
294
320
  const rsus = spec.resourceSuspend;
295
321
  if (typeof rsus.scope !== "string" || rsus.scope === "") {
@@ -327,6 +353,35 @@ export function prepareConfigDoors(input) {
327
353
  }
328
354
  const resolvedRole = resolveTaskModel(spec, deps);
329
355
  const model = resolvedRole.model;
356
+ if (usageWindows !== undefined && deps.usageWindowStore !== undefined) {
357
+ const costed = usageWindows.find((w) => w.maxCostUsd !== undefined);
358
+ const why = costed === undefined
359
+ ? undefined
360
+ : !isModelPriced(model, deps.pricing)
361
+ ? `model ${JSON.stringify(model.id)} is unpriced — it has neither a RunnerDeps.pricing entry nor a Model.cost declaration, so this run produces NO cost figure to charge the window with (an unpriced run has no cost, which is not the same as a cost of 0). Price the model, or drop maxCostUsd from the window.`
362
+ : (() => {
363
+ const bad = malformedPricingField(deps.pricing?.[model.id] ?? modelCostToPricing(model.cost));
364
+ return bad === undefined
365
+ ? undefined
366
+ : `the price table for model ${JSON.stringify(model.id)} declares an unevaluable ${bad} — every rate must be a finite, non-negative number of USD per 1M tokens. A table like this prices every turn at zero (or below), so the ceiling would never fill: refused rather than enforced in name only.`;
367
+ })();
368
+ if (costed !== undefined && why !== undefined) {
369
+ const e = new Error(`RunnerDeps.usageWindows declares a cost ceiling (maxCostUsd ${String(costed.maxCostUsd)} over ${String(costed.windowMs)}ms, ${costed.anchor}) but ${why}`);
370
+ e.code = "config.usage_window_unpriced";
371
+ throw e;
372
+ }
373
+ }
374
+ const dollarCeiling = spec.limits?.maxCostUsd !== undefined
375
+ ? "limits.maxCostUsd"
376
+ : spec.resourceSuspend?.totalBudgetUsd !== undefined
377
+ ? "resourceSuspend.totalBudgetUsd"
378
+ : undefined;
379
+ if (dollarCeiling !== undefined && isModelPriced(model, deps.pricing)) {
380
+ const bad = malformedPricingField(deps.pricing?.[model.id] ?? modelCostToPricing(model.cost));
381
+ if (bad !== undefined) {
382
+ throw limitConfigError("config.limit_invalid", `TaskSpec.${dollarCeiling} is a money ceiling, but the price table for model ${JSON.stringify(model.id)} declares an unevaluable ${bad} — every rate must be a finite, non-negative number of USD per 1M tokens. A table like this prices every turn at zero (or below), so the ceiling would never trip: refused rather than enforced in name only.`);
383
+ }
384
+ }
330
385
  const fableMitigations = resolveModelPromptTraits(model, spec, internals).fableMitigations;
331
386
  const gateDecision = applyToolModelGate({
332
387
  tools: spec.tools,
@@ -73,14 +73,24 @@ export declare const USAGE_WINDOW_REAP_MARGIN_MS: number;
73
73
  export interface UsageGovernance {
74
74
  /** The ledger key this run is governed under — the principal, or the shared global key. */
75
75
  readonly key: string;
76
+ /** Does any governed window carry a MONEY ceiling? Read by the run loop's pricing seats: only a
77
+ * cost-governing deployment has to treat an unevaluable price table as unpriced spend. */
78
+ readonly governsCost: boolean;
76
79
  /** Ms the caller must wait before ANY window would admit work again, or `undefined` when none is
77
80
  * exhausted as of `now`. Reads the ledger; propagates a store failure (an unreadable ceiling must not
78
81
  * read as an open one). */
79
82
  check(now: number): Promise<number | undefined>;
80
- /** Charge whatever of `cumulativeTokens` has not been charged yet. Takes the run's CUMULATIVE total
81
- * rather than a delta so no caller can double-charge by calling twice, and so a caller that skips a
82
- * boundary loses nothing. */
83
- commit(cumulativeTokens: number, now: number): Promise<void>;
83
+ /**
84
+ * Charge whatever of the run's cumulative spend has not been charged yet. Takes the run's CUMULATIVE
85
+ * totals rather than deltas so no caller can double-charge by calling twice, and so a caller that skips
86
+ * a boundary loses nothing.
87
+ *
88
+ * `cumulativeCostMicroUsd` is the MONEY half (integer micro-USD, `stats.costMicroUsd`). Pass `undefined`
89
+ * when the run's spend has no cost figure at all (RB-368's unpriced state) — a deployment governing a
90
+ * `maxCostUsd` window then REFUSES here rather than charging the fabricated 0 that would let the ceiling
91
+ * silently stop applying. A token-only deployment ignores the argument entirely.
92
+ */
93
+ commit(cumulativeTokens: number, cumulativeCostMicroUsd: number | undefined, now: number): Promise<void>;
84
94
  }
85
95
  /**
86
96
  * design/164 件四 — resolve the moment an env's lifetime EXPIRES (epoch ms), or `undefined` when the env
@@ -133,7 +143,7 @@ export declare function checkpointScopeOf(spec: {
133
143
  principal?: string;
134
144
  }): string;
135
145
  export { resolveCheckpointStore } from "../checkpoint-store.js";
136
- export { isFableFamilyModelId, resolveModelPromptTraits, resolveTaskLimits } from "./prepare-config-doors.js";
146
+ export { isFableFamilyModelId, resolveAttachmentsConfig, resolveModelPromptTraits, resolveTaskLimits } from "./prepare-config-doors.js";
137
147
  export { rebaseWorkspacePath, rebaseWorkspacePathAcross } from "./prepare-workspace-restore.js";
138
148
  export interface Prepared {
139
149
  harness: AgentHarness;
@@ -1463,8 +1473,8 @@ export interface RunInternals {
1463
1473
  * receive a subagent's live `task_progress` ticks (which otherwise stay in the child's ISOLATED stream). Threaded
1464
1474
  * recursively down the delegation tree (via `ctx.forwardEvent`), so every nested subagent's ticks bubble to the
1465
1475
  * SAME sink. The Runner's ctx wrapper forwards `task_progress` always; when the run's spec sets
1466
- * `forwardSubagentEvents: true` it ALSO forwards the child's content events (`text_delta` / `reasoning_delta` /
1467
- * `tool_start` / `tool_end` — the subagent viewing pane, carrying the same UNTRUSTED-RAW/consumer-must-redact
1476
+ * `forwardSubagentEvents: true` it ALSO forwards the child's content events (`text_delta` / `text_end` (#447) /
1477
+ * `reasoning_delta` / `tool_start` / `tool_end` — the subagent viewing pane, carrying the same UNTRUSTED-RAW/consumer-must-redact
1468
1478
  * contract as the main stream's tool events). Either way the child stream is NEVER merged into the parent's
1469
1479
  * MODEL context (this is purely a render channel). Absent unless the deployment opted in.
1470
1480
  */
@@ -1629,6 +1639,35 @@ export interface ResolvedWorkspace {
1629
1639
  * which `cwd` is a host path a consumer may diff / merge / remove. */
1630
1640
  remote: boolean;
1631
1641
  }
1642
+ /**
1643
+ * RB-330 + 5.38 r2 件2 — the SINGLE effective-delegation derivation for a leg, minted once per prepare
1644
+ * and read by EVERY consumer face; two facets, one source:
1645
+ *
1646
+ * - `isDelegatedChild` — the raw delegation fact, forks included. Trusted internals first; on a
1647
+ * resume leg where the caller supplied NO delegation fact, the checkpoint's persisted axis
1648
+ * ({@link import("../checkpoint-store.js").CheckpointState.isDelegatedChild}) stands in — else a
1649
+ * deps-only `resume(token, outcome, config)` of a parked delegation read `false` at every station:
1650
+ * persona flip (subagent consent/notes pack dropped), ask refusal texts on the parent-thread arm
1651
+ * ("wait for the user" in a transcript no user turn ever lands in), and a lifecycle-observer gate
1652
+ * that never opened. Consumed by `askSourceIdentity` (a fork's refusal posture is the child's) and
1653
+ * the `hookIdentity` mint.
1654
+ * - `isNonForkChild` — the fact minus forks, for the authority/context faces: the `loadProjectMemory`
1655
+ * ctx flag ({@link RunnerDeps.loadProjectMemory} `isSubagent`) and the prompt runtime fact
1656
+ * `isSubagent` (RB-204's `SUBAGENT_CONSENT_NOTICE` gate). A FORK is excluded on both, deliberately:
1657
+ * a fork IS the parent continuing (design/110), so it inherits the parent's authority — and, on the
1658
+ * memory face, the parent's own project context; trimming a fork's CLAUDE.md would hand the
1659
+ * continuation LESS context than the run it continues. (RB-330 history: the memory face once used
1660
+ * `parentTaskId !== undefined` alone, which {@link RunInternals.isDelegatedChild}'s own docstring
1661
+ * forbids — a directly-started workflow's children have no nameable parent.)
1662
+ *
1663
+ * `insideFork` is NOT persisted (the checkpoint's documented honest absence), so on a deps-only resume
1664
+ * a FORK child reads as a plain delegated child on the non-fork facet — the honest residue is written
1665
+ * on the checkpoint field's own list, not papered over with a new persistence axis.
1666
+ */
1667
+ export declare function effectiveDelegationFacts(internals: Pick<RunInternals, "isDelegatedChild" | "insideFork"> | undefined, seedIsDelegatedChild: boolean | undefined): {
1668
+ isDelegatedChild: boolean;
1669
+ isNonForkChild: boolean;
1670
+ };
1632
1671
  /**
1633
1672
  * From the resumed/active transcript, the batch position of `currentId` (design/45 §4.ter): the tool-call
1634
1673
  * ids of the assistant message that issued it (the batch), and the subset already resolved (executed