@sema-agent/core 7.0.2 → 7.2.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 (66) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/agents/cross-session-envelope.d.ts +138 -0
  3. package/dist/agents/cross-session-envelope.js +191 -0
  4. package/dist/agents/cross-session-judge.d.ts +119 -0
  5. package/dist/agents/cross-session-judge.js +184 -0
  6. package/dist/agents/cross-session-ref.d.ts +52 -0
  7. package/dist/agents/cross-session-ref.js +64 -0
  8. package/dist/agents/repair-loop.d.ts +8 -7
  9. package/dist/agents/roster-store.d.ts +7 -2
  10. package/dist/agents/send-message-tool.d.ts +13 -0
  11. package/dist/agents/send-message-tool.js +36 -12
  12. package/dist/brain/errors.d.ts +18 -0
  13. package/dist/brain/errors.js +3 -0
  14. package/dist/brain/stream-engine.js +6 -4
  15. package/dist/core/checkpoint-store.d.ts +189 -3
  16. package/dist/core/checkpoint-store.js +56 -16
  17. package/dist/core/context-edit.d.ts +3 -0
  18. package/dist/core/governance-codes.d.ts +1 -1
  19. package/dist/core/governance-codes.js +4 -0
  20. package/dist/core/hooks.d.ts +34 -7
  21. package/dist/core/hooks.js +14 -8
  22. package/dist/core/image-downsample.d.ts +4 -3
  23. package/dist/core/permission-rule-consent.d.ts +72 -23
  24. package/dist/core/permission-rule-consent.js +115 -26
  25. package/dist/core/permission-rule-model.d.ts +245 -51
  26. package/dist/core/permission-rule-model.js +312 -54
  27. package/dist/core/permission-rule-org.js +13 -6
  28. package/dist/core/remote-env.d.ts +8 -1
  29. package/dist/core/roles.d.ts +30 -8
  30. package/dist/core/roles.js +12 -8
  31. package/dist/core/runner/assemble-result.js +2 -1
  32. package/dist/core/runner/prepare-task.d.ts +41 -2
  33. package/dist/core/runner/prepare-task.js +353 -152
  34. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  35. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  36. package/dist/core/runner/runtask.d.ts +12 -3
  37. package/dist/core/runner/runtask.js +45 -7
  38. package/dist/core/safety-axis-vocab.d.ts +1 -1
  39. package/dist/core/strategy-store.d.ts +4 -1
  40. package/dist/core/task-notification.d.ts +64 -5
  41. package/dist/core/task-notification.js +25 -4
  42. package/dist/core/task-registry-shared.d.ts +7 -3
  43. package/dist/core/tool-errors.d.ts +1 -1
  44. package/dist/core/tool-policy.d.ts +51 -7
  45. package/dist/core/tool-policy.js +63 -9
  46. package/dist/core/types.d.ts +110 -9
  47. package/dist/core/untrusted-text.js +17 -1
  48. package/dist/engine/compaction/compaction.js +6 -2
  49. package/dist/engine/harness/agent-harness.d.ts +28 -6
  50. package/dist/engine/harness/agent-harness.js +34 -2
  51. package/dist/engine/harness/messages.js +4 -0
  52. package/dist/engine/harness/types.d.ts +37 -0
  53. package/dist/engine/harness/types.js +5 -0
  54. package/dist/engine/session/session.js +3 -2
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.js +5 -2
  57. package/dist/internal/harness.d.ts +1 -0
  58. package/dist/internal/harness.js +1 -0
  59. package/dist/orchestration/builtin-workflows.d.ts +17 -9
  60. package/dist/orchestration/run-workflow-tool.js +7 -2
  61. package/dist/orchestration/workflow-governance.js +1 -1
  62. package/dist/orchestration/workflow-types.d.ts +1 -0
  63. package/dist/orchestration/workflow.js +1 -1
  64. package/dist/stores/file/mailbox-store.d.ts +2 -1
  65. package/package.json +1 -1
  66. package/test/export-surface.snapshot.json +125 -1
@@ -765,7 +765,12 @@ export type PendingAction = {
765
765
  * order (redemption TICKETS are keyed on the consent record's flat CANDIDATE list instead —
766
766
  * a chosen offer is redeemed via `redeemRuleBatch`), per-element parse with single-row
767
767
  * degrade on an unknown `kind` and
768
- * original-index preservation. The park→resume redemption chain walks the same consent
768
+ * original-index preservation. design/382 B3: a batch offer's members are the
769
+ * {@link import("./permission-rule-model.js").RuleOfferBatchMember} discriminated union
770
+ * (`command` | `directoryRead`), and the member-level degrade is the union's own normative
771
+ * arm — an unknown MEMBER kind drops the WHOLE batch offer (never one member: a conjunction
772
+ * silently one member short renders "yes to N" as "yes to N−1"), while the sibling single
773
+ * offers stay rendered. The park→resume redemption chain walks the same consent
769
774
  * protocol as the synchronous card — there is no second form. */
770
775
  ruleOffers?: readonly import("./permission-rule-model.js").RuleOffer[];
771
776
  /** #490 修② (additive; no checkpoint-version bump — the `previewWithheld`/`hasBidiControls`
@@ -777,7 +782,11 @@ export type PendingAction = {
777
782
  * the tool's own mandate marks, a hook-raised ask, an ancestor's authority, or — #502 — a
778
783
  * demotion this CALL's reversibility probe declared structural, which for the built-in shell
779
784
  * probe means a read outside the directories the session declared; an inbox must NOT point at
780
- * rule-writing on this arm, nor at a directory grant, which does not exist in this version),
785
+ * rule-writing on this arm "allow rules silence the classifier's questions, never a mandated
786
+ * one" — nor at a directory grant: the subpath-rule family exists, but the out-of-root arm has
787
+ * no clearing configuration in this version, so a grant minted in answer to this line would
788
+ * never take effect where it was minted; confirming the call is the whole of what a person can
789
+ * do about it),
781
790
  * `"shadowed"` (a rule the person already wrote is
782
791
  * speaking and does not clear it), `"lane_cannot_speak"` (this card has no rule to offer — the
783
792
  * grammar has no text for the command, or the card's array-order contract declines to present
@@ -786,6 +795,14 @@ export type PendingAction = {
786
795
  * "there are offers" and the structural doors (no lane wired, another tool, a task that cannot
787
796
  * hold a rule) — so read presence. See the synchronous seat's doc for the full contract. */
788
797
  ruleOffersAbsence?: "mandated" | "lane_cannot_speak" | "shadowed";
798
+ /** design/382 §2.4 (adversarial-review r3; additive — no checkpoint-version bump, the
799
+ * `previewWithheld` precedent: an optional field an older reader ignores and the resume
800
+ * path never reads) — the PARK twin of `AskRequest.execCwd`, minted by the SAME factory as
801
+ * {@link ruleOffers}: the relative-cd resolution base those offers were minted with (the
802
+ * live tracked cwd at park time). An approval inbox reconstructing the authoritative
803
+ * consent record threads it as `prepareCardApproval`'s `execCwd`. Present only beside
804
+ * {@link ruleOffers} when the run had a tracked cwd; echo-only, never a control input. */
805
+ execCwd?: string;
789
806
  /**
790
807
  * design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
791
808
  * SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at
@@ -1483,6 +1500,49 @@ export interface ResolveExpectation {
1483
1500
  /** The monotonic {@link Checkpoint.rev} the caller observed at `get()` (absent rev ⇒ legacy `0`). */
1484
1501
  rev: number;
1485
1502
  }
1503
+ /** design/384 slice 2 / S-25-R1 — the terminal intents a claim can carry. Deliberately the
1504
+ * TERMINAL pair only: `resolve` and `expire` are the two verbs that race one pending row to
1505
+ * its end (the design/51 fence law). `reopen` (resolved→pending) is not terminal and `reap`
1506
+ * is a bulk sweep — both stay on their own verbs. */
1507
+ export type TerminalClaimIntent = {
1508
+ kind: "resolve";
1509
+ outcome: ResumeOutcome;
1510
+ expect?: ResolveExpectation;
1511
+ } | {
1512
+ kind: "expire";
1513
+ };
1514
+ /** design/384 slice 2 / S-25-R1 — the single-round-trip answer: EITHER the claim won, OR it lost and
1515
+ * the SAME atomic operation reports what the row is now. `current` reuses the row's own vocabulary
1516
+ * (status / rev / resolvedOutcome) — no new value domain. `status:"pending"` on a loss is the OCC
1517
+ * arm only (`expect.rev` mismatch): the row is still open — re-get + re-validate, zero backoff (a
1518
+ * cleanly KNOWN state is never "unknown"). */
1519
+ export type TerminalClaimOutcome = {
1520
+ claimed: true;
1521
+ } | {
1522
+ claimed: false;
1523
+ current: {
1524
+ status: "pending" | "resolved" | "expired";
1525
+ /** `(cp.rev ?? 0)` — the absent-rev-reads-as-0 rule the OCC key already uses. */
1526
+ rev: number;
1527
+ /** Row-field PASSTHROUGH on every present status: the row PRESERVES it across `reopen`
1528
+ * (resolved→pending rows carry it), `expire` never clears it, and a winnerless resolve
1529
+ * (resource/wake/task_done) does not overwrite an older value — so even
1530
+ * `(status:"resolved", outcome present)` is only "the row is resolved AND this is its
1531
+ * LAST RECORDED decision winner", never a store-level guarantee that THIS resolve minted
1532
+ * it. Interpreting the pair as a current decision is the CALLER's discipline (the
1533
+ * runner's gate-kind matching; a single-decision row schema like a deployment's ask
1534
+ * rows). See {@link CheckpointStore.claimTerminal} law 6. */
1535
+ resolvedOutcome?: ResolvedOutcome;
1536
+ }
1537
+ /** Row missing — OR wrong scope: the CAS predicate is the shared REF-A4 row match
1538
+ * ({@link checkpointRowMatches}), and a wrong-scope claim must lose WITHOUT reporting the
1539
+ * row's truth (multi-tenant isolation: cross-scope truth in a loss answer would be a read
1540
+ * bypass of the scope WHERE). Absent is a KNOWN state (zero backoff): expire intent ⇒ moot;
1541
+ * resolve intent ⇒ the caller's typed not-found path, no retry. */
1542
+ | {
1543
+ status: "absent";
1544
+ };
1545
+ };
1486
1546
  /** A persisted suspension point: enough to resume a task on any replica. `status` drives the 3-state
1487
1547
  * machine (pending → resolved | expired) that makes resume idempotent (§5). */
1488
1548
  export interface Checkpoint {
@@ -2447,6 +2507,78 @@ export interface CheckpointStore {
2447
2507
  * `resolve`/`reap`.
2448
2508
  */
2449
2509
  expire(token: CheckpointToken, scope: string): Promise<boolean>;
2510
+ /**
2511
+ * design/384 slice 2 / S-25-R1 — the ATOMIC terminal claim: "win the terminal race" and "read the
2512
+ * truth on a loss" as ONE store operation. OPTIONAL, probed by presence (the `listByScope`
2513
+ * precedent); the runner's resume chain adopts it when present (the loser triages off `current`
2514
+ * with zero second read) and keeps the two-step resolve+get path otherwise. This JSDoc is the
2515
+ * single normative statement of the claim law — `put`'s create-once/read-back three-state and the
2516
+ * suspend saga's commit discipline are the same single-winner law's PUT variant and cross-reference
2517
+ * here rather than restate.
2518
+ *
2519
+ * The laws:
2520
+ * 1. **Atomicity** — the win/lose decision and the loser's `current` reading MUST come from the
2521
+ * same atomic unit (one `UPDATE … RETURNING` / one transaction / one single-threaded map
2522
+ * operation). A settle is self-sufficient: a caller holding `claimed:false` NEVER needs a
2523
+ * second read to act.
2524
+ * 2. **CAS-predicate identity** — `kind:"resolve"`'s win IS {@link resolve}'s win (same WHERE:
2525
+ * token+scope+pending[+rev]; a win records the winner via {@link winnerFromOutcome} and bumps
2526
+ * `rev` identically); `kind:"expire"` IS {@link expire}. ONE-MACHINE OBLIGATION: a store that
2527
+ * provides `claimTerminal` must route all three verbs through one protected write path
2528
+ * (resolve/expire implemented over claimTerminal, or all three over one shared CAS kernel) —
2529
+ * two predicates would be a drift window, and this law forbids it.
2530
+ * 3. **Timeout posture** — no timeout parameter here: transport deadlines belong to the backend,
2531
+ * wall-clock backstops to the caller. The contract states exactly two halves: a SETTLE carries
2532
+ * the truth; a REJECT proves NOTHING (the row may already be terminal — the caller must not
2533
+ * proclaim a terminal state from its own intent; fail-closed projection of "still pending"
2534
+ * stays legal). A LATE settle is as self-sufficient as a prompt one — an answer arriving after
2535
+ * the caller's own deadline still carries the true decision, so the caller settles on truth
2536
+ * with zero follow-up reads.
2537
+ * 4. **Recovery discipline — truth is comparable, ownership is not.** A bounded retry after a
2538
+ * reject re-sends the SAME claim (the CAS is idempotent). When the retry answers
2539
+ * `claimed:false`, `current` tells the caller what the row IS — never WHOSE claim made it so:
2540
+ * two claimants writing byte-identical outcomes cannot be told apart by content, and
2541
+ * {@link ResolvedOutcome} carries no claimant identity. Two consumption classes follow:
2542
+ * IDEMPOTENT consumption (settle on the recorded truth — who wrote it is irrelevant) may use
2543
+ * `claimed:false` + the recorded decision directly; RESPONSIBILITY-bearing consumption (only
2544
+ * the winner may execute the pending action — the runner's resume) must NEVER self-attribute
2545
+ * after ambiguity: treat it as a loss (the `already_resolved` shape, nothing executed) — the
2546
+ * same law as the existing crash-after-CAS pin ("retry sees resolved and does NOT re-execute"),
2547
+ * which this claim does not weaken. There is NO replay arm: a burned approval takes the
2548
+ * existing reopen/re-ask compensation route, and this contract opens no "assume approved and
2549
+ * replay" door. (A claimant-identity field would be a schema change; not here.) Expire-class
2550
+ * claims are naturally idempotent (expire-by-me ≡ expire-by-reaper; no attribution needed).
2551
+ * 5. **A loss reading is a consistent snapshot at the claim's linearization point, not an eternal
2552
+ * truth** — `resolved` can be reopened back to pending, so "settle is self-sufficient" scopes
2553
+ * to the disposition of THIS loss (settle on truth / re-validate on OCC / close on absent),
2554
+ * never to the row's future; a caller needing current state later still `get`s. `current` is a
2555
+ * projection, not the row (no state / pendingAction payloads).
2556
+ * 6. **`resolvedOutcome` reads as the row field it is, passed through verbatim** — the persisted
2557
+ * winner record: written only by decision-bearing resolves ({@link winnerFromOutcome} answers
2558
+ * `undefined` for resource/wake/task_done), PRESERVED by `reopen`, not cleared by later
2559
+ * winnerless resolves. In a loss answer it means "the row's last recorded decision winner",
2560
+ * NOT "the payload of the claim that beat you"; absence means "no decision winner recorded",
2561
+ * not "the winner had no content". For has-a-human-decided consumption this is exactly the
2562
+ * needed semantics; "who beat me / with what" is claimant-identity territory this contract
2563
+ * does not mint.
2564
+ * 7. **No declaration bit** — presence of the method IS the contract (an implementation
2565
+ * obligation at the same level as `resolve`'s CAS promise); no `redecision`-style declaration,
2566
+ * because that family guards against a stub satisfying a type, and a `claimTerminal` stub has
2567
+ * no such shape — the return form itself carries the obligation.
2568
+ *
2569
+ * Deployment correspondence (an ask-row store implementing the same law maps its own vocabulary):
2570
+ * token+scope ↔ the ask id (+ its run binding); pending/resolved/expired ↔ the ask row's
2571
+ * open/decided/terminal states; intent `resolve(outcome)` ↔ the human-decision verb; intent
2572
+ * `expire` ↔ the expire/cancel verbs; the `claimed:false → (status, resolvedOutcome)` PAIRED read
2573
+ * ↔ the decided row's recorded decision (reading the pair as the CURRENT decision is the CALLER's
2574
+ * discipline here — gate-kind matching — while a single-decision row schema satisfies it
2575
+ * structurally). Two shape notes carried from that correspondence: a store whose rows have NO
2576
+ * OCC/rev axis simply never produces the `status:"pending"` loss arm (an honestly absent arm is
2577
+ * correct — do not fabricate a rev); a store whose domain has NO reopen verb enjoys stronger
2578
+ * monotonicity than law 5 assumes — consumption written against law 5's weaker snapshot reading
2579
+ * stays correct there unchanged.
2580
+ */
2581
+ claimTerminal?(token: CheckpointToken, scope: string, intent: TerminalClaimIntent): Promise<TerminalClaimOutcome>;
2450
2582
  /**
2451
2583
  * CAS-expire `pending` checkpoints in `scope` whose `deadline` has passed (`deadline <= cutoff`):
2452
2584
  * `pending → expired`. Returns the count expired (for metrics). Idempotent across replicas (DB
@@ -2589,7 +2721,14 @@ export type CheckpointFaultMode =
2589
2721
  "resolve-after-commit"
2590
2722
  /** `resolve` throws *before* the CAS — simulates a crash before the commit; the row stays `pending`
2591
2723
  * so a retry can still win it. */
2592
- | "resolve-before-commit";
2724
+ | "resolve-before-commit"
2725
+ /** design/384 slice 2 — `claimTerminal` wins its claim (the commit lands) then throws before the
2726
+ * caller is acked: the claim-law recovery scenario (law 3/4 — a reject proves nothing; the retry's
2727
+ * loss answer carries the truth and a responsibility-bearing consumer must NOT self-attribute). */
2728
+ | "claim-after-commit"
2729
+ /** design/384 slice 2 — `claimTerminal` throws *before* its CAS: the row is untouched (still
2730
+ * pending), so a retry can still win the same claim. */
2731
+ | "claim-before-commit";
2593
2732
  /**
2594
2733
  * Default in-process {@link CheckpointStore}. Single-instance / tests only — it does NOT survive a
2595
2734
  * restart or span replicas, so it cannot deliver the cross-process guarantee a durable backend does.
@@ -2613,7 +2752,54 @@ export declare class InMemoryCheckpointStore implements CheckpointStore {
2613
2752
  private fault;
2614
2753
  put(token: CheckpointToken, cp: Checkpoint): Promise<void>;
2615
2754
  get(token: CheckpointToken): Promise<Checkpoint | null>;
2755
+ /** The loser's single-round-trip reading (claim law 1): what the row IS, in its own vocabulary —
2756
+ * or `absent` for a missing row AND a wrong-scope row alike (the isolation rule: cross-scope truth
2757
+ * in a loss answer would be a read bypass of the scope WHERE). Cloned projection, never a live ref. */
2758
+ protected claimLossFrom(cp: Checkpoint | undefined, scope: string): TerminalClaimOutcome;
2759
+ /**
2760
+ * The ONE protected write core for the terminal-resolve claim — `resolve` and
2761
+ * `claimTerminal({kind:"resolve"})` both run exactly this (claim law 2's one-machine obligation:
2762
+ * one predicate, one commit, no drift window). Single-threaded JS makes the check + flip atomic; a
2763
+ * durable backend folds the same predicate into its CAS WHERE clause.
2764
+ *
2765
+ * ORDER (design/384 slice 2, healing the pre-existing resolve shape): the winner is derived and
2766
+ * CLONED — the point where an uncloneable `updatedInput` says so — BEFORE any field is written, and
2767
+ * the commit is then one uninterrupted write group (status+rev+winner+reopenReason). The old order
2768
+ * cloned AFTER the status/rev flip, so an uncloneable winner threw with the row already resolved
2769
+ * and NO winner recorded; now the same throw leaves the row byte-identical (still pending) — the
2770
+ * claim REJECTS (a reject proves nothing, law 3) instead of half-committing.
2771
+ */
2772
+ protected claimResolveCore(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): TerminalClaimOutcome;
2773
+ /** The one protected write core for the terminal-expire claim — `expire` and
2774
+ * `claimTerminal({kind:"expire"})` both run exactly this (claim law 2, same as the resolve core). */
2775
+ protected claimExpireCore(token: CheckpointToken, scope: string): TerminalClaimOutcome;
2616
2776
  resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise<boolean>;
2777
+ /** design/384 slice 2 — the atomic terminal claim (the interface JSDoc is the law's single
2778
+ * normative statement). Implemented OVER the same protected cores `resolve`/`expire` run, which is
2779
+ * the one-machine obligation discharged rather than restated. ONE fault plane with the one
2780
+ * machine: a fault armed under the `resolve-*` names fires on the claim route of the SAME CAS too
2781
+ * (`kind:"resolve"` only — expire never consumed resolve faults), so a crash simulation pinned
2782
+ * against the two-step verb keeps firing byte-identically when the runner adopts the claim form.
2783
+ *
2784
+ * INHERITANCE OBLIGATION (a subclass provides `claimTerminal` whether it means to or not): a
2785
+ * subclass that overrides `resolve`/`expire` to wrap the CAS (audit hooks, fault injection,
2786
+ * interleave fixtures) must override THIS method consistently. Overriding the `protected` cores
2787
+ * (`claimResolveCore`/`claimExpireCore`) instead covers every COMMIT on every route — the cores
2788
+ * are the one write core all three verbs share — but NOT every ATTEMPT: `resolve`/`expire`
2789
+ * evaluate the shared row predicate themselves and return `false` without entering a core when
2790
+ * the row is not pending (missing, another scope, already terminal), while this claim route
2791
+ * enters the core unconditionally and takes its loss reading from it. A core override therefore
2792
+ * sees every winning claim and every OCC loss, but a row-predicate loss only when it arrived via
2793
+ * the claim route; a hook that must record EVERY attempt, including those losses, on EVERY route
2794
+ * wraps the three public verbs consistently. The same applies to a DELEGATING WRAPPER (Proxy / hand-built object)
2795
+ * that forwards `claimTerminal` while hooking only `resolve`: forwarding IS providing, and the
2796
+ * claim route is then the one that actually runs. A resolve-only wrap goes dark the moment a
2797
+ * consumer prefers the claim route (the runner's resume does), which is a one-machine-law
2798
+ * violation the SUBCLASS created — the base class cannot route through the public verbs
2799
+ * instead, because an `await` between the CAS and the loss reading would break the law-1
2800
+ * atomic unit this method exists to provide. (A wrapper that OMITS the member opts out
2801
+ * cleanly: the consumer's presence probe then keeps the two-step path, hooks intact.) */
2802
+ claimTerminal(token: CheckpointToken, scope: string, intent: TerminalClaimIntent): Promise<TerminalClaimOutcome>;
2617
2803
  reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise<boolean>;
2618
2804
  setPendingSteer(token: CheckpointToken, scope: string, steer: PendingSteerInput): Promise<boolean>;
2619
2805
  expire(token: CheckpointToken, scope: string): Promise<boolean>;
@@ -527,29 +527,71 @@ export class InMemoryCheckpointStore {
527
527
  const cp = this.cps.get(token);
528
528
  return cp ? structuredClone(cp) : null;
529
529
  }
530
- async resolve(token, scope, outcome, expect) {
531
- if (this.fault === "resolve-before-commit") {
532
- this.fault = null;
533
- throw new Error("injected fault: resolve before commit");
534
- }
530
+ claimLossFrom(cp, scope) {
531
+ if (cp === undefined || cp.scope !== scope)
532
+ return { claimed: false, current: { status: "absent" } };
533
+ return {
534
+ claimed: false,
535
+ current: {
536
+ status: cp.status,
537
+ rev: cp.rev ?? 0,
538
+ ...(cp.resolvedOutcome !== undefined ? { resolvedOutcome: structuredClone(cp.resolvedOutcome) } : {}),
539
+ },
540
+ };
541
+ }
542
+ claimResolveCore(token, scope, outcome, expect) {
535
543
  const cp = this.cps.get(token);
536
544
  if (!checkpointRowMatches(cp, scope, "pending")) {
537
- return false;
545
+ return this.claimLossFrom(cp, scope);
538
546
  }
539
547
  if (!checkpointOccMatches(cp, expect)) {
540
- return false;
548
+ return this.claimLossFrom(cp, scope);
541
549
  }
550
+ const winner = winnerFromOutcome(outcome);
551
+ const winnerClone = winner ? structuredClone(winner) : undefined;
542
552
  cp.status = "resolved";
543
553
  cp.rev = (cp.rev ?? 0) + 1;
544
- const winner = winnerFromOutcome(outcome);
545
- if (winner)
546
- cp.resolvedOutcome = structuredClone(winner);
554
+ if (winnerClone)
555
+ cp.resolvedOutcome = winnerClone;
547
556
  cp.reopenReason = undefined;
548
- if (this.fault === "resolve-after-commit") {
557
+ return { claimed: true };
558
+ }
559
+ claimExpireCore(token, scope) {
560
+ const cp = this.cps.get(token);
561
+ if (!checkpointRowMatches(cp, scope, "pending")) {
562
+ return this.claimLossFrom(cp, scope);
563
+ }
564
+ cp.status = "expired";
565
+ return { claimed: true };
566
+ }
567
+ async resolve(token, scope, outcome, expect) {
568
+ if (this.fault === "resolve-before-commit") {
569
+ this.fault = null;
570
+ throw new Error("injected fault: resolve before commit");
571
+ }
572
+ if (!checkpointRowMatches(this.cps.get(token), scope, "pending")) {
573
+ return false;
574
+ }
575
+ const out = this.claimResolveCore(token, scope, outcome, expect);
576
+ if (out.claimed && this.fault === "resolve-after-commit") {
549
577
  this.fault = null;
550
578
  throw new Error("injected fault: resolve after commit");
551
579
  }
552
- return true;
580
+ return out.claimed;
581
+ }
582
+ async claimTerminal(token, scope, intent) {
583
+ if (this.fault === "claim-before-commit" || (intent.kind === "resolve" && this.fault === "resolve-before-commit")) {
584
+ const name = this.fault === "claim-before-commit" ? "claim" : "resolve";
585
+ this.fault = null;
586
+ throw new Error(`injected fault: ${name} before commit`);
587
+ }
588
+ const out = intent.kind === "resolve" ? this.claimResolveCore(token, scope, intent.outcome, intent.expect) : this.claimExpireCore(token, scope);
589
+ if (out.claimed && (this.fault === "claim-after-commit" || (intent.kind === "resolve" && this.fault === "resolve-after-commit"))) {
590
+ const name = this.fault === "claim-after-commit" ? "claim" : "resolve";
591
+ this.fault = null;
592
+ throw new Error(`injected fault: ${name} after commit`);
593
+ }
594
+ return out;
553
595
  }
554
596
  async reopen(token, scope, reason) {
555
597
  const cp = this.cps.get(token);
@@ -571,12 +613,10 @@ export class InMemoryCheckpointStore {
571
613
  return true;
572
614
  }
573
615
  async expire(token, scope) {
574
- const cp = this.cps.get(token);
575
- if (!checkpointRowMatches(cp, scope, "pending")) {
616
+ if (!checkpointRowMatches(this.cps.get(token), scope, "pending")) {
576
617
  return false;
577
618
  }
578
- cp.status = "expired";
579
- return true;
619
+ return this.claimExpireCore(token, scope).claimed;
580
620
  }
581
621
  async reap(scope, cutoff) {
582
622
  let n = 0;
@@ -257,6 +257,9 @@ export interface ContextEditOptions {
257
257
  * append-only sessions cannot be rewritten. Dropping the junk from the REQUEST VIEW restores a legal
258
258
  * transcript (TR3 rejoins its batch). Deterministic ⇒ byte-stable per request ⇒ prefix-cache safe.
259
259
  * New sessions never contain these (the loop exits cleanly on abort; the harness skips persisting them).
260
+ * #506 ㋐ did NOT re-open this class: the API-failure record the harness now writes carries `API Error: …`
261
+ * TEXT, so it is not an empty failure assistant and this heal correctly leaves it alone. That record is
262
+ * excluded one layer later, at `convertToLlm`, by its own three-key identity (`isSyntheticApiErrorMessage`).
260
263
  */
261
264
  export declare function dropEmptyFailureAssistants(messages: AgentMessage[]): AgentMessage[];
262
265
  /**
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- 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", "workflow.agent_option_ignored", "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", "memory.consolidation_withheld", "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", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ 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", "workflow.agent_option_ignored", "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", "memory.consolidation_withheld", "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", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -124,6 +124,8 @@ export const ENGINE_NOTICE_CODES = [
124
124
  "task.user_followup_undrained",
125
125
  "steering.parked_input_blocked",
126
126
  "task.turn_interrupted",
127
+ "task.halt_unconsumed",
128
+ "task.late_approval",
127
129
  "memory.capture_opted_out",
128
130
  "memory.capture_optout_unpersisted",
129
131
  "tool_result.offload_put_failed",
@@ -139,6 +141,8 @@ const NOTICE_AUDIENCE_TABLE = {
139
141
  "task.user_followup_undrained": "user",
140
142
  "task.turn_interrupted": "user",
141
143
  "steering.parked_input_blocked": "user",
144
+ "task.halt_unconsumed": "user",
145
+ "task.late_approval": "user",
142
146
  "memory.capture_opted_out": "user",
143
147
  "memory.capture_optout_unpersisted": "user",
144
148
  "memory.consolidation_withheld": "user",
@@ -1169,7 +1169,10 @@ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
1169
1169
  unreadable?: true;
1170
1170
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
1171
1171
  };
1172
- /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort signal. */
1172
+ /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort
1173
+ * signal; when the caller also supplies {@link ToolGateInput.callSignal}, the Runner's closures
1174
+ * additionally bind their waits to that per-call signal (`AbortSignal.any` of the two), so a turn
1175
+ * interrupt releases a pending ask instead of leaving it hanging on the run's lifetime alone. */
1173
1176
  export interface ToolGateInput {
1174
1177
  /** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
1175
1178
  * isolation scope (onHookError itself, the deny observer, the durable-park observer). The runner
@@ -1214,13 +1217,29 @@ export interface ToolGateInput {
1214
1217
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
1215
1218
  * passed through unchanged; omitted when the deployment wired no execution environment. */
1216
1219
  hookEnv?: HookEnvCapabilities;
1217
- /** The combined tool-policy check (abort-bound), or undefined when no policy is wired (→ allow). */
1218
- adjudicate?: (req: ToolCallRequest) => Promise<PermissionResult>;
1220
+ /**
1221
+ * design/384 slice 1 — the PER-CALL abort signal, carried in from the `tool_call` hook event's
1222
+ * own `signal` seat (the loop's turn-scoped controller; the run abort is composed into it). The
1223
+ * gate itself never awaits on it — it hands the signal verbatim to the four consuming seats
1224
+ * below (`adjudicate`, `resolveAsk`, `resolveContentAsk`, and — slice 2 — `suspendAsk`, whose
1225
+ * park closure composes it into the halt×park fence) as their optional trailing argument, and
1226
+ * the Runner's closures compose it with the run signal so a turn interrupt (bare halt /
1227
+ * steer-now) releases the wait — or, on the durable leg, makes the park concede. Absent (a host
1228
+ * driving the gate directly, or an unthreaded emit) ⇒ every seat is invoked exactly as before —
1229
+ * same arity, no trailing `undefined` (the threading is a conditional invocation) — and waits
1230
+ * stay bound to the run signal alone: the historical shape, byte-identical.
1231
+ */
1232
+ callSignal?: AbortSignal;
1233
+ /** The combined tool-policy check (abort-bound), or undefined when no policy is wired (→ allow).
1234
+ * The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
1235
+ * through verbatim; an implementation that ignores it keeps the run-level binding. */
1236
+ adjudicate?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<PermissionResult>;
1219
1237
  /** Resolve an `ask` to allow/deny via `onAsk` (abort-bound). Required iff a decision can be `ask`.
1220
1238
  * G1 three-value: the result may carry `approverUnavailable` — the live approver affirmed no
1221
1239
  * operator is reachable for THIS ask; the gate then re-routes onto the durable park leg (below),
1222
- * keeping the carried fail-closed deny when no park is armed. */
1223
- resolveAsk: (decision: PermissionResult, req: ToolCallRequest) => Promise<ResolvedAsk>;
1240
+ * keeping the carried fail-closed deny when no park is armed. The optional trailing `callSignal`
1241
+ * (design/384) is {@link ToolGateInput.callSignal}, passed through verbatim. */
1242
+ resolveAsk: (decision: PermissionResult, req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ResolvedAsk>;
1224
1243
  /**
1225
1244
  * design/45: route a policy `ask` to a **durable suspension** instead of the synchronous `resolveAsk`
1226
1245
  * (onAsk) path. Called in the `ask` branch with the FINAL post-hook args; if it returns a suspend
@@ -1296,7 +1315,13 @@ export interface ToolGateInput {
1296
1315
  * rule the lane would then refuse to honor. APPENDED at the tail for the reason its neighbour
1297
1316
  * states — every parameter here is positional — and typed `boolean` so a mis-bind against the two
1298
1317
  * trailing `string` seats is a compile error rather than a silent re-binding. */
1299
- probeMandated?: boolean) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
1318
+ probeMandated?: boolean,
1319
+ /** design/384 slice 2 — {@link ToolGateInput.callSignal}, threaded VERBATIM as a CONDITIONAL
1320
+ * trailing argument (the slice-1 arity discipline: absent means the historical arity, never a
1321
+ * trailing `undefined`). The park closure composes it with the run signal into the halt×park
1322
+ * fence's cut signal; an implementation that ignores it keeps the run-level binding. APPENDED at
1323
+ * the tail like its neighbours, and typed `AbortSignal` so a mis-bind is a compile error. */
1324
+ callSignal?: AbortSignal) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
1300
1325
  /**
1301
1326
  * design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
1302
1327
  * before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
@@ -1309,8 +1334,10 @@ export interface ToolGateInput {
1309
1334
  * from "nobody home" — the two have opposite re-routing rules). Passed UNCONDITIONALLY by the runner,
1310
1335
  * including on runs with no live face: it then answers `unavailable` without contacting anything, which
1311
1336
  * is exactly the pre-174 park/refusal behavior. Absent entirely ⇒ the pre-174 path verbatim.
1337
+ * The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
1338
+ * through verbatim; an implementation that ignores it keeps the run-level binding.
1312
1339
  */
1313
- resolveContentAsk?: (req: ToolCallRequest) => Promise<ContentAskOutcome>;
1340
+ resolveContentAsk?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ContentAskOutcome>;
1314
1341
  /**
1315
1342
  * design/70: the called tool is egress-marked (`ToolSpec.egress` — an external write: open a PR,
1316
1343
  * push, send). The gate tightens a surviving `allow` to `ask` so an egress tool is NEVER
@@ -426,6 +426,7 @@ export function persistedRuleMandateOf(marks) {
426
426
  export async function runToolGate(input) {
427
427
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
428
428
  const { toolCallId, toolName } = event;
429
+ const callSignal = input.callSignal;
429
430
  const hookCtx = (seatSignal) => ({
430
431
  toolCallId,
431
432
  toolName,
@@ -506,7 +507,7 @@ export async function runToolGate(input) {
506
507
  }
507
508
  }
508
509
  const req = { toolName, args: currentInput, toolCallId };
509
- let decision = adjudicate ? await adjudicate(req) : { action: "allow" };
510
+ let decision = adjudicate ? await (callSignal !== undefined ? adjudicate(req, callSignal) : adjudicate(req)) : { action: "allow" };
510
511
  let denySource = "policy";
511
512
  let policyRewrite;
512
513
  if ((decision.action === "allow" || decision.action === "ask") && decision.updatedInput !== undefined) {
@@ -858,7 +859,8 @@ export async function runToolGate(input) {
858
859
  ? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
859
860
  : undefined;
860
861
  if (suspendAsk && decision.action === "ask") {
861
- 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, decision.action === "ask" ? decision.probeMandated : undefined);
862
+ const parkArgs = [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, decision.action === "ask" ? decision.probeMandated : undefined];
863
+ const suspended = await (callSignal !== undefined ? suspendAsk(...parkArgs, callSignal) : suspendAsk(...parkArgs));
862
864
  if (suspended) {
863
865
  if ("parkFailed" in suspended)
864
866
  parkFailed = suspended.parkFailed;
@@ -867,7 +869,7 @@ export async function runToolGate(input) {
867
869
  }
868
870
  }
869
871
  if (decision.action === "ask" && req.toolName === ASK_USER_QUESTION_TOOL_NAME) {
870
- const outcome = input.resolveContentAsk !== undefined ? await input.resolveContentAsk(req) : { kind: "unavailable", parkDeclined: false };
872
+ const outcome = input.resolveContentAsk !== undefined ? await (callSignal !== undefined ? input.resolveContentAsk(req, callSignal) : input.resolveContentAsk(req)) : { kind: "unavailable", parkDeclined: false };
871
873
  if (outcome.kind === "answered") {
872
874
  decision = { action: "allow", updatedInput: outcome.presentedInput };
873
875
  }
@@ -877,7 +879,8 @@ export async function runToolGate(input) {
877
879
  req.args = outcome.presentedInput;
878
880
  }
879
881
  if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
880
- 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, decision.action === "ask" ? decision.probeMandated : undefined);
882
+ const parkArgs = [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, decision.action === "ask" ? decision.probeMandated : undefined];
883
+ const suspended = await (callSignal !== undefined ? suspendAsk(...parkArgs, callSignal) : suspendAsk(...parkArgs));
881
884
  if (suspended) {
882
885
  if ("parkFailed" in suspended)
883
886
  parkFailed = suspended.parkFailed;
@@ -902,14 +905,15 @@ export async function runToolGate(input) {
902
905
  }
903
906
  if (decision.action === "ask") {
904
907
  const askBeforeResolve = decision;
905
- const resolved = await resolveAsk(decision, req);
908
+ const resolved = await (callSignal !== undefined ? resolveAsk(decision, req, callSignal) : resolveAsk(decision, req));
906
909
  if (resolved.action !== "ask" && resolved.approver !== undefined)
907
910
  resolvedApprover = resolved.approver;
908
911
  if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
909
912
  askDenyResolution = resolved.resolution;
910
913
  decision = resolved;
911
914
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
912
- 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, askBeforeResolve.action === "ask" ? askBeforeResolve.probeMandated : undefined);
915
+ const parkArgs = [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, askBeforeResolve.action === "ask" ? askBeforeResolve.probeMandated : undefined];
916
+ const suspended = await (callSignal !== undefined ? suspendAsk(...parkArgs, callSignal) : suspendAsk(...parkArgs));
913
917
  if (suspended) {
914
918
  if ("parkFailed" in suspended)
915
919
  parkFailed = suspended.parkFailed;
@@ -978,7 +982,7 @@ export async function runToolGate(input) {
978
982
  let recheck = { action: "allow" };
979
983
  if (adjudicate) {
980
984
  try {
981
- recheck = await adjudicate({ toolName, args: editArgs, toolCallId });
985
+ recheck = await (callSignal !== undefined ? adjudicate({ toolName, args: editArgs, toolCallId }, callSignal) : adjudicate({ toolName, args: editArgs, toolCallId }));
982
986
  }
983
987
  catch (err) {
984
988
  recheck = {
@@ -1014,7 +1018,9 @@ export async function runToolGate(input) {
1014
1018
  editArgs = recheck.updatedInput;
1015
1019
  editRewrittenSinceHuman = true;
1016
1020
  }
1017
- const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
1021
+ const editAskDecision = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
1022
+ const editAskReq = { toolName, args: editArgs, toolCallId };
1023
+ const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
1018
1024
  resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
1019
1025
  if (rr.action !== "allow") {
1020
1026
  if (rr.action === "deny" && isAskDenyResolution(rr.resolution))
@@ -15,9 +15,10 @@
15
15
  export declare const MCP_IMAGE_MAX_BASE64: number;
16
16
  /**
17
17
  * 批③ image-pipeline — CC 2.1.x `constants/apiLimits.ts` values, shared by the MCP inline-image bound
18
- * AND the Read tool's image branch (tools/fs — internal consistency: ONE yardstick per limit). See also
19
- * `tools/fs/index.ts:40-41` ("one limit, two consumers, no drift") — the other consumer of this same
20
- * yardstick, which is where this invariant was originally called out.
18
+ * AND the Read tool's image branch (tools/fs — internal consistency: ONE yardstick per limit). That other
19
+ * consumers are the file tools (`tools/fs/fs-read.ts`'s image branch, plus `fs-pdf`/`fs-bash`), which IMPORT
20
+ * {@link MCP_IMAGE_MAX_BASE64} / {@link IMAGE_TARGET_RAW_SIZE} from here (through core/mcp.js's re-export)
21
+ * rather than re-deriving them — one limit, every consumer reading it, no drift.
21
22
  * - `IMAGE_TARGET_RAW_SIZE` (apiLimits.ts:29): raw-byte target that guarantees the base64 encoding stays
22
23
  * under {@link MCP_IMAGE_MAX_BASE64} (raw × 4/3 = base64 → 3.75MB raw = 5MB base64).
23
24
  * - `IMAGE_MAX_WIDTH/HEIGHT` (apiLimits.ts:42-43): client-side resize box. The API internally resizes