@sema-agent/core 5.59.0 → 5.60.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 +40 -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 +116 -19
  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.js +10 -6
  18. package/dist/core/memory-engine/content-origin.d.ts +24 -2
  19. package/dist/core/memory-engine/content-origin.js +6 -1
  20. package/dist/core/memory.d.ts +10 -0
  21. package/dist/core/park-selfcheck.js +1 -0
  22. package/dist/core/permission-rule-consent.js +9 -5
  23. package/dist/core/runner/prepare-config-doors.d.ts +22 -1
  24. package/dist/core/runner/prepare-config-doors.js +36 -0
  25. package/dist/core/runner/prepare-task.d.ts +28 -1
  26. package/dist/core/runner/prepare-task.js +106 -8
  27. package/dist/core/runner/runtask.js +41 -4
  28. package/dist/core/store-contracts/checkpoint-store-contract.js +32 -0
  29. package/dist/core/tool-policy.d.ts +24 -8
  30. package/dist/core/tool-policy.js +3 -3
  31. package/dist/core/tools.js +1 -1
  32. package/dist/core/trace.d.ts +36 -0
  33. package/dist/core/types.d.ts +127 -2
  34. package/dist/core/untrusted-text.d.ts +11 -0
  35. package/dist/core/untrusted-text.js +1 -0
  36. package/dist/engine/llm/types.d.ts +21 -2
  37. package/dist/engine/loop/agent-loop.js +7 -1
  38. package/dist/engine/loop/types.d.ts +4 -1
  39. package/dist/tools/fs/fs-bash.js +1 -2
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.60.0 — 2026-08-25
4
+
5
+ ### Added
6
+ - **CC clearing machine + micro-compact recovery (design/374 slices 1a/1b/2/4; every default byte-identical)**:
7
+ `ContextEditOptions.machine: "legacy" | "cc"` selects the clearing machine — `cc` is CC's rejection-leg
8
+ form carried whole (keep 5, a >=20000-token minimum-savings gate with the field-exact `Sby` estimator,
9
+ one deep clear, a window base over ALL compactable occurrences, five cleared-predicate forms);
10
+ `RunnerDeps.microCompact { machine, clearOnRejection }` (both default off). A typed `input_too_long`
11
+ cause classifies real provider context refusals (position-0 anchored family over the parsed envelope
12
+ diagnostic; reflected copies stay ruled out) and feeds an MC-R recovery arm at the head of the
13
+ recovery chain (attempt-1 latch; plan over the rejected projection; a projection LEDGER — occurrence
14
+ keys (toolCallId, timestamp, dup ordinal), new-machine writes only — is the clear's sole survival
15
+ channel across per-request rebuilds). `resolveTriggerWindow` becomes the one trigger-window source
16
+ (four consumers); a clamped window announces `config.autocompact_window_clamped` once per task.
17
+ Trace frames `context.mc_clear`/`context.mc_null`. Nine CC divergences registered (cc-parity ledger).
18
+ - **Durable park rows carry the bidi twin (design/377, #438)**: `PendingAction.tool_approval.hasBidiControls?: true`
19
+ minted at park over the fidelity-projected args snapshot + preview; `CheckpointSummary.hasBidiControls`
20
+ projected, bit-less legacy rows BACKFILLED at projection from their own bounded materials (32K units /
21
+ 512 nodes per row; never written back; a stored `false` reads as absent). Checkpoint version unchanged.
22
+ - **MCP entry-level content-trust declaration (design/378, #439; default byte-identical)**:
23
+ `McpServerSpec.contentOrigin?: "local" | "execution" | "external"` (the existing ToolContentOrigin
24
+ vocabulary), minted once at intake from a frozen copy; `memory.content_class_declared` announces one
25
+ per-value line per declared entry per prepared leg (explicit `"external"` announces too — it
26
+ forecloses the trustedTools exemption); malformed declarations refuse at preparation
27
+ (`config.mcp_content_class`).
28
+
29
+ ### Changed — narrowings (named)
30
+ - **Inbox display family goes Cf-safe (two byte generations)**: descriptor-owned `RiskDescriptor` seats
31
+ (`summary` both arms, `touchedPaths`, `shadowedRule`) render through `renderUntrustedCommandText` —
32
+ newly-minted rows strip the `\p{Cf}` format class (emoji ZWJ sequences display decomposed); persisted
33
+ rows keep their old bytes forever. Byte-pinning consumers must read per row generation.
34
+ - **Confirm-face refusal messages stop interpolating raw command bytes (#430)**: >120-code-point
35
+ commands cap with an ellipsis, control bytes render `\uXXXX`; short clean commands byte-identical.
36
+ Two `record_malformed` damage sentences escape record-sourced values.
37
+ - **Vocabulary-outside `declared` content origins now classify `external`** (previously escaped marking
38
+ silently — a fail-open hole closed; untyped host-mounted tools with garbage declarations are the
39
+ affected shape).
40
+ - **`approvalRecordDamageOf` gains the reverse integrity clause (#441)**: an orphan candidate row
41
+ (belonging to no offer) answers `record_malformed` at all three consent entries.
42
+
3
43
  ## 5.59.0 — 2026-08-24
4
44
 
5
45
  ### 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. */
@@ -1615,6 +1679,39 @@ export interface CheckpointSummary {
1615
1679
  * contextually escape), ECHO-ONLY (no gate/CAS/resume reads it), and surfaces SHOULD show the
1616
1680
  * bound raw args (`toolInput`/`pendingAction.args`) alongside — the preview never replaces them. */
1617
1681
  preview?: unknown;
1682
+ /**
1683
+ * #438: PRESENCE ONLY — this pending `tool_approval` row's payload carries at least one DIRECTIONAL
1684
+ * format control (the {@link import("./tool-policy.js").AskRequest.hasBidiControls} character class;
1685
+ * that doc owns the hazard). Only ever for a `tool_approval` pendingAction; OMITTED for every other
1686
+ * kind (the {@link toolCallId} ABSENCE-SHAPE contract applies: never `null`, never present-but-
1687
+ * `undefined`, never `false` — a re-projecting BFF must not materialize any of those on the way out).
1688
+ *
1689
+ * WHERE THE ANSWER COMES FROM: the row's own persisted bit when present (the park mint is the
1690
+ * authority — one look, one answer, never re-derived); else a bounded BACKFILL scan run AT PROJECTION
1691
+ * TIME over this projection's OWN bounded materials — the truncated {@link toolInput} string just
1692
+ * derived plus the row's `preview`, under a small per-row budget — so a pre-#438 row and a
1693
+ * deployment-written row still get a bit exactly where the inbox reads. The backfill judges THE BYTES
1694
+ * THIS PROJECTION HANDS THE INBOX: a marker buried past the `toolInput` truncation point (or past the
1695
+ * per-row scan budget) is honestly reported as absent — new-engine-minted rows carry the mint-time bit
1696
+ * (judged over the full parked args) for exactly that reason. The backfill NEVER writes back to the
1697
+ * row: `summarizeCheckpoint` stays a pure projection.
1698
+ *
1699
+ * HOW TO READ IT: `true` = core detected the class in this row's payload (or took a deployment row's
1700
+ * own `true` at its word — over-warning is the safe direction); render a visible "the text below can
1701
+ * display differently from what it runs" annotation. ABSENT = NOT DETECTED, never "clean" — it covers
1702
+ * clean, budget-exhausted, and a `false`-writing out-of-contract producer (read as absent, never
1703
+ * projected). The bit changes NO rendering obligation a surface already had: with or without it,
1704
+ * `toolInput` / `toolName` / a summary string go through
1705
+ * {@link import("./permission-rule-model.js").renderUntrustedCommandText} (or the surface's own
1706
+ * screen) before a human reads them; for the structured `args`/`preview` the FIRST-CHOICE display
1707
+ * material is the bounded projections core already minted (`toolInput`, the descriptor `summary`) —
1708
+ * do not re-serialize the whole args for a list render, and a drill-in that must show everything owes
1709
+ * itself a TOTAL, bounded traversal (cycle-safe, per-type output forms, throwing getters to a
1710
+ * placeholder, depth/node/char budgets with a truncation marker — never "full `JSON.stringify` then
1711
+ * truncate", which does all the work and takes all the throws before the cap). ECHO-ONLY, like
1712
+ * {@link sourceTaskId}: a warning to a person, never a control input — resume/gate/CAS never read it.
1713
+ */
1714
+ hasBidiControls?: true;
1618
1715
  }
1619
1716
  /**
1620
1717
  * design/80 assistant-scheduler seam #1: project a single PENDING {@link Checkpoint} to its lightweight
@@ -1,6 +1,8 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
2
  import { uuidv7 } from "../internal/harness.js";
3
- import { inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
3
+ import { PROBE_CAUSE_PATH_MAX, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
4
+ import { carriesBidiControls } from "./tool-policy.js";
5
+ import { renderUntrustedCommandText } from "./permission-rule-model.js";
4
6
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
5
7
  export function mintCheckpointToken() {
6
8
  return randomBytes(16).toString("hex");
@@ -9,7 +11,6 @@ export function mintCheckpointId() {
9
11
  return `cp_${randomUUID()}`;
10
12
  }
11
13
  export const PROBE_REASON_MAX = 200;
12
- export const PROBE_CAUSE_PATH_MAX = 200;
13
14
  const PROBE_CAUSE_MAX_SHOWN = 8;
14
15
  export function normalizeProbeCause(value) {
15
16
  if (!isPlainRecord(value))
@@ -62,6 +63,8 @@ const MAX_TOUCHED_PATHS = 8;
62
63
  const MAX_DIGEST_KEYS = 16;
63
64
  const MAX_DIGEST_SCAN_KEYS = 256;
64
65
  export const MAX_TOOL_INPUT_PREVIEW_CHARS = 512;
66
+ const BIDI_BACKFILL_MAX_CHARS = 32_000;
67
+ const BIDI_BACKFILL_MAX_NODES = 512;
65
68
  export const MAX_PENDING_STEER_CHARS = 16_000;
66
69
  export const PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES = 48_000;
67
70
  export const MAX_PENDING_STEER_ENTRIES = Math.floor(PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES / MAX_PENDING_STEER_CHARS);
@@ -121,7 +124,7 @@ export function buildRiskDescriptor(input) {
121
124
  if (shell || toolName === "Bash") {
122
125
  const cmd = isPlainRecord(args) ? safeDataValue(args, "command") : undefined;
123
126
  if (typeof cmd === "string" && cmd.length > 0)
124
- summary = inlineUntrusted(cmd, SUMMARY_CMD_MAX);
127
+ summary = renderUntrustedCommandText(cmd, SUMMARY_CMD_MAX);
125
128
  const bg = isPlainRecord(args) ? safeDataValue(args, "run_in_background") : undefined;
126
129
  if (bg === true)
127
130
  summary = `[background persistent process — no per-step recheck] ${summary ?? ""}`.trimEnd();
@@ -135,20 +138,20 @@ export function buildRiskDescriptor(input) {
135
138
  const v = safeDataValue(args, key);
136
139
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
137
140
  const encDigest = (s) => s.replace(/%/g, "%25").replace(/=/g, "%3D").replace(/ /g, "%20");
138
- const k = encDigest(inlineUntrusted(key, 40));
139
- const val = encDigest(inlineUntrusted(String(v), SUMMARY_VALUE_MAX));
141
+ const k = encDigest(renderUntrustedCommandText(key, 40));
142
+ const val = encDigest(renderUntrustedCommandText(String(v), SUMMARY_VALUE_MAX));
140
143
  parts.push(`${k}=${val}`);
141
144
  }
142
145
  }
143
146
  if (parts.length > 0)
144
- summary = inlineUntrusted(parts.join(" "), SUMMARY_TOTAL_MAX);
147
+ summary = renderUntrustedCommandText(parts.join(" "), SUMMARY_TOTAL_MAX);
145
148
  }
146
149
  }
147
150
  const touched = [];
148
151
  if (!shell && toolName !== "Bash") {
149
152
  const p = fsPathArg(args);
150
153
  if (p !== undefined)
151
- touched.push(inlineUntrusted(p, PATH_MAX));
154
+ touched.push(renderUntrustedCommandText(p, PATH_MAX));
152
155
  }
153
156
  const touchedPaths = touched.length > 0 ? touched.slice(0, MAX_TOUCHED_PATHS) : undefined;
154
157
  return {
@@ -156,7 +159,7 @@ export function buildRiskDescriptor(input) {
156
159
  axes,
157
160
  toolName,
158
161
  ...(input.shellGated && input.shellGateDoctrine !== undefined ? { shellGateDoctrine: input.shellGateDoctrine } : {}),
159
- ...(input.shadowedRule !== undefined ? { shadowedRule: inlineUntrusted(input.shadowedRule, 200) } : {}),
162
+ ...(input.shadowedRule !== undefined ? { shadowedRule: renderUntrustedCommandText(input.shadowedRule, 200) } : {}),
160
163
  ...(input.probeReason !== undefined ? { probeReason: inlineUntrusted(input.probeReason, PROBE_REASON_MAX) } : {}),
161
164
  ...(() => {
162
165
  if (input.probeCause === undefined)
@@ -235,6 +238,9 @@ export function summarizeCheckpoint(cp) {
235
238
  toolInput = undefined;
236
239
  }
237
240
  }
241
+ const hasBidi = tool !== undefined &&
242
+ (tool.hasBidiControls === true ||
243
+ carriesBidiControls([toolInput, tool.preview], { maxNodes: BIDI_BACKFILL_MAX_NODES, maxChars: BIDI_BACKFILL_MAX_CHARS }));
238
244
  return {
239
245
  token: cp.token,
240
246
  ...(cp.checkpointId !== undefined ? { checkpointId: cp.checkpointId } : {}),
@@ -254,6 +260,7 @@ export function summarizeCheckpoint(cp) {
254
260
  ? { restoreMode: cp.state.workspaceHandle.restoreMode === "park_only" ? "park_only" : "snapshot" }
255
261
  : {}),
256
262
  ...(tool?.preview !== undefined ? { preview: tool.preview } : {}),
263
+ ...(hasBidi ? { hasBidiControls: true } : {}),
257
264
  };
258
265
  }
259
266
  export class CheckpointError extends Error {