@sema-agent/core 7.3.1 → 7.4.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 (56) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/agents/peer-admission.d.ts +18 -3
  3. package/dist/agents/peer-admission.js +79 -4
  4. package/dist/agents/peer-held-queue.d.ts +101 -0
  5. package/dist/agents/peer-held-queue.js +229 -0
  6. package/dist/agents/peer-idle.d.ts +109 -0
  7. package/dist/agents/peer-idle.js +240 -0
  8. package/dist/agents/peer-notice-route.d.ts +33 -0
  9. package/dist/agents/peer-notice-route.js +46 -0
  10. package/dist/agents/peer-notices.d.ts +103 -0
  11. package/dist/agents/peer-notices.js +206 -0
  12. package/dist/agents/peer-session-drain.d.ts +39 -4
  13. package/dist/agents/peer-session-drain.js +248 -42
  14. package/dist/agents/send-message-tool.d.ts +8 -1
  15. package/dist/agents/send-message-tool.js +96 -30
  16. package/dist/agents/subagent.js +1 -0
  17. package/dist/core/auto-mode-defaults.d.ts +11 -0
  18. package/dist/core/auto-mode-defaults.js +2 -0
  19. package/dist/core/auto-mode.d.ts +59 -0
  20. package/dist/core/auto-mode.js +57 -1
  21. package/dist/core/checkpoint-store.js +2 -2
  22. package/dist/core/governance-codes.d.ts +1 -1
  23. package/dist/core/governance-codes.js +8 -0
  24. package/dist/core/hooks.d.ts +30 -0
  25. package/dist/core/hooks.js +43 -8
  26. package/dist/core/mailbox-store.d.ts +33 -1
  27. package/dist/core/mailbox-store.js +42 -2
  28. package/dist/core/runner/assemble-result.d.ts +5 -0
  29. package/dist/core/runner/assemble-result.js +1 -1
  30. package/dist/core/runner/denial-limit-arms.d.ts +149 -0
  31. package/dist/core/runner/denial-limit-arms.js +91 -0
  32. package/dist/core/runner/edited-files-ledger.d.ts +33 -0
  33. package/dist/core/runner/edited-files-ledger.js +14 -0
  34. package/dist/core/runner/prepare-hands-readface.d.ts +5 -0
  35. package/dist/core/runner/prepare-hands-readface.js +1 -0
  36. package/dist/core/runner/prepare-task.d.ts +62 -1
  37. package/dist/core/runner/prepare-task.js +120 -89
  38. package/dist/core/runner/runtask.js +10 -0
  39. package/dist/core/sensitive-path-policy.d.ts +27 -6
  40. package/dist/core/sensitive-path-policy.js +57 -2
  41. package/dist/core/task-notification.d.ts +24 -2
  42. package/dist/core/task-notification.js +6 -1
  43. package/dist/core/tool-policy.d.ts +55 -4
  44. package/dist/core/tool-policy.js +28 -5
  45. package/dist/core/types.d.ts +207 -10
  46. package/dist/index.d.ts +10 -5
  47. package/dist/index.js +8 -3
  48. package/dist/orchestration/workflow.js +7 -3
  49. package/dist/tools/fs/fs-write.d.ts +4 -4
  50. package/dist/tools/fs/fs-write.js +30 -11
  51. package/dist/tools/fs/index.d.ts +7 -1
  52. package/dist/tools/fs/index.js +1 -1
  53. package/dist/tools/fs/safety.d.ts +29 -8
  54. package/dist/tools/fs/safety.js +11 -1
  55. package/package.json +1 -1
  56. package/test/export-surface.snapshot.json +169 -1
@@ -35,11 +35,43 @@ export interface MailboxPeerMeta {
35
35
  fromScope?: string;
36
36
  /** Cross-principal delivery only — the delivery gate's receipt id (audit back-reference). */
37
37
  gateReceiptId?: string;
38
+ /** Notice-kind records only (design/385 §4.4 / §5.2): the typed notice the requester's drain renders
39
+ * — a delivery receipt's state (`kind: "delivery_notice"`) or an idle notice's kind
40
+ * (`kind: "idle_notice"`). The record's `content` is a human-readable mirror; THIS is the authority. */
41
+ notice?: MailboxPeerNoticeMeta;
42
+ }
43
+ /** design/385 — the typed body of a notice-kind record. `state` is drawn from the receipt states on a
44
+ * `delivery_notice` (`held|denied|expired|delivered|refused|dropped`) and from the idle kinds on an
45
+ * `idle_notice` (`idle|exited|unavailable|expired`); the validator admits the union, the drain checks
46
+ * the pairing (a state that does not fit its kind is settled `notice_unrouted`, never rendered). */
47
+ export interface MailboxPeerNoticeMeta {
48
+ state: string;
49
+ /** delivery_notice: the box seq of the SENDER'S message this receipt is about. */
50
+ refSeq?: number;
51
+ /** delivery_notice: the recipient's display label (as its directory row spells it). */
52
+ recipient?: string;
53
+ /** delivery_notice `dropped`: the ingress guard's reason. */
54
+ dropReason?: string;
55
+ /** idle_notice `idle`/`exited`: when the target finished its turn (epoch ms). */
56
+ finishedAt?: number;
57
+ /** idle_notice `idle`: the target's one-line detail (already bounded by the producer). */
58
+ detail?: string;
59
+ /** idle_notice `expired` (the requester answering ITSELF): the label the ask carried for the target
60
+ * that never answered — the record's author is the requester, so the target must be named here. */
61
+ target?: string;
38
62
  }
39
63
  export declare const MAILBOX_PEER_FROM_MODES: readonly ["bypass", "prompting"];
40
64
  export type MailboxPeerFromMode = (typeof MAILBOX_PEER_FROM_MODES)[number];
41
- export declare const MAILBOX_PEER_RECORD_KINDS: readonly ["peer_message", "idle_notice", "delivery_notice"];
65
+ /** design/385: `peer_message` runs the parity judgment; `idle_subscription` registers the sender as an
66
+ * idle subscriber of the box owner (§5.2, the store form of CC's `notify_when_idle` frame);
67
+ * `idle_notice` / `delivery_notice` land on the notice face (rendered, never enveloped as a peer's words). */
68
+ export declare const MAILBOX_PEER_RECORD_KINDS: readonly ["peer_message", "idle_notice", "delivery_notice", "idle_subscription"];
42
69
  export type MailboxPeerRecordKind = (typeof MAILBOX_PEER_RECORD_KINDS)[number];
70
+ /** The `notice.state` vocabulary the validator admits (the union of both notice families; the drain
71
+ * checks the kind↔state pairing). Spelled here, beside the record kinds, so the store contract is
72
+ * self-contained — the lane's own closed sets re-derive from these. */
73
+ export declare const MAILBOX_PEER_NOTICE_STATES: readonly ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"];
74
+ export type MailboxPeerNoticeState = (typeof MAILBOX_PEER_NOTICE_STATES)[number];
43
75
  /** The `append` refusal code for a malformed `peerMeta` (design/385): a garbage record is refused up
44
76
  * front with this code, never silently stripped or stored — a drain that reads a half-typed record
45
77
  * would judge on fabricated inputs. */
@@ -1,6 +1,7 @@
1
1
  import { assertRetentionPolicy } from "./retention-policy.js";
2
2
  export const MAILBOX_PEER_FROM_MODES = ["bypass", "prompting"];
3
- export const MAILBOX_PEER_RECORD_KINDS = ["peer_message", "idle_notice", "delivery_notice"];
3
+ export const MAILBOX_PEER_RECORD_KINDS = ["peer_message", "idle_notice", "delivery_notice", "idle_subscription"];
4
+ export const MAILBOX_PEER_NOTICE_STATES = ["held", "denied", "expired", "delivered", "refused", "dropped", "idle", "exited", "unavailable"];
4
5
  export const MAILBOX_INVALID_PEER_META_CODE = "mailbox.invalid_peer_meta";
5
6
  export const MAILBOX_CROSS_PROCESS_UNSAFE_CODE = "mailbox.cross_process_unsafe";
6
7
  const PEER_META_STRING_KEYS = ["fromSession", "senderKey", "fromScope", "gateReceiptId"];
@@ -38,14 +39,53 @@ export function readMailboxPeerMeta(raw) {
38
39
  return refuse(`${k} must be a non-empty string`);
39
40
  out[k] = v;
40
41
  }
42
+ else if (k === "notice") {
43
+ out.notice = readMailboxPeerNoticeMeta(v, refuse);
44
+ }
41
45
  else {
42
46
  return refuse(`carries an unknown key ${JSON.stringify(k)}`);
43
47
  }
44
48
  }
45
49
  return out;
46
50
  }
51
+ function readMailboxPeerNoticeMeta(raw, refuse) {
52
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
53
+ return refuse("notice must be a plain object");
54
+ const proto = Object.getPrototypeOf(raw);
55
+ if (proto !== Object.prototype && proto !== null)
56
+ return refuse("notice must be a plain object (a class instance, Date, Map or similar is not)");
57
+ const out = {};
58
+ for (const key of Reflect.ownKeys(raw)) {
59
+ if (typeof key !== "string")
60
+ return refuse("notice carries a symbol-keyed member");
61
+ const v = raw[key];
62
+ if (v === undefined)
63
+ continue;
64
+ if (key === "state") {
65
+ if (!MAILBOX_PEER_NOTICE_STATES.includes(v))
66
+ return refuse(`notice.state must be one of ${MAILBOX_PEER_NOTICE_STATES.join("|")}`);
67
+ out.state = v;
68
+ }
69
+ else if (key === "recipient" || key === "dropReason" || key === "detail" || key === "target") {
70
+ if (typeof v !== "string" || v === "")
71
+ return refuse(`notice.${key} must be a non-empty string`);
72
+ out[key] = v;
73
+ }
74
+ else if (key === "refSeq" || key === "finishedAt") {
75
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
76
+ return refuse(`notice.${key} must be a non-negative finite number`);
77
+ out[key] = v;
78
+ }
79
+ else {
80
+ return refuse(`notice carries an unknown key ${JSON.stringify(key)}`);
81
+ }
82
+ }
83
+ if (out.state === undefined)
84
+ return refuse("notice.state is required");
85
+ return out;
86
+ }
47
87
  export function cloneMailboxPeerMeta(meta) {
48
- return meta === undefined ? undefined : { ...meta };
88
+ return meta === undefined ? undefined : { ...meta, ...(meta.notice !== undefined ? { notice: { ...meta.notice } } : {}) };
49
89
  }
50
90
  export const MAILBOX_TOMBSTONED_RECIPIENT_CODE = "mailbox.recipient_tombstoned";
51
91
  export class MailboxStoreError extends Error {
@@ -117,6 +117,11 @@ export interface ResultFlags {
117
117
  * Pure pass-through — assembly neither adds nor filters (a rewind that FAILED never reaches here; it
118
118
  * throws at prepare and lands in the `threw` slot as a terminal errorCode). */
119
119
  rewindNotes?: TaskResult["rewindNotes"];
120
+ /** The run's OWN edited-file ledger, echoed on `TaskResult.editedFiles`. Pure pass-through on
121
+ * EVERY terminal — what this run's hands changed is a fact about the leg that ran, whatever
122
+ * terminal it reached (a failed/aborted run that edited files is exactly the case a "restore the
123
+ * code this message changed" affordance is for). Undefined ⇒ the key is omitted. */
124
+ editedFiles?: TaskResult["editedFiles"];
120
125
  /** The run's final turn was halted by a person's BARE rejection of a tool call (the parent-thread
121
126
  * control-flow boundary) — echoed on `TaskResult.haltedOnUserRejection`. Pure pass-through on
122
127
  * every terminal: the fact is about the leg that ran, whatever terminal it reached (on the normal
@@ -181,5 +181,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
181
181
  if (flags.unpricedSpend)
182
182
  delete publicStats.costMicroUsd;
183
183
  const stampHaltedByUser = flags.userHalted === true && status !== "suspended" && status !== "needs_review";
184
- return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
184
+ return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.editedFiles !== undefined && flags.editedFiles.length > 0 ? { editedFiles: flags.editedFiles } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
185
185
  }
@@ -0,0 +1,149 @@
1
+ import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback } from "../auto-mode.js";
2
+ import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "../tool-policy.js";
3
+ import { type EngineNotice } from "../types.js";
4
+ import type { Prepared } from "./prepare-task.js";
5
+ /**
6
+ * #548 — the classifier DENIAL-LIMIT arms of the tool gate's inherited (delegation) lane, extracted
7
+ * from `prepareTask` as a phase module (design/238 D-7: extract, don't accrete). The gate's OWN
8
+ * classifier block site lives in tool-policy; these are the pieces the runner threads around it:
9
+ * · the tracker attached to a re-supplied chain entry ({@link attachRebuiltDenialTrackers});
10
+ * · the run's ONE typed stop seat + the closure that fills it ({@link createDenialLimitStop});
11
+ * · the frozen classifier's judgment with the count-then-fallback step, shared by the two inherited
12
+ * wrapper arms and the approved-edit re-check ({@link judgeInheritedClassifier});
13
+ * · the post-resolution settlement of a fallback ask — a person's allow resets the streak, a deny
14
+ * nobody made stops the run ({@link settleDenialLimitFallback}).
15
+ * Each helper is pure over what prepare hands it; the fold/ask seams themselves stay in the runner.
16
+ */
17
+ /** An ancestor chain entry's classifier half as the arms read it: the decider and, beside it, the
18
+ * per-run denial tracker (absent on an entry that counts nothing). */
19
+ export interface InheritedAutoMode {
20
+ decider: AutoModeDecider;
21
+ denialTracking?: AutoModeDenialTracker;
22
+ }
23
+ /**
24
+ * A re-supplied chain entry (a cross-process redemption rebuilt its ancestor's classifier from the
25
+ * recorded recipe — `rebuildAutoModeDecider` hands back a decider and its arming, nothing live)
26
+ * carries no denial tracker, and an entry with a decider but no tracker would count nothing: the
27
+ * rebuilt classifier could block without bound on the redeemed leg, the exact gap the limit closes.
28
+ * Attach a FRESH tracker under this deployment's own bounds (a fresh count is the documented
29
+ * cross-process semantics — the breaker restarts the same way). Entries that already carry one (the
30
+ * in-process chain from a live ancestor) are kept BY IDENTITY; only a tracker-less armed entry is
31
+ * re-wrapped (its `policy`, the identity the fold and the pass-through compare, is untouched).
32
+ * ONE tracker per DECIDER identity, not per entry: a parent attaches its one decider to both its
33
+ * hook entry and its caller-policy entry, and a count split across two trackers could alternate
34
+ * forever without either reaching the bound. The tracker is minted lazily, so a bad bound refuses
35
+ * only a prepare that actually has such an entry (the armed leg's own mint screens the knob first).
36
+ */
37
+ export declare function attachRebuiltDenialTrackers<T extends {
38
+ autoMode?: InheritedAutoMode;
39
+ }>(entries: ReadonlyArray<T> | undefined, denialLimit: AutoModeDenialLimitOptions | undefined): T[] | undefined;
40
+ /** The one closure that stops a run for a headless denial-limit fallback (see {@link createDenialLimitStop}). */
41
+ export type StopForDenialLimit = (info: {
42
+ toolName: string;
43
+ toolCallId: string;
44
+ fallback: DenialLimitFallback;
45
+ }) => void;
46
+ /**
47
+ * The gate's typed stop seat (`Prepared.gateStopRef`) and the one closure that fills it — the headless
48
+ * arm of the denial-limit fallback: no approver to fall back to ⇒ stop the run with
49
+ * `classifier.denial_limit` (CC throws "Agent aborted: too many classifier denials in headless
50
+ * mode"). Once per run (the first headless fallback owns the terminal); the deny that triggered it
51
+ * stands as that call's result. Minted once so every mint site — the main gate's seat and the
52
+ * inherited-lane arms — stops the SAME run through the SAME seat. `abort` is the run's own abort
53
+ * (controller + harness), handed in because the harness is built after the seat is.
54
+ */
55
+ export declare function createDenialLimitStop(opts: {
56
+ sessionId: string;
57
+ runId: string;
58
+ onNotice: ((notice: EngineNotice) => void) | undefined;
59
+ abort: () => void;
60
+ }): {
61
+ gateStopRef: Prepared["gateStopRef"];
62
+ stopForDenialLimit: StopForDenialLimit;
63
+ };
64
+ /** The ask arm of a permission decision — what the inherited lane arbitrates. */
65
+ export type InheritedAsk = Extract<PermissionResult, {
66
+ action: "ask";
67
+ }>;
68
+ /**
69
+ * The delegation half of the two classifier exclusions the LIVE gate enforces: an ask raised by a hook
70
+ * (`decisionReason:"hook"`, engine-stamped, never self-supplied) and an ask minted by an explicit `ask`
71
+ * permission rule (`matchedAskRule`) are exactly as out of the FROZEN classifier's reach as the live
72
+ * one's — the person's / hook's standing decision travels with the frozen entry, and a frozen decider
73
+ * resolving it would re-open the same hole one process over. An ask that already IS a denial-limit
74
+ * fallback (a deeper ancestor's) is not re-judged either. Excluded asks fall through to the frozen
75
+ * approver chain, the delegation lane's original chain.
76
+ */
77
+ export declare function frozenClassifierExcluded(d: {
78
+ decisionReason?: string;
79
+ matchedAskRule?: string;
80
+ denialLimitFallback?: unknown;
81
+ }): boolean;
82
+ /** What {@link judgeInheritedClassifier} hands back to the arm that called it. */
83
+ export type InheritedClassifierJudgment<A extends InheritedAsk> =
84
+ /** The frozen classifier allowed — the arm returns its own allow shape (with or without the ask's rewrite). */
85
+ {
86
+ kind: "allow";
87
+ }
88
+ /** A block below the bound — the exact deny the ancestor's own gate would have produced. */
89
+ | {
90
+ kind: "deny";
91
+ result: PermissionResult;
92
+ }
93
+ /** Proceed to the frozen approver with `ask`: the incoming ask unchanged (classifier unavailable,
94
+ * excluded, or not armed), or — at the bound — the fallback ask minted from it (`mintedHere`).
95
+ * `fallback` is the member riding the ask either way (an incoming one is a deeper ancestor's). */
96
+ | {
97
+ kind: "resolve";
98
+ ask: A;
99
+ fallback: DenialLimitFallback | undefined;
100
+ mintedHere: boolean;
101
+ };
102
+ /**
103
+ * The ancestor's frozen classifier, run BEFORE its frozen approver in the ancestor's own gate order,
104
+ * with the denial-limit count folded in (CC 2.1.250 count-then-judge): `allow` ⇒ the classifier's
105
+ * auto-allow (and the streak resets); `block` below the bound ⇒ deny, the exact verdict the ancestor's
106
+ * gate would have produced; `block` AT the bound ⇒ the ask is re-spoken in the classifier's voice as
107
+ * the fallback (`decisionReason:"classifier"`, `requiresRealApproval`, the member carrying counts +
108
+ * window) for the arm to resolve at the frozen approver; anything else (unavailable / parse error /
109
+ * excluded / no classifier) ⇒ the incoming ask, unchanged. The member may ALREADY be on the incoming
110
+ * ask (a deeper ancestor's fallback, minted inside the nested fold — the case the exclusion names):
111
+ * it rides to the approver and the headless arm unchanged, whichever tracker minted it; only a
112
+ * tracker that counted THIS streak is reset by the person's later allow (`mintedHere`).
113
+ * `req` is the request as the classifier must see it (the arm's presented / edited args);
114
+ * `subject` is the noun the deny names ("this call" on the wrapper arms, "the approved edit" on the
115
+ * re-check).
116
+ */
117
+ export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
118
+ autoMode: InheritedAutoMode | undefined;
119
+ ask: A;
120
+ req: ToolCallRequest;
121
+ signal: AbortSignal;
122
+ subject: "this call" | "the approved edit";
123
+ }): Promise<InheritedClassifierJudgment<A>>;
124
+ /** The resolver's answer as the settlement reads it. */
125
+ export type FallbackResolution = Pick<ResolvedAsk, "action" | "resolution" | "approverUnavailable">;
126
+ /** A deny nobody made, at a wrapper arm: no approver wired, or a blanket `onAsk:"allow"` refused as
127
+ * no approver. `approver_unavailable` is NOT headless here — it floats to the main gate, which owns
128
+ * that arm (a park, or the fail-closed refusal). */
129
+ export declare function headlessDenyAtFold(r: FallbackResolution): boolean;
130
+ /** The re-check's reading: the approved-edit helper has NO park route (an approved edit is
131
+ * re-adjudicated in place), so an approver that reports unavailable is as headless as none at all. */
132
+ export declare function headlessDenyAtRecheck(r: FallbackResolution): boolean;
133
+ /**
134
+ * After the frozen approver answered a fallback ask (CC `yR`): a person's allow ends the ancestor's
135
+ * streak — only on the tracker that counted it (`mintedHere`; a deeper ancestor's fallback leaves
136
+ * this entry's count alone); a deny nobody made (`headless`, the arm's own reading) is the headless
137
+ * arm — the run stops through the one seat (`stop`). Any other answer settles nothing here. A no-op
138
+ * when the ask was not a fallback.
139
+ */
140
+ export declare function settleDenialLimitFallback(opts: {
141
+ fallback: DenialLimitFallback | undefined;
142
+ mintedHere: boolean;
143
+ tracker: AutoModeDenialTracker | undefined;
144
+ resolved: FallbackResolution;
145
+ headless: (r: FallbackResolution) => boolean;
146
+ stop: StopForDenialLimit;
147
+ toolName: string;
148
+ toolCallId: string;
149
+ }): void;
@@ -0,0 +1,91 @@
1
+ import { createAutoModeDenialTracker, denialLimitFallbackMessage, denialLimitSentence } from "../auto-mode.js";
2
+ import { deliverEngineNotice } from "../types.js";
3
+ import { inlineUntrusted } from "../untrusted-text.js";
4
+ export function attachRebuiltDenialTrackers(entries, denialLimit) {
5
+ const rebuiltTrackers = new Map();
6
+ const trackerForRebuilt = (decider) => {
7
+ let t = rebuiltTrackers.get(decider);
8
+ if (t === undefined) {
9
+ t = createAutoModeDenialTracker(denialLimit);
10
+ rebuiltTrackers.set(decider, t);
11
+ }
12
+ return t;
13
+ };
14
+ return entries?.map((pc) => pc.autoMode !== undefined && pc.autoMode.denialTracking === undefined
15
+ ? { ...pc, autoMode: { ...pc.autoMode, denialTracking: trackerForRebuilt(pc.autoMode.decider) } }
16
+ : pc);
17
+ }
18
+ export function createDenialLimitStop(opts) {
19
+ const gateStopRef = {};
20
+ const stopForDenialLimit = (info) => {
21
+ if (gateStopRef.terminal !== undefined)
22
+ return;
23
+ const message = `too many classifier denials in headless mode — ${denialLimitSentence(info.fallback)} The run was stopped: ` +
24
+ `the auto-mode classifier's denial limit falls back to a person, and no approver is wired to fall back to ` +
25
+ `(latest blocked action: "${info.toolName}").`;
26
+ const terminal = Object.assign(new Error(message), { code: "classifier.denial_limit" });
27
+ gateStopRef.terminal = terminal;
28
+ deliverEngineNotice(opts.onNotice, {
29
+ code: "classifier.denial_limit",
30
+ message,
31
+ detail: { sessionId: opts.sessionId, runId: opts.runId, toolName: info.toolName, toolCallId: info.toolCallId, consecutive: info.fallback.consecutive, total: info.fallback.total, limit: info.fallback.limit },
32
+ });
33
+ opts.abort();
34
+ };
35
+ return { gateStopRef, stopForDenialLimit };
36
+ }
37
+ export function frozenClassifierExcluded(d) {
38
+ return d.decisionReason === "hook" || d.matchedAskRule !== undefined || d.denialLimitFallback !== undefined;
39
+ }
40
+ export async function judgeInheritedClassifier(opts) {
41
+ const { autoMode, ask, req } = opts;
42
+ if (autoMode === undefined || frozenClassifierExcluded(ask))
43
+ return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false };
44
+ const verdict = await autoMode.decider
45
+ .decide({ req, ...(ask.message !== undefined ? { askMessage: ask.message } : {}) }, opts.signal)
46
+ .catch(() => ({ kind: "unavailable", cause: "error" }));
47
+ if (verdict.kind === "allow") {
48
+ autoMode.denialTracking?.recordAllow();
49
+ return { kind: "allow" };
50
+ }
51
+ if (verdict.kind !== "block")
52
+ return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false };
53
+ const reason = verdict.reason ? inlineUntrusted(verdict.reason) : "";
54
+ const category = verdict.category ? inlineUntrusted(verdict.category) : "";
55
+ const tracked = autoMode.denialTracking?.recordBlock();
56
+ if (tracked?.limitReached !== true) {
57
+ return {
58
+ kind: "deny",
59
+ result: {
60
+ action: "deny",
61
+ message: `auto-mode classifier blocked ${opts.subject} at an inherited ancestor layer${reason ? `: ${reason}` : category ? `: [${category}]` : ""}`,
62
+ decisionReason: "classifier",
63
+ },
64
+ };
65
+ }
66
+ const fallback = tracked.fallback;
67
+ return {
68
+ kind: "resolve",
69
+ ask: { ...ask, message: denialLimitFallbackMessage(fallback, reason || category || req.toolName), decisionReason: "classifier", requiresRealApproval: true, denialLimitFallback: fallback },
70
+ fallback,
71
+ mintedHere: true,
72
+ };
73
+ }
74
+ export function headlessDenyAtFold(r) {
75
+ return r.resolution === "no_approver" || r.resolution === "blanket_allow_refused";
76
+ }
77
+ export function headlessDenyAtRecheck(r) {
78
+ return headlessDenyAtFold(r) || r.resolution === "approver_unavailable" || r.approverUnavailable === true;
79
+ }
80
+ export function settleDenialLimitFallback(opts) {
81
+ const { fallback, resolved } = opts;
82
+ if (fallback === undefined)
83
+ return;
84
+ if (resolved.action === "allow") {
85
+ if (opts.mintedHere)
86
+ opts.tracker?.recordAllow();
87
+ }
88
+ else if (resolved.action === "deny" && opts.headless(resolved)) {
89
+ opts.stop({ toolName: opts.toolName, toolCallId: opts.toolCallId, fallback });
90
+ }
91
+ }
@@ -0,0 +1,33 @@
1
+ import type { FileEditedHook, TaskResult } from "../types.js";
2
+ /** The ceiling on DISTINCT FILES in a run's edited-file ledger (`TaskResult.editedFiles`). A result
3
+ * seat must be bounded by something other than how long the model keeps going; a single host-side
4
+ * user message that touches a thousand distinct files is already past what that observation is for.
5
+ * Far above the delegated-child projection's own cap, which bounds a NOTIFICATION preview rather
6
+ * than a per-message ledger — different job, different ceiling. The field's contract states this
7
+ * number and the saturation behaviour, so a consumer can tell a full list from a clipped one. */
8
+ export declare const RUN_EDITED_FILES_CAP = 1000;
9
+ /** A run's OWN edited-file ledger — the observation behind `TaskResult.editedFiles`. */
10
+ export interface EditedFilesLedger {
11
+ /** The hands band's post-write seat feeds this (the other end of the same mutation lane the
12
+ * first-touch hook sits at). */
13
+ note: FileEditedHook;
14
+ /** The read face: the ledger as `TaskResult.editedFiles`, or `undefined` when this run's hands
15
+ * landed nothing (the key is ABSENT then, never an empty array). A LIVE reader, read at result
16
+ * assembly — so the throw-path backstop terminal sees the same ledger the ordinary assembly does. */
17
+ snapshot: () => TaskResult["editedFiles"];
18
+ }
19
+ /**
20
+ * Build the ledger. Built UNCONDITIONALLY by prepare — unlike the first-touch history hook, which
21
+ * exists only when a history store is wired, this answers "what did this run change", a fact about
22
+ * the run and not about the store. Insertion order = order of first edit; the cap keeps a result seat
23
+ * bounded by something other than the model's persistence (past it, listed paths keep counting and
24
+ * new ones are dropped — see the field's own contract).
25
+ *
26
+ * Identity is the CANONICAL key, display is the model's own spelling. The two differ, and both halves
27
+ * matter: the band resolves a relative argument against the LIVE cwd, so one spelling can name two
28
+ * different files across a `cd` (keying on the spelling would merge them and drop a real file from
29
+ * the ledger), and two spellings can name one file (keying on the spelling would split one file into
30
+ * two rows). The first spelling that reached a given file is what the row shows — the coordinate the
31
+ * transcript used, which is the seat's stated path form.
32
+ */
33
+ export declare function createEditedFilesLedger(cap?: number): EditedFilesLedger;
@@ -0,0 +1,14 @@
1
+ export const RUN_EDITED_FILES_CAP = 1000;
2
+ export function createEditedFilesLedger(cap = RUN_EDITED_FILES_CAP) {
3
+ const counts = new Map();
4
+ return {
5
+ note: (n) => {
6
+ const seen = counts.get(n.key);
7
+ if (seen !== undefined)
8
+ seen.edits += 1;
9
+ else if (counts.size < cap)
10
+ counts.set(n.key, { path: n.path, edits: 1 });
11
+ },
12
+ snapshot: () => (counts.size > 0 ? [...counts.values()].map(({ path, edits }) => ({ path, edits })) : undefined),
13
+ };
14
+ }
@@ -204,6 +204,11 @@ export interface PrepareHandsReadFaceInput {
204
204
  /** design/381 — the first-touch history hook prepare built (present iff a fileHistoryStore is
205
205
  * wired and the run mounts a real fs env); threaded to the write band's trackFileEdit seat. */
206
206
  trackFileEdit?: import("../types.js").TrackFileEditHook;
207
+ /** borrowed — the mutation lane's LANDED observation seat (the accumulator behind
208
+ * `TaskResult.editedFiles`); threaded to the write band's onFileEdited seat. Unlike
209
+ * `trackFileEdit` it does NOT depend on a wired history store: it observes what this run's hands
210
+ * did, so it is present on every run that mounts the band. */
211
+ onFileEdited?: import("../types.js").FileEditedHook;
207
212
  }
208
213
  /** The phase's outputs (相 API 规则件 four-class form) — ALL settled before the return; the driver
209
214
  * binds them as fresh consts (R-5) except the inverted-closure trio and the two shellGated bits,
@@ -313,6 +313,7 @@ export async function prepareHandsMount(input) {
313
313
  reminderMark: input.reminderMark,
314
314
  reminderDisclosureCounts: input.reminderDisclosureCounts,
315
315
  ...(input.trackFileEdit !== undefined ? { trackFileEdit: input.trackFileEdit } : {}),
316
+ ...(input.onFileEdited !== undefined ? { onFileEdited: input.onFileEdited } : {}),
316
317
  includeShell: handsIncludeShell,
317
318
  readOnly: handsReadOnly,
318
319
  ...(handsCwdRef ? { cwdRef: handsCwdRef } : {}),
@@ -1,7 +1,7 @@
1
1
  import { AgentHarness, type ThinkingLevel } from "../../internal/harness.js";
2
2
  import type { Model } from "../../internal/llm.js";
3
3
  import { type CompactionForkContext } from "../auto-compaction.js";
4
- import { type AutoModeDecider } from "../auto-mode.js";
4
+ import { type AutoModeDecider, type AutoModeDenialTracker } from "../auto-mode.js";
5
5
  import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
6
6
  import { type MaterializedMcp } from "../mcp.js";
7
7
  import { type MaterializedA2a } from "../a2a.js";
@@ -214,6 +214,24 @@ export interface FileHistoryBoundarySeat {
214
214
  begin(entryId: string): void;
215
215
  settle(): Promise<void>;
216
216
  }
217
+ /** The ONE reading of {@link RunInternals.fileHistoryLineage} (see {@link resolveFileHistoryCoordinates}):
218
+ * the lineage's scope iff BOTH tree coordinates match; otherwise the run's own session. */
219
+ export declare function resolveFileHistoryScope(lineage: RunInternals["fileHistoryLineage"], historyRoot: string, historyFs: string, sessionId: string): string;
220
+ /**
221
+ * WHICH filesystem an env is a view of — the second coordinate of tree identity for the file-history
222
+ * lineage, read from what the env's MINTER states rather than inferred from a path:
223
+ * · a remote workspace names itself through its {@link WorkspaceHandle} (provider + sandbox, and the
224
+ * device lane's id when stamped) — two runs on one sandbox share a tree, two sandboxes never do;
225
+ * · an env that DECLARES its paths host-local (`hostLocalPaths: true`), or is the host adapter itself
226
+ * (a {@link NodeExecutionEnv}, which the #211 seam names as the host-local default), is the
227
+ * control-plane host's filesystem — every such env is one tree;
228
+ * · anything else — an env declaring `hostLocalPaths: false`, or an undeclared custom adapter — is
229
+ * attested to nothing, so it is its OWN tree: a per-instance token, which still matches when the
230
+ * child literally holds the parent's env object and never matches a fresh per-task mint.
231
+ * A remote handle that cannot be read (an env not yet connected) falls to the per-instance arm for
232
+ * the same reason: unattested is not shared.
233
+ */
234
+ export declare function fileHistoryFilesystemIdentity(env: ExecutionEnv): string;
217
235
  export interface Prepared {
218
236
  harness: AgentHarness;
219
237
  /** The CONCRETE built-in session (engine-internal: prepare constructs/acquires `StoredSession` itself,
@@ -320,6 +338,11 @@ export interface Prepared {
320
338
  /** RB-430-a: prepare-time rewind disclosures (conversation-only branch / no snapshot backend / no file
321
339
  * env), echoed verbatim onto `TaskResult.rewindNotes`. Present only when there is something to say. */
322
340
  rewindNotes?: NonNullable<TaskResult["rewindNotes"]>;
341
+ /** The run's edited-file ledger read face — what its OWN hands landed, as `TaskResult.editedFiles`
342
+ * (undefined when nothing landed: the key is absent, never an empty array). A LIVE reader rather
343
+ * than a snapshot, so the throw-path backstop terminal reports the same ledger the ordinary
344
+ * assembly would. Always present on Prepared; independent of whether a fileHistoryStore is wired. */
345
+ editedFilesSnapshot: () => TaskResult["editedFiles"];
323
346
  /** design/381 — the run's turn-start boundary seat (present iff a fileHistoryStore is wired and
324
347
  * the run mounts a real fs env). runtask calls begin() at the first committed user entry and
325
348
  * awaits settle() at the lease close + the finish tail. */
@@ -725,6 +748,19 @@ export interface Prepared {
725
748
  * the task the typed terminal — the harness turns a loop throw into an error assistant message, so
726
749
  * without this the cause would reach the caller only as the generic `provider.error`. */
727
750
  brainCallGuardrailRef: BrainCallGuardrailRef;
751
+ /**
752
+ * #548 — the tool gate's own TYPED STOP: set (once) when the classifier denial limit was reached with
753
+ * no approver to fall back to (headless), together with the run abort. The run loop adopts it as the
754
+ * terminal `threw` (`TaskResult.errorCode` = the error's `code`, `errorMessage` = its sentence) the
755
+ * same way it adopts the brain-call guardrail's — a loop that ended because THIS lane aborted it must
756
+ * report the cause, not the consequence. The abort-result details seam reads it too, so the aborted
757
+ * call's own `tool_end` carries the code. `undefined` ⇒ no gate stop happened.
758
+ */
759
+ gateStopRef: {
760
+ terminal?: Error & {
761
+ code: string;
762
+ };
763
+ };
728
764
  /** design/80 D-B — set by a tool calling `ctx.requestReview()` (the first-party `present_plan` tool, CC
729
765
  * ExitPlanMode parity): the run loop honors it at the next CLEAN turn boundary by minting a `plan_review`
730
766
  * checkpoint. `{ pending }` is set (with an optional reason) the moment a tool requests review; the boundary
@@ -1269,6 +1305,13 @@ export interface InheritedGate {
1269
1305
  */
1270
1306
  autoMode?: {
1271
1307
  decider: AutoModeDecider;
1308
+ /**
1309
+ * #548 — the ancestor's per-run DENIAL-LIMIT tracker, frozen beside its decider (same owner). The
1310
+ * wrapper arms count the frozen classifier's blocks on it and, at a bound, resolve the fallback
1311
+ * ask at the frozen approver instead of denying (`requiresRealApproval` set, sandbox admission
1312
+ * excluded). Live-only, like the decider: a cross-process redemption starts a fresh count.
1313
+ */
1314
+ denialTracking?: AutoModeDenialTracker;
1272
1315
  /**
1273
1316
  * #503 — the SERIALIZABLE criteria half of this classifier (assembly inputs + knobs + the
1274
1317
  * deployment's settings epoch), present when the arming deployment opted in
@@ -1801,6 +1844,24 @@ export interface RunInternals {
1801
1844
  * parent's cwd instead of an empty per-task sandbox. `isolation: "worktree"` wins over this when both set.
1802
1845
  */
1803
1846
  parentCwd?: string;
1847
+ /**
1848
+ * The spawning run's file-history LINEAGE — the scope it records first-touch edits into and the
1849
+ * TREE those records' keys are minted against (the canonical root spelling + the filesystem
1850
+ * identity of {@link fileHistoryFilesystemIdentity}) — threaded VERBATIM by core delegation
1851
+ * callers from {@link import("../types.js").ToolExecuteContext.fileHistoryLineage} (NEVER a
1852
+ * {@link TaskSpec} field). {@link resolveFileHistoryScope} is the ONE reading: this run records
1853
+ * into the lineage's scope iff its own tree coordinates BOTH equal the lineage's, and then
1854
+ * re-exposes the SAME triple on its own ctx, so every same-tree descendant of a root session — at
1855
+ * any depth — lands in the root session's scope (the fixed point), while a descendant on another
1856
+ * tree (worktree isolation, explicit `cwd`, a fresh per-task sandbox) becomes the root of its own
1857
+ * subtree's history. Absent on a top-level run, on a run with no live history store, and on a
1858
+ * tier-3 revival (the reviver's lineage says nothing about the revived row's tree).
1859
+ */
1860
+ fileHistoryLineage?: {
1861
+ scope: string;
1862
+ root: string;
1863
+ fs: string;
1864
+ };
1804
1865
  /**
1805
1866
  * [c209-D] — the EXPLICIT Agent.cwd request, distinct from the best-effort `parentCwd`
1806
1867
  * inheritance hint above: inheritance may be silently ignored by a factory (or absent without one),