@sema-agent/core 5.59.0 → 5.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/brain/anthropic.js +15 -5
  3. package/dist/brain/errors.d.ts +18 -1
  4. package/dist/brain/errors.js +7 -1
  5. package/dist/brain/input-too-long.d.ts +57 -0
  6. package/dist/brain/input-too-long.js +35 -0
  7. package/dist/brain/stream-engine.js +9 -1
  8. package/dist/core/auto-compaction.js +2 -2
  9. package/dist/core/checkpoint-store.d.ts +172 -25
  10. package/dist/core/checkpoint-store.js +15 -8
  11. package/dist/core/context-edit.d.ts +243 -41
  12. package/dist/core/context-edit.js +247 -32
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +5 -0
  15. package/dist/core/locked-config.d.ts +36 -4
  16. package/dist/core/locked-config.js +34 -1
  17. package/dist/core/mcp.d.ts +4 -5
  18. package/dist/core/mcp.js +10 -6
  19. package/dist/core/memory-engine/content-origin.d.ts +24 -2
  20. package/dist/core/memory-engine/content-origin.js +6 -1
  21. package/dist/core/memory-engine/engine.d.ts +5 -6
  22. package/dist/core/memory.d.ts +10 -0
  23. package/dist/core/park-selfcheck.js +1 -0
  24. package/dist/core/permission-rule-consent.js +9 -5
  25. package/dist/core/runner/prepare-config-doors.d.ts +22 -1
  26. package/dist/core/runner/prepare-config-doors.js +36 -0
  27. package/dist/core/runner/prepare-task.d.ts +28 -1
  28. package/dist/core/runner/prepare-task.js +107 -8
  29. package/dist/core/runner/runtask.js +97 -9
  30. package/dist/core/store-contracts/checkpoint-store-contract.js +32 -0
  31. package/dist/core/tool-policy.d.ts +32 -10
  32. package/dist/core/tool-policy.js +3 -3
  33. package/dist/core/tools.js +1 -1
  34. package/dist/core/trace.d.ts +36 -0
  35. package/dist/core/types.d.ts +127 -2
  36. package/dist/core/untrusted-text.d.ts +11 -0
  37. package/dist/core/untrusted-text.js +1 -0
  38. package/dist/engine/llm/types.d.ts +21 -2
  39. package/dist/engine/loop/agent-loop.js +7 -1
  40. package/dist/engine/loop/types.d.ts +4 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/tools/fs/fs-bash.js +1 -2
  44. package/package.json +1 -1
  45. package/test/export-surface.snapshot.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.60.1 — 2026-08-25
4
+
5
+ ### Fixed
6
+ - **Resume-leg usage-governance holes (#449, P0/P1)**: a resume no longer burns a full model turn
7
+ when the usage window is exhausted — an owed row (approval/review decision, wake, parked steer,
8
+ bg snapshot) refuses PRE-CAS with the new `resume.usage_window_exhausted` CheckpointError
9
+ (`detail.retryAfterMs`; the row stays pending, zero consumption, the same token redeems after the
10
+ window); a bare resource_limit continue re-suspends at turn 0 through the same platform-limit leg
11
+ the turn boundary uses (fresh checkpoint, new `resumeAfterMs`, zero model calls). The resume
12
+ ledger key now falls back to the checkpoint-pinned principal (`resource_limit` mints stamp
13
+ `cp.principal`) — a caller that omits principal no longer silently charges the GLOBAL bucket and
14
+ collects a fresh quota; supplying a DIFFERENT principal refuses loudly
15
+ (`resume.principal_mismatch`, pre-CAS, row pending). NARROWING, named: mismatched-principal
16
+ resumes previously proceeded under the wrong identity.
17
+ - **covering-ask audit arm (#450, core half)**: the prepare-time policy-name audit gains ask-slot
18
+ coverage pins (the exemption for `mcp__<server>__*` spellings now proves it speaks the truth
19
+ against a live in-engine ask lane); `isNamespacedCoveringRuleName`/`namespacedRuleNameCovers`
20
+ root-exported (additive) so out-of-engine name-set publishers can self-check the covering grammar
21
+ (the fifth-publisher fail-open shape stays pinned as-is until the server-side consumer lands its
22
+ fix). Export surface 1758 → 1760.
23
+
24
+ ## 5.60.0 — 2026-08-25
25
+
26
+ ### Added
27
+ - **CC clearing machine + micro-compact recovery (design/374 slices 1a/1b/2/4; every default byte-identical)**:
28
+ `ContextEditOptions.machine: "legacy" | "cc"` selects the clearing machine — `cc` is CC's rejection-leg
29
+ form carried whole (keep 5, a >=20000-token minimum-savings gate with the field-exact `Sby` estimator,
30
+ one deep clear, a window base over ALL compactable occurrences, five cleared-predicate forms);
31
+ `RunnerDeps.microCompact { machine, clearOnRejection }` (both default off). A typed `input_too_long`
32
+ cause classifies real provider context refusals (position-0 anchored family over the parsed envelope
33
+ diagnostic; reflected copies stay ruled out) and feeds an MC-R recovery arm at the head of the
34
+ recovery chain (attempt-1 latch; plan over the rejected projection; a projection LEDGER — occurrence
35
+ keys (toolCallId, timestamp, dup ordinal), new-machine writes only — is the clear's sole survival
36
+ channel across per-request rebuilds). `resolveTriggerWindow` becomes the one trigger-window source
37
+ (four consumers); a clamped window announces `config.autocompact_window_clamped` once per task.
38
+ Trace frames `context.mc_clear`/`context.mc_null`. Nine CC divergences registered (cc-parity ledger).
39
+ - **Durable park rows carry the bidi twin (design/377, #438)**: `PendingAction.tool_approval.hasBidiControls?: true`
40
+ minted at park over the fidelity-projected args snapshot + preview; `CheckpointSummary.hasBidiControls`
41
+ projected, bit-less legacy rows BACKFILLED at projection from their own bounded materials (32K units /
42
+ 512 nodes per row; never written back; a stored `false` reads as absent). Checkpoint version unchanged.
43
+ - **MCP entry-level content-trust declaration (design/378, #439; default byte-identical)**:
44
+ `McpServerSpec.contentOrigin?: "local" | "execution" | "external"` (the existing ToolContentOrigin
45
+ vocabulary), minted once at intake from a frozen copy; `memory.content_class_declared` announces one
46
+ per-value line per declared entry per prepared leg (explicit `"external"` announces too — it
47
+ forecloses the trustedTools exemption); malformed declarations refuse at preparation
48
+ (`config.mcp_content_class`).
49
+
50
+ ### Changed — narrowings (named)
51
+ - **Inbox display family goes Cf-safe (two byte generations)**: descriptor-owned `RiskDescriptor` seats
52
+ (`summary` both arms, `touchedPaths`, `shadowedRule`) render through `renderUntrustedCommandText` —
53
+ newly-minted rows strip the `\p{Cf}` format class (emoji ZWJ sequences display decomposed); persisted
54
+ rows keep their old bytes forever. Byte-pinning consumers must read per row generation.
55
+ - **Confirm-face refusal messages stop interpolating raw command bytes (#430)**: >120-code-point
56
+ commands cap with an ellipsis, control bytes render `\uXXXX`; short clean commands byte-identical.
57
+ Two `record_malformed` damage sentences escape record-sourced values.
58
+ - **Vocabulary-outside `declared` content origins now classify `external`** (previously escaped marking
59
+ silently — a fail-open hole closed; untyped host-mounted tools with garbage declarations are the
60
+ affected shape).
61
+ - **`approvalRecordDamageOf` gains the reverse integrity clause (#441)**: an orphan candidate row
62
+ (belonging to no offer) answers `record_malformed` at all three consent entries.
63
+
3
64
  ## 5.59.0 — 2026-08-24
4
65
 
5
66
  ### BREAKING (narrow, named)
@@ -1,5 +1,6 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
+ import { startsWithInputTooLongAnchor } from "./input-too-long.js";
3
4
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
5
  import { createRepetitionPoll, parseStreamedToolArgs } from "./stream-shared.js";
5
6
  import { mintFallbackToolCallId } from "./tool-call-id.js";
@@ -178,6 +179,8 @@ function anthropicStreamErrorCode(t) {
178
179
  return "auth";
179
180
  case "invalid_request_error":
180
181
  return "invalid_request";
182
+ case "request_too_large":
183
+ return "invalid_request";
181
184
  default:
182
185
  return "server";
183
186
  }
@@ -501,8 +504,13 @@ export function createAnthropicBrain(config = {}) {
501
504
  break;
502
505
  }
503
506
  case "error": {
504
- const e = evt.error;
505
- throw new BrainError(anthropicStreamErrorCode(e?.type), `anthropic stream error (${e?.type ?? "unknown"}): ${e?.message ?? ""}`);
507
+ const rawError = evt.error;
508
+ const e = typeof rawError === "object" && rawError !== null && !Array.isArray(rawError)
509
+ ? rawError
510
+ : undefined;
511
+ const inputTooLong = e?.type === "request_too_large" ||
512
+ (e?.type === "invalid_request_error" && typeof e.message === "string" && startsWithInputTooLongAnchor(e.message));
513
+ throw new BrainError(anthropicStreamErrorCode(e?.type), `anthropic stream error (${e?.type ?? "unknown"}): ${e?.message ?? ""}`, undefined, e === undefined ? undefined : inputTooLong ? { inputTooLong: true } : { inputTooLongRuledOut: true });
506
514
  }
507
515
  default:
508
516
  break;
@@ -654,9 +662,11 @@ export function createAnthropicBrain(config = {}) {
654
662
  (truncatedEmpty && !dynamicCut && !staticReasoningCut);
655
663
  const errorKind = degenerate
656
664
  ? "degenerate"
657
- : !safetyCut && toolError === undefined && truncatedEmpty && !dynamicCut && !staticReasoningCut
658
- ? "length_empty"
659
- : undefined;
665
+ : !safetyCut && contextWindowExceeded
666
+ ? "input_too_long"
667
+ : !safetyCut && toolError === undefined && truncatedEmpty && !dynamicCut && !staticReasoningCut
668
+ ? "length_empty"
669
+ : undefined;
660
670
  const finalMessage = {
661
671
  ...partial,
662
672
  content: finalContent.length > 0 ? finalContent : [{ type: "text", text: "" }],
@@ -34,7 +34,24 @@ export declare const IN_BAND_OUTPUT_CODES: ReadonlySet<BrainErrorCode>;
34
34
  export declare class BrainError extends Error {
35
35
  readonly code: BrainErrorCode;
36
36
  readonly status?: number;
37
- constructor(code: BrainErrorCode, detail: string, status?: number);
37
+ /** design/374 slice 2 — STRUCTURAL cause seat: the provider stated (in a provenance-checked
38
+ * diagnostic field or status, see `input-too-long.ts`) that the request INPUT exceeds its
39
+ * context limit. The engine's terminal-shell mint lifts it onto `AssistantMessage.errorKind:
40
+ * "input_too_long"`, which the loop's prompt-too-long classifier reads instead of regexing the
41
+ * human-facing message. Never inferred from `detail` prose after construction. */
42
+ readonly inputTooLong?: true;
43
+ /** The AUTHORITATIVE-NEGATIVE half (adversarial-review r1): the classifier RAN with provenance
44
+ * and this failure is NOT input-too-long (wrong status family, or the provider's own envelope
45
+ * states a different error). Lifted onto `AssistantMessage.inputTooLongRuledOut`, which
46
+ * suppresses the loop's prose fallback — a reflected "prompt is too long" inside an unrelated
47
+ * error body must not drive a context-shrinking recovery. Mutually exclusive with
48
+ * `inputTooLong`; BOTH absent = classification could not establish provenance ("unknown"),
49
+ * and the prose fallback keeps its historic reach. */
50
+ readonly inputTooLongRuledOut?: true;
51
+ constructor(code: BrainErrorCode, detail: string, status?: number, opts?: {
52
+ inputTooLong?: boolean;
53
+ inputTooLongRuledOut?: boolean;
54
+ });
38
55
  }
39
56
  /** Map an HTTP status to an error class. 401/403 = auth (don't retry); 429 = rate limit; 5xx = server. */
40
57
  export declare function classifyHttp(status: number): BrainErrorCode;
@@ -12,11 +12,17 @@ export const IN_BAND_OUTPUT_CODES = new Set(["stream_torn", "refusal", "length_e
12
12
  export class BrainError extends Error {
13
13
  code;
14
14
  status;
15
- constructor(code, detail, status) {
15
+ inputTooLong;
16
+ inputTooLongRuledOut;
17
+ constructor(code, detail, status, opts) {
16
18
  super(`[${code}] ${detail}`);
17
19
  this.name = "BrainError";
18
20
  this.code = code;
19
21
  this.status = status;
22
+ if (opts?.inputTooLong === true)
23
+ this.inputTooLong = true;
24
+ else if (opts?.inputTooLongRuledOut === true)
25
+ this.inputTooLongRuledOut = true;
20
26
  }
21
27
  }
22
28
  export function classifyHttp(status) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * design/374 slice 2 — TYPED input-too-long classification for provider refusals.
3
+ *
4
+ * "The request INPUT alone exceeds the model's context limit" used to be recognized only by the
5
+ * loop's prose regex over the human-facing `errorMessage` — a classifier that (a) cannot tell a
6
+ * provider's own diagnostic from caller text a gateway reflected, and (b) breaks the moment a
7
+ * provider rewords. This module classifies at the BRAIN boundary, where provenance is decidable,
8
+ * and the verdict rides `BrainError.inputTooLong` → `AssistantMessage.errorKind: "input_too_long"`
9
+ * (the same typed lane `degenerate`/`length_empty` already use).
10
+ *
11
+ * PROVENANCE DISCIPLINE (mirrors `context-overflow.ts`, the sibling classifier for the
12
+ * input+max_tokens overflow — a DIFFERENT error: there the input fits and lowering the output cap
13
+ * self-heals; here the input alone does not fit and only shrinking the input helps):
14
+ * - The only text source is the provider's diagnostic FIELD: `error.message` of a JSON object
15
+ * body whose `error` is itself an object (both the Anthropic `{type:"error",error:{…}}` and
16
+ * the OpenAI `{error:{…}}` envelopes reduce to this). A plain-text body, a string `error`, a
17
+ * top-level `message` — refused: each is a guess about gateway formatting that would trade a
18
+ * hypothetical classification for a field a gateway may fill with caller content.
19
+ * - Prose is matched ANCHORED at the start of that field only, against the conservative
20
+ * context-limit FAMILY below (case-INsensitive since the r2 family form — the family note owns
21
+ * the reasoning and the member list). A gateway that inlines the offending request
22
+ * ("validation failed; offending prompt: prompt is too long…") therefore cannot classify:
23
+ * caller text must never steer a retry.
24
+ * - Structural signals need no prose at all: OpenAI-family `error.code ===
25
+ * "context_length_exceeded"`, and HTTP 413 (the transport's own "request entity too large").
26
+ *
27
+ * Misclassification cost note (updated with the r1 three-state verdict — the original "a miss
28
+ * keeps exactly the pre-374 behavior" claim died with the binary form): inside a PARSED provider
29
+ * envelope a miss is an AUTHORITATIVE negative that SUPPRESSES the loop's prose fallback — which
30
+ * is why the family must carry every observed genuine opening (the r2/r3/r4 additions below);
31
+ * prose-only bodies keep the pre-374 fallback reach via "unknown". A FALSE HIT costs one bounded
32
+ * recovery attempt — but only on a 400/413/422 the request already failed on.
33
+ */
34
+ /** True when the (provider-authored) diagnostic OPENS with the context-limit refusal family. */
35
+ export declare function startsWithInputTooLongAnchor(diagnostic: string): boolean;
36
+ /**
37
+ * Three-state classification verdict (adversarial-review r1: a binary answer conflated "the
38
+ * provider said something ELSE" with "we could not read the provider", and the loop's prose
39
+ * fallback then re-opened the reflected-text hole for exactly the errors this module HAD
40
+ * examined):
41
+ * - `"yes"` — provenance-checked input-too-long; the caller stamps the typed cause.
42
+ * - `"no"` — the classification is AUTHORITATIVE-NEGATIVE: either the status can never carry
43
+ * this refusal (5xx, auth, rate limit — reflected prose in those bodies must not
44
+ * drive a context-shrinking recovery), or the provider's own diagnostic envelope
45
+ * parsed and states a different error. The caller marks the terminal shell
46
+ * ruled-out, which SUPPRESSES the loop's prose fallback for this message.
47
+ * - `"unknown"` — a PTL-capable status whose body established no provenance (text/plain gateway,
48
+ * truncated read): nothing is stamped either way, and the loop's historic prose
49
+ * fallback keeps working for gateways that speak prose only.
50
+ */
51
+ export type InputTooLongVerdict = "yes" | "no" | "unknown";
52
+ /**
53
+ * Classify an HTTP refusal as input-too-long. `body` is the (bounded) error-body text as read;
54
+ * a truncated read that no longer parses as JSON answers `"unknown"` (413 excepted — the status
55
+ * alone is the provider's statement).
56
+ */
57
+ export declare function classifyInputTooLong(status: number, body: string): InputTooLongVerdict;
@@ -0,0 +1,35 @@
1
+ const INPUT_TOO_LONG_ANCHORED_RE = /^(?:prompt is too long|input is too long|this model's maximum context length is |context.{0,3}length|context window (?:limit|exceeded)|reached its context window|too many tokens|maximum context|input length and `max_tokens` exceed context limit|(?:the )?request(?: \([^)]{0,40}\))? exceeds|(?:the )?input token count(?: \([^)]{0,40}\))? exceeds|requested tokens(?: \([^)]{0,40}\))? exceed|your input exceeds|(?:the )?number of tokens in the request exceeds)/i;
2
+ const INPUT_TOO_LONG_STATUSES = new Set([400, 413, 422]);
3
+ export function startsWithInputTooLongAnchor(diagnostic) {
4
+ return INPUT_TOO_LONG_ANCHORED_RE.test(diagnostic.trimStart());
5
+ }
6
+ function providerErrorObject(body) {
7
+ const trimmed = body.trim();
8
+ if (!trimmed.startsWith("{"))
9
+ return undefined;
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(trimmed);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
18
+ return undefined;
19
+ const err = parsed.error;
20
+ if (typeof err !== "object" || err === null || Array.isArray(err))
21
+ return undefined;
22
+ return err;
23
+ }
24
+ export function classifyInputTooLong(status, body) {
25
+ if (!INPUT_TOO_LONG_STATUSES.has(status))
26
+ return "no";
27
+ if (status === 413)
28
+ return "yes";
29
+ const err = providerErrorObject(body);
30
+ if (err === undefined)
31
+ return "unknown";
32
+ if (err.code === "context_length_exceeded")
33
+ return "yes";
34
+ return typeof err.message === "string" && startsWithInputTooLongAnchor(err.message) ? "yes" : "no";
35
+ }
@@ -1,6 +1,7 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
3
3
  import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError } from "./errors.js";
4
+ import { classifyInputTooLong } from "./input-too-long.js";
4
5
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
5
6
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
6
7
  import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
@@ -258,6 +259,12 @@ export function runStreamingBrain(args) {
258
259
  const errorMsg = emptyAssistant(model);
259
260
  errorMsg.stopReason = aborted ? "aborted" : "error";
260
261
  errorMsg.errorMessage = err instanceof Error ? err.message : String(err);
262
+ if (!aborted && err instanceof BrainError) {
263
+ if (err.inputTooLong === true)
264
+ errorMsg.errorKind = "input_too_long";
265
+ else if (err.inputTooLongRuledOut === true)
266
+ errorMsg.inputTooLongRuledOut = true;
267
+ }
261
268
  errorMsg.usageMissing = true;
262
269
  out.push({ type: "error", reason: aborted ? "aborted" : "error", error: errorMsg });
263
270
  })
@@ -429,7 +436,8 @@ export function runStreamingBrain(args) {
429
436
  throw new BrainError("network", describeNetworkError(netErr));
430
437
  const detail = overflowBody ?? (r ? (await readErrorBody(r, signal)).text : "");
431
438
  const status = r?.status ?? 0;
432
- throw new BrainError(classifyHttp(status), `${httpLabel} HTTP ${status || "ERR"}: ${detail.slice(0, 500)}${overflowDeclineNote}`, status);
439
+ const itl = classifyInputTooLong(status, detail);
440
+ throw new BrainError(classifyHttp(status), `${httpLabel} HTTP ${status || "ERR"}: ${detail.slice(0, 500)}${overflowDeclineNote}`, status, itl === "yes" ? { inputTooLong: true } : itl === "no" ? { inputTooLongRuledOut: true } : undefined);
433
441
  }
434
442
  finally {
435
443
  cc.abort();
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_CHARS_PER_TOKEN, DEFAULT_CLAMP_TOLERANCE, DEFAULT_COMPACTION_SETTINGS, CompactionError, compact, computeFileLists, dryRunSummarizationClamp, estimateContextTokens, estimateTokens, formatFileOperations, formatPersistedOutputRefs, prepareCompaction, shouldCompact, } from "../internal/harness.js";
2
2
  import { fileArgPath } from "../tools/fs/safety.js";
3
- import { contextEditFrontier } from "./context-edit.js";
3
+ import { contextEditFrontier, resolveTriggerWindow } from "./context-edit.js";
4
4
  import { selectCompactionEpoch } from "../prompt-assembly/epoch.js";
5
5
  import { SEMA_DEFAULT_PACK } from "../prompt-assembly/packs/sema-default.js";
6
6
  import { brainToRuntime } from "./runtime.js";
@@ -81,7 +81,7 @@ export async function maybeCompact(opts) {
81
81
  const structuralTokens = ctx.messages.reduce((sum, m) => sum + estimateTokens(m, cpt), 0);
82
82
  const anchorStale = est.usageTokens > 0 && est.tokens > structuralTokens * STALE_ANCHOR_STRUCTURAL_MARGIN + overhead;
83
83
  const tokens = anchorStale || est.usageTokens === 0 ? structuralTokens + overhead : est.tokens;
84
- const window = opts.model.autoCompactTokens ?? opts.model.contextTokens ?? opts.model.contextWindow;
84
+ const window = resolveTriggerWindow(opts.model).window;
85
85
  const windowKnown = Number.isFinite(window) && window > 0;
86
86
  const windowField = opts.model.autoCompactTokens != null ? "autoCompactTokens" : opts.model.contextTokens != null ? "contextTokens" : "contextWindow";
87
87
  const settings = sanitizeCompactionSettings(rawSettings, window);
@@ -135,19 +135,41 @@ export interface RiskDescriptor {
135
135
  * answer that in the field. */
136
136
  shellGateDoctrine?: "classify" | "always";
137
137
  /** A REDACTED, length-capped ONE-LINE summary of the call (the command for a shell gate; a brief key-arg
138
- * digest otherwise) for the inbox preview. NEUTRALIZED via {@link import("./untrusted-text.js").inlineUntrusted}
139
- * + length-capped (it is persisted + shown to a human inbox, so a crafted tool arg carrying a
140
- * `</system-reminder>` variant / newline / huge string must NOT inject into the render or bloat storage).
138
+ * digest otherwise) for the inbox preview. NEUTRALIZED via
139
+ * {@link import("./permission-rule-model.js").renderUntrustedCommandText} + length-capped (it is
140
+ * persisted + shown to a human inbox, so a crafted tool arg carrying a `</system-reminder>` variant /
141
+ * newline / huge string must NOT inject into the render or bloat storage — and, since #438, a
142
+ * directional/zero-width `\p{Cf}` marker cannot ride into the render either: the display baseline
143
+ * STRIPS the format class, so what the line shows is what the args carry, in the order they carry it).
144
+ * GENERATIONAL, like every mint-time seat: the summary is minted ONCE at park mint and persisted, so
145
+ * rows minted before the #438 render change keep their old bytes (which may carry `\p{Cf}`) forever —
146
+ * a consumer must not assume one byte-form across row generations, and the display obligation
147
+ * (render through the baseline, or screen) is the same for both. One benign visible consequence on
148
+ * new rows: an emoji ZWJ sequence in a command displays decomposed here (raw bytes stay in
149
+ * `pendingAction.args`/`toolInput` — this seat is DISPLAY, not identity).
141
150
  * NEVER raw secrets / full args / env. Deterministic. Optional (omitted when nothing safe to summarize). */
142
151
  summary?: string;
143
152
  /** Best-effort file paths the action touches (fs-tool `path` args; shell parsing is deliberately NOT
144
- * attempted — over-reaching a shell parse risks a wrong/forgeable path). Each path `inlineUntrusted`-capped.
153
+ * attempted — over-reaching a shell parse risks a wrong/forgeable path). Each path rendered through the
154
+ * same {@link import("./permission-rule-model.js").renderUntrustedCommandText} baseline as
155
+ * {@link summary} (a path name can carry the same reordering hazard as a command) + capped.
145
156
  * Omitted when none derivable. */
146
157
  touchedPaths?: string[];
147
158
  /** #144 (additive): a persisted allow rule MATCHED this call but could not clear the mandated ask —
148
- * the matched rule text (`inlineUntrusted`-capped), threaded to the mint so the durable-park route
149
- * carries the same disclosure the synchronous ask does. The inbox renders "their rule is alive,
150
- * just outranked" instead of the person concluding their rule silently broke. */
159
+ * the matched rule text (rendered through the same display baseline as {@link summary}, capped),
160
+ * threaded to the mint so the durable-park route carries the same disclosure the synchronous ask
161
+ * does. The inbox renders "their rule is alive, just outranked" instead of the person concluding
162
+ * their rule silently broke. (The baseline is a FAMILY-UNIFORMITY choice AND, on this seat, a
163
+ * disclosed one-face NARROWING: the feeding path is the permission-rule store's READ leg, which
164
+ * does not re-screen stored rule text through the validator — the bytes may be model-authored or
165
+ * a custom provider's, and a store-sourced string can legally carry `\p{Cf}`. For
166
+ * validator-screened text the render coincides with the previous `inlineUntrusted` treatment
167
+ * (the two differ only on the `\p{Cf}` class, and both fold the width-bearing controls it admits
168
+ * (a tab) identically); for a Cf carrier this durable seat STRIPS what the synchronous ask's
169
+ * `persistedRuleShadowed` preserves — same value, two byte-forms, diverging exactly on `\p{Cf}`,
170
+ * the durable face stripping MORE (the safe direction). This seat carries NO cross-face
171
+ * same-value contract (unlike `probeReason`/`probeCause` below), which is why the one-face move
172
+ * is legal — a byte-pinning consumer must read per face.) */
151
173
  shadowedRule?: string;
152
174
  /** backlog #239 (additive): WHY the reversibility probe did not clear this call, in the probe's own
153
175
  * words — the cause the tighten acted on, which until now existed only inside the probe and left the
@@ -157,7 +179,14 @@ export interface RiskDescriptor {
157
179
  * boundary, so it is DISPLAY/TRIAGE metadata only, never adjudication input.
158
180
  *
159
181
  * The UNSTRUCTURED face: a deployment's own probe writes prose here and this engine cannot say what it
160
- * means. An engine-minted cause uses {@link probeCause} instead — see that field for why. */
182
+ * means. An engine-minted cause uses {@link probeCause} instead — see that field for why.
183
+ *
184
+ * RESIDUAL, disclosed (#438): unlike the descriptor-owned display family above, this seat does
185
+ * NOT strip the `\p{Cf}` display-reordering class — a directional/zero-width marker in the
186
+ * probe's prose survives here byte-for-byte, because the seat carries a documented SAME-VALUE
187
+ * contract with its synchronous `AskRequest.probeReason` twin (one string, two faces) and a
188
+ * one-face re-render would break it. A consumer rendering this seat owes its own screen (or the
189
+ * descriptor family's display baseline). */
161
190
  probeReason?: string;
162
191
  /** backlog #239 (additive): the STRUCTURED cause, for a probe whose verdict this engine understands —
163
192
  * the built-in shell probe is the first. Carries a machine-readable {@link ProbeCause.code} and the
@@ -178,7 +207,12 @@ export interface RiskDescriptor {
178
207
  * command text. */
179
208
  export interface ProbeCauseOperands {
180
209
  /** The named entries. Each `inlineUntrusted`-neutralized and capped at {@link PROBE_CAUSE_PATH_MAX}
181
- * INDIVIDUALLY, so one pathological entry costs only itself. Bounded in count by the minting probe. */
210
+ * INDIVIDUALLY, so one pathological entry costs only itself. Bounded in count by the minting probe.
211
+ * RESIDUAL, disclosed (#438): `inlineUntrusted` does NOT strip the `\p{Cf}` display-reordering
212
+ * class the descriptor's own display family strips — a directional/zero-width marker in a path
213
+ * survives on this seat (cross-face byte parity with the synchronous `AskRequest.probeCause`
214
+ * twin; see that same-value contract for why a one-face re-render is off the table). A consumer
215
+ * rendering these entries owes its own screen (or the descriptor family's display baseline). */
182
216
  readonly shown: readonly string[];
183
217
  /** How many entries the family has in total — `shown.length` when nothing was elided. A non-negative
184
218
  * integer; never a lie by omission. */
@@ -218,11 +252,6 @@ export interface ProbeCause {
218
252
  * that sanitizes at the entry boundary and the descriptor builder that re-sanitizes at the persist
219
253
  * boundary cannot cap the same value at two different lengths. */
220
254
  export declare const PROBE_REASON_MAX = 200;
221
- /** backlog #239: the per-ENTRY ceiling inside a {@link ProbeCause} operand family, sized like the
222
- * descriptor's own {@link RiskDescriptor.touchedPaths} entries (the same thing on the same card). Per
223
- * entry, not per cause: that is the whole point of the structured shape — one pathological path costs
224
- * only itself, where a single joined string let it consume every other entry's room. */
225
- export declare const PROBE_CAUSE_PATH_MAX = 200;
226
255
  /**
227
256
  * backlog #239: the ONE validator for a {@link ProbeCause} arriving from a probe.
228
257
  *
@@ -346,11 +375,19 @@ export interface PendingSteerInput {
346
375
  * degrade an inbox preview, never a permission outcome.
347
376
  *
348
377
  * **Redaction is load-bearing** (the `summary`/`touchedPaths` are PERSISTED + surfaced to a human inbox):
349
- * every model-controlled value goes through {@link inlineUntrusted} (folds CR/LF/Unicode separators to one
350
- * space, defuses `</system-reminder>` variants + `<<<`/`>>>` fence sentinels) AND a length cap, so a
351
- * crafted arg carrying a break-out tag / newline / huge string can neither inject into the inbox render nor
352
- * bloat the durable row. NEVER dumps full args / env / secrets only a bash command string or a short
353
- * `name=value` digest of the SHOWN args.
378
+ * every model-controlled value in the DESCRIPTOR-OWNED display family (`summary` both arms,
379
+ * `touchedPaths`, `shadowedRule`) goes through
380
+ * {@link import("./permission-rule-model.js").renderUntrustedCommandText} which STRIPS the `\p{Cf}`
381
+ * format class (directional controls / zero-width formatters, the display-reordering hazard the
382
+ * `hasBidiControls` bit warns about; #438) and then applies the shared inline form (folds CR/LF/Unicode
383
+ * separators to one space, defuses `</system-reminder>` variants + `<<<`/`>>>` fence sentinels) — AND a
384
+ * length cap, so a crafted arg carrying a break-out tag / newline / huge string / RLO can neither inject
385
+ * into the inbox render nor display as something other than what runs nor bloat the durable row.
386
+ * TWO SEATS ARE DELIBERATELY NOT IN THE FAMILY: `probeCause` and `probeReason` carry a documented
387
+ * SAME-VALUE contract with their synchronous `AskRequest` twins (their docs say so verbatim), so
388
+ * changing their bytes here alone would break that cross-face contract — they keep the shared
389
+ * `inlineUntrusted`/`normalizeProbeCause` treatment (a residual their docs disclose). NEVER dumps full
390
+ * args / env / secrets — only a bash command string or a short `name=value` digest of the SHOWN args.
354
391
  *
355
392
  * `summary` for a shell gate = the `command` string (capped 200 cp); otherwise a `name=value` digest of the
356
393
  * call's top-level string/number/boolean args (each value capped), omitted when nothing safe to summarize.
@@ -728,6 +765,33 @@ export type PendingAction = {
728
765
  * pre-1a.2 checkpoint (deserialized without it), in which case the resume skips the hash check.
729
766
  */
730
767
  boundInputHash: string;
768
+ /**
769
+ * #438 (additive; no checkpoint-version bump — the `reminderMark` precedent: an optional field an
770
+ * older reader ignores. Unlike that precedent seat — which resume DOES read, through its
771
+ * verify-then-adopt port — nothing reads THIS bit at all, per the echo-only clause below):
772
+ * PRESENCE ONLY — the row's execution
773
+ * payload carries at least one DIRECTIONAL format control. The durable twin of the synchronous
774
+ * {@link import("./tool-policy.js").AskRequest.hasBidiControls} (ONE contract, two faces — that
775
+ * field's doc owns the character class, the hazard and the warning-not-verdict posture; everything
776
+ * there applies here unchanged). What is stated per face:
777
+ *
778
+ * - JUDGED AT THE PARK MINT over THIS ROW's materialized payload: the fidelity-projected `args`
779
+ * snapshot (the bytes a human approves and a resume executes) plus the row's own `preview`
780
+ * projection. The row's bit describes the row's bytes — on a shape-narrowing backend (a Map key
781
+ * under a JSON codec) a marker the synchronous face detected can be GONE from the row, and the
782
+ * honest bit for the row is then ABSENT: the inbox renders the row's snapshot, and the bit
783
+ * agrees with what it renders. The two faces coincide whenever the projection is shape-preserving.
784
+ * - `true` or ABSENT, never `false` (absence = "not detected": clean, or the bounded scan did not
785
+ * reach it — a `false` would claim a proof the scan does not offer).
786
+ * - A DEPLOYMENT-WRITTEN park row (the design/144 convention's deployment half) may carry `true`
787
+ * (taken at its word — over-warning is the safe direction, and a writer with row access holds
788
+ * the args themselves already) or omit it (the summary projection then runs its own bounded
789
+ * backfill scan — see {@link CheckpointSummary.hasBidiControls}). A written `false` is
790
+ * out-of-contract and is read as ABSENT everywhere; it never reaches a projection.
791
+ * - ECHO-ONLY, like {@link CheckpointSummary.toolInput}: NO resume / gate / CAS /
792
+ * `winnerFromOutcome` path reads it — it is a display warning, never a control input.
793
+ */
794
+ hasBidiControls?: true;
731
795
  /** All tool-call ids in the suspending assistant message, in emission order. */
732
796
  batchToolCallIds: string[];
733
797
  /** Ids of calls already executed (results in the session) when the suspend fired — #1..k-1. */
@@ -1199,7 +1263,7 @@ export declare const FACE_CHECKPOINT_VERSION = 9;
1199
1263
  * (binding human/irreversible_ask), v4 (a pre-164 row, refused only when it carries the retired
1200
1264
  * wall-clock allocation), v5 (token-allocation-bearing resource/approval), v6 (org-admission freeze
1201
1265
  * state), v7 (F-012 constraint-chain / delegation-provenance enforcement state), v8 (a
1202
- * non-budgetable `realApproval` gate bit with its org-origin resume semantics) and v9 (the
1266
+ * non-budgetable `realApproval` gate bit with its org-origin resume semantics), v9 (the
1203
1267
  * design/199 read-face section: resolved face + deny-set additions, folded stricter-wins on
1204
1268
  * resume). */
1205
1269
  export declare const MAX_SUPPORTED_CHECKPOINT_VERSION = 9;
@@ -1487,11 +1551,21 @@ export interface Checkpoint {
1487
1551
  sourceTaskId?: string;
1488
1552
  /**
1489
1553
  * design S1d (source-tag persistence): the issuing task's authenticated end-user {@link
1490
- * import("./types.js").TaskSpec.principal} (design/62), stamped at the APPROVAL suspend mint so an aggregating
1491
- * inbox can attribute a paused checkpoint per-userthe durable analog of {@link
1492
- * import("./tool-policy.js").AskRequest}.principal. Caller-set (never a tool/worker arg), and like
1493
- * {@link sourceTaskId} it is **ECHO-ONLY triage metadata, NEVER a gate input** (no gate/CAS/winner/resume path
1494
- * reads it). Absent no principal (a non-ask gate, an unauthenticated task, or a legacy checkpoint).
1554
+ * import("./types.js").TaskSpec.principal} (design/62), stamped at the APPROVAL suspend mint and, since
1555
+ * #449 G2, at the `resource_limit` mint tooso an aggregating inbox can attribute a paused checkpoint
1556
+ * per-user — the durable analog of {@link import("./tool-policy.js").AskRequest}.principal. Caller-set
1557
+ * (never a tool/worker arg), and like {@link sourceTaskId} it is **NEVER a gate input**: no gate / CAS /
1558
+ * winner path reads it. The RESUME ENTRY however does read it as the row's identity record (#449 G2):
1559
+ * a resume config that omits `principal` inherits this value for the resumed leg (usage-ledger bucket,
1560
+ * later mints' scope derivation, ask attribution — a dropped field must not silently move the leg onto
1561
+ * the SHARED usage bucket), and a resume config carrying a DIFFERENT non-empty principal is refused
1562
+ * pre-CAS (`resume.principal_mismatch`). Absent ⇒ no principal (a legacy checkpoint, an unauthenticated
1563
+ * task, or a gate kind that predates the stamp). Rolling-deployment note (#449 G2 residual): the field
1564
+ * is NOT version-gated — an older worker accepts a principal-bearing row and keeps its historic
1565
+ * behavior (no fallback, no mismatch refusal); the identity-continuity guarantee holds once the fleet
1566
+ * is on enforcing workers. Version-gating it is a recorded design candidate: the lattice's newest rung
1567
+ * (design/199) encodes "v>=9 ⟺ face section present", so ANY new version above it must rewrite that
1568
+ * guard's window form and add its own presence invariant — a lattice-wide change, not a stamp.
1495
1569
  */
1496
1570
  principal?: string;
1497
1571
  /**
@@ -1615,6 +1689,39 @@ export interface CheckpointSummary {
1615
1689
  * contextually escape), ECHO-ONLY (no gate/CAS/resume reads it), and surfaces SHOULD show the
1616
1690
  * bound raw args (`toolInput`/`pendingAction.args`) alongside — the preview never replaces them. */
1617
1691
  preview?: unknown;
1692
+ /**
1693
+ * #438: PRESENCE ONLY — this pending `tool_approval` row's payload carries at least one DIRECTIONAL
1694
+ * format control (the {@link import("./tool-policy.js").AskRequest.hasBidiControls} character class;
1695
+ * that doc owns the hazard). Only ever for a `tool_approval` pendingAction; OMITTED for every other
1696
+ * kind (the {@link toolCallId} ABSENCE-SHAPE contract applies: never `null`, never present-but-
1697
+ * `undefined`, never `false` — a re-projecting BFF must not materialize any of those on the way out).
1698
+ *
1699
+ * WHERE THE ANSWER COMES FROM: the row's own persisted bit when present (the park mint is the
1700
+ * authority — one look, one answer, never re-derived); else a bounded BACKFILL scan run AT PROJECTION
1701
+ * TIME over this projection's OWN bounded materials — the truncated {@link toolInput} string just
1702
+ * derived plus the row's `preview`, under a small per-row budget — so a pre-#438 row and a
1703
+ * deployment-written row still get a bit exactly where the inbox reads. The backfill judges THE BYTES
1704
+ * THIS PROJECTION HANDS THE INBOX: a marker buried past the `toolInput` truncation point (or past the
1705
+ * per-row scan budget) is honestly reported as absent — new-engine-minted rows carry the mint-time bit
1706
+ * (judged over the full parked args) for exactly that reason. The backfill NEVER writes back to the
1707
+ * row: `summarizeCheckpoint` stays a pure projection.
1708
+ *
1709
+ * HOW TO READ IT: `true` = core detected the class in this row's payload (or took a deployment row's
1710
+ * own `true` at its word — over-warning is the safe direction); render a visible "the text below can
1711
+ * display differently from what it runs" annotation. ABSENT = NOT DETECTED, never "clean" — it covers
1712
+ * clean, budget-exhausted, and a `false`-writing out-of-contract producer (read as absent, never
1713
+ * projected). The bit changes NO rendering obligation a surface already had: with or without it,
1714
+ * `toolInput` / `toolName` / a summary string go through
1715
+ * {@link import("./permission-rule-model.js").renderUntrustedCommandText} (or the surface's own
1716
+ * screen) before a human reads them; for the structured `args`/`preview` the FIRST-CHOICE display
1717
+ * material is the bounded projections core already minted (`toolInput`, the descriptor `summary`) —
1718
+ * do not re-serialize the whole args for a list render, and a drill-in that must show everything owes
1719
+ * itself a TOTAL, bounded traversal (cycle-safe, per-type output forms, throwing getters to a
1720
+ * placeholder, depth/node/char budgets with a truncation marker — never "full `JSON.stringify` then
1721
+ * truncate", which does all the work and takes all the throws before the cap). ECHO-ONLY, like
1722
+ * {@link sourceTaskId}: a warning to a person, never a control input — resume/gate/CAS never read it.
1723
+ */
1724
+ hasBidiControls?: true;
1618
1725
  }
1619
1726
  /**
1620
1727
  * design/80 assistant-scheduler seam #1: project a single PENDING {@link Checkpoint} to its lightweight
@@ -1715,6 +1822,23 @@ export declare class CheckpointError extends Error {
1715
1822
  * thinner (or different) ancestor chain than it suspended with. Rejected pre-CAS (the checkpoint
1716
1823
  * stays `pending`); re-resume with the full original chain. */
1717
1824
  | "resume.parent_constraint_mismatch"
1825
+ /** #449 G1 (codex r2, pre-CAS entrance): a deployment usage-governance window for this resume's
1826
+ * ledger key (the config's principal, else the row's recorded one, else the shared bucket) is
1827
+ * EXHAUSTED, and the row still OWES a delivery a re-mint cannot carry (an approval/review
1828
+ * decision, a wake, parked steers, a background-task obituary snapshot). Refused BEFORE the CAS,
1829
+ * so nothing is consumed and nothing is unpinned — the SAME token with the SAME decision is
1830
+ * redeemable once the window frees (`detail.retryAfterMs` carries the wait). A bare
1831
+ * `resource_limit` continue with nothing parked does NOT take this refusal: it proceeds and
1832
+ * re-suspends at turn 0 into a FRESH checkpoint carrying the new wait hint. */
1833
+ | "resume.usage_window_exhausted"
1834
+ /** #449 G2 (identity continuity): the resume config carries a NON-EMPTY `principal` that differs from
1835
+ * the {@link Checkpoint.principal} recorded at the suspend mint. `ResumeTaskConfig`'s contract is
1836
+ * "the same TaskSpec the run suspended with", and the principal keys real lanes on the resumed leg
1837
+ * (the usage-governance ledger bucket, later mints' scope derivation, session-rule reads, ask
1838
+ * attribution) — silently running the leg under a different identity is the bad-value direction this
1839
+ * refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
1840
+ * original principal, or omit the field to inherit the recorded one. */
1841
+ | "resume.principal_mismatch"
1718
1842
  /** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
1719
1843
  * constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
1720
1844
  * edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
@@ -1742,6 +1866,9 @@ export declare class CheckpointError extends Error {
1742
1866
  * discriminant. */
1743
1867
  readonly detail?: {
1744
1868
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
1869
+ /** #449 G1 — on `resume.usage_window_exhausted` ONLY: how long until the binding window frees
1870
+ * (the same wait a suspended row carries as `gate.resumeAfterMs`). Absent on every other code. */
1871
+ retryAfterMs?: number;
1745
1872
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1746
1873
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
1747
1874
  * damaged/caller-declared and no worker ever will" — blanket-retriable and gate-shape heuristics were
@@ -1844,6 +1971,23 @@ export declare class CheckpointError extends Error {
1844
1971
  * thinner (or different) ancestor chain than it suspended with. Rejected pre-CAS (the checkpoint
1845
1972
  * stays `pending`); re-resume with the full original chain. */
1846
1973
  | "resume.parent_constraint_mismatch"
1974
+ /** #449 G1 (codex r2, pre-CAS entrance): a deployment usage-governance window for this resume's
1975
+ * ledger key (the config's principal, else the row's recorded one, else the shared bucket) is
1976
+ * EXHAUSTED, and the row still OWES a delivery a re-mint cannot carry (an approval/review
1977
+ * decision, a wake, parked steers, a background-task obituary snapshot). Refused BEFORE the CAS,
1978
+ * so nothing is consumed and nothing is unpinned — the SAME token with the SAME decision is
1979
+ * redeemable once the window frees (`detail.retryAfterMs` carries the wait). A bare
1980
+ * `resource_limit` continue with nothing parked does NOT take this refusal: it proceeds and
1981
+ * re-suspends at turn 0 into a FRESH checkpoint carrying the new wait hint. */
1982
+ | "resume.usage_window_exhausted"
1983
+ /** #449 G2 (identity continuity): the resume config carries a NON-EMPTY `principal` that differs from
1984
+ * the {@link Checkpoint.principal} recorded at the suspend mint. `ResumeTaskConfig`'s contract is
1985
+ * "the same TaskSpec the run suspended with", and the principal keys real lanes on the resumed leg
1986
+ * (the usage-governance ledger bucket, later mints' scope derivation, session-rule reads, ask
1987
+ * attribution) — silently running the leg under a different identity is the bad-value direction this
1988
+ * refusal exists to close. Rejected PRE-CAS (the checkpoint stays `pending`); re-resume with the
1989
+ * original principal, or omit the field to inherit the recorded one. */
1990
+ | "resume.principal_mismatch"
1847
1991
  /** F-012 L3: the resume carried an approver EDIT (`updatedInput`) and a FROZEN inherited ancestor
1848
1992
  * constraint (the persisted projection chain) — or the deployment's resume-edit policy — DENIES the
1849
1993
  * edited args. Refused PRE-CAS with fresh-redecision semantics: the checkpoint stays `pending` and
@@ -1871,6 +2015,9 @@ export declare class CheckpointError extends Error {
1871
2015
  * discriminant. */
1872
2016
  detail?: {
1873
2017
  field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
2018
+ /** #449 G1 — on `resume.usage_window_exhausted` ONLY: how long until the binding window frees
2019
+ * (the same wait a suspended row carries as `gate.resumeAfterMs`). Absent on every other code. */
2020
+ retryAfterMs?: number;
1874
2021
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1875
2022
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
1876
2023
  * damaged/caller-declared and no worker ever will" — blanket-retriable and gate-shape heuristics were