@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
@@ -6,6 +6,8 @@ import { uuidv7 } from "../internal/harness.js";
6
6
  import { MAILBOX_TOMBSTONED_RECIPIENT_CODE, mailboxCrossProcessMountVerdict } from "../core/mailbox-store.js";
7
7
  import { peerSendVerdictSeverity } from "./cross-session-judge.js";
8
8
  import { parsePeerSessionAddress, peerSessionBoxHandle, resolvePeerSessions } from "./peer-directory.js";
9
+ import { peerIdleMachineFor } from "./peer-idle.js";
10
+ import { PEER_IDLE_OUTSTANDING_CAP, PEER_IDLE_SUBSCRIPTION_TTL_MS, peerIdleNoticeLabel } from "./peer-notices.js";
9
11
  import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
10
12
  import { inlineUntrusted } from "../core/untrusted-text.js";
11
13
  import { neutralizePeerBody } from "./cross-session-envelope.js";
@@ -50,6 +52,11 @@ function targetLaneKey(scope, targetId) {
50
52
  const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
51
53
  export function createSendMessageTool(opts) {
52
54
  const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
55
+ const notifyWhenIdleParameter = {
56
+ notify_when_idle: Type.Optional(Type.Boolean({
57
+ description: "Ask a peer SESSION of this engine to send you ONE notice when it next goes idle (finishes its turn with nothing queued) or exits — opt-in, one-shot, no polling. With a message: deliver it now AND subscribe. Without a message (omit it): a pure subscription that costs the other session nothing. Peer sessions only (not agents or teammates), and only from the main conversation.",
58
+ })),
59
+ };
53
60
  const peerLane = (() => {
54
61
  if (opts.peerDirectory === undefined)
55
62
  return { active: false };
@@ -58,6 +65,14 @@ export function createSendMessageTool(opts) {
58
65
  const verdict = mailboxCrossProcessMountVerdict(opts.mailbox);
59
66
  return verdict.ok ? { active: true } : { active: false, reason: verdict.reason };
60
67
  })();
68
+ const baseParameters = Type.Object({
69
+ to: Type.String({ description: 'Recipient: the agent\'s name, or its task_id (a…) returned by the Agent tool with run_in_background. "main" is reserved for the spawning conversation.' }),
70
+ message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
71
+ summary: Type.Optional(Type.String({ maxLength: 200, description: "A 5-10 word summary shown as a preview in the UI (required when message is a string)" })),
72
+ });
73
+ const sendMessageParameters = peerLane.active
74
+ ? Type.Object({ ...baseParameters.properties, message: Type.Optional(Type.String({ description: `${baseParameters.properties.message.description ?? ""} Optional only for a pure notify_when_idle subscription.`.trim() })), ...notifyWhenIdleParameter })
75
+ : baseParameters;
61
76
  const retrievalMounted = opts.retrievalToolMounted;
62
77
  const retrievalHedge = retrievalMounted === undefined ? " (where that tool is mounted)" : "";
63
78
  const RETRIEVE_NOW = retrievalMounted === false
@@ -144,19 +159,16 @@ export function createSendMessageTool(opts) {
144
159
  (peerLane.active
145
160
  ? ` Peer SESSIONS — other sessions of this engine for the same user${opts.peerListingMounted === false ? "" : opts.peerListingMounted === true ? " (list them with ListAgents)" : " (list them with ListAgents where that tool is mounted)"} — are addressed by name, by \`name [ref]\` when a name is ambiguous, or by their \`session.<id>\` address. A message to a peer is parked durably in its session box and read at its next turn boundary (an offline peer reads it when it next runs); the receipt confirms the box accepted it, not that the peer acted on it.`
146
161
  : ""),
147
- parameters: Type.Object({
148
- to: Type.String({ description: 'Recipient: the agent\'s name, or its task_id (a…) returned by the Agent tool with run_in_background. "main" is reserved for the spawning conversation.' }),
149
- message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
150
- summary: Type.Optional(Type.String({ maxLength: 200, description: "A 5-10 word summary shown as a preview in the UI (required when message is a string)" })),
151
- }),
162
+ parameters: sendMessageParameters,
152
163
  execute: async (args, ctx) => {
153
164
  const a = args;
154
165
  const operatorUserInitiated = ctx[OPERATOR_CONTINUATION_CTX] === true;
155
166
  const to = String(a.to ?? "").trim();
156
167
  const message = String(a.message ?? "").trim();
168
+ const notifyWhenIdle = a.notify_when_idle === true;
157
169
  if (!to)
158
170
  return { content: "Message not sent: 'to' was empty. Pass the agent's task_id (a…).", details: { error: "empty to" }, isError: true };
159
- if (!message)
171
+ if (!message && !notifyWhenIdle)
160
172
  return { content: "Message not sent: 'message' was empty.", details: { error: "empty message" }, isError: true };
161
173
  if (to === "*") {
162
174
  return {
@@ -173,7 +185,7 @@ export function createSendMessageTool(opts) {
173
185
  };
174
186
  }
175
187
  const summaryArg = typeof a.summary === "string" ? a.summary.trim() : "";
176
- if (summaryArg === "") {
188
+ if (summaryArg === "" && message !== "") {
177
189
  return {
178
190
  content: "Message not sent: summary is required when message is a string — pass a 5-10 word summary of the follow-up.",
179
191
  details: { error: "summary_required", to },
@@ -192,7 +204,7 @@ export function createSendMessageTool(opts) {
192
204
  if (isObserverTaskId(to)) {
193
205
  return { content: OBSERVER_SENDMESSAGE_TARGET_REFUSAL, details: { error: "observer_target", to }, isError: true };
194
206
  }
195
- const admissionConfig = resolvePeerAdmissionConfig(opts.admission);
207
+ const admissionConfig = resolvePeerAdmissionConfig(opts.admission, opts.onNotice);
196
208
  const guardScope = ctx.principal ?? opts.scope ?? opts.peerSelfSession?.scope;
197
209
  const selfRef = ctx.peerSelfRef ?? opts.peerSelf;
198
210
  const directMountSessionId = ctx.sessionId ?? opts.sessionId ?? opts.peerSelfSession?.sessionId;
@@ -244,11 +256,8 @@ export function createSendMessageTool(opts) {
244
256
  if (record.inboundPosture === "unavailable") {
245
257
  return peerRefusal("peer_send.recipient_refuses", `${peerLabel} advertises that it does not accept cross-session messages (its crossSessionInbound is "refuse"). Nothing was parked.`);
246
258
  }
247
- const recipientKey = peerAxisToken(peerScope, "s", record.sessionId);
248
- const storedContent = `[${summary}] ${clipCarrierMessage(message)}`;
249
- const sendVerdict = judgePeerAdmission(peerScope, recipientKey, { senderKey, body: storedContent, prospectiveChain, ownTokens: [recipientKey] }, admissionConfig, undefined, "send");
250
- if (!sendVerdict.ok)
251
- return admissionRefusal(sendVerdict.reason, peerLabel);
259
+ const modeClass = opts.peerSelfSession?.modeClass?.();
260
+ const now = Date.now();
252
261
  let boxFull = false;
253
262
  try {
254
263
  boxFull = (await opts.mailbox.peekCount(peerScope, handle)) >= admissionConfig.maxQueuedPeerMessages;
@@ -258,23 +267,56 @@ export function createSendMessageTool(opts) {
258
267
  if (boxFull) {
259
268
  return { content: `Message not sent: ${inlineUntrusted(peerLabel, 200)}'s session box is at its queued-message limit (${admissionConfig.maxQueuedPeerMessages.toFixed(0)}) — the message was NOT queued; it drains the box at its next turn. Resend later. ${DEDUP_RETRY_NOTE.slice(0, 200)}`, details: { error: "queue_full", code: "queue_full", to }, isError: true };
260
269
  }
261
- const modeClass = opts.peerSelfSession?.modeClass?.();
262
- const peerMeta = {
263
- ...(peerSelfSessionId !== undefined ? { fromSession: peerSelfSessionId } : {}),
264
- ...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
265
- ...(senderKey !== undefined ? { senderKey } : {}),
266
- kind: "peer_message",
267
- };
268
- const now = Date.now();
269
270
  let seq;
270
- try {
271
- seq = await opts.mailbox.append(peerScope, handle, { from: ownRowName ?? opts.peerSelfSession?.name ?? senderLabel, content: storedContent, sentAt: now, hopChain: prospectiveChain, peerMeta });
271
+ if (message !== "") {
272
+ const recipientKey = peerAxisToken(peerScope, "s", record.sessionId);
273
+ const storedContent = `[${summary}] ${clipCarrierMessage(message)}`;
274
+ const sendVerdict = judgePeerAdmission(peerScope, recipientKey, { senderKey, body: storedContent, prospectiveChain, ownTokens: [recipientKey] }, admissionConfig, undefined, "send");
275
+ if (!sendVerdict.ok)
276
+ return admissionRefusal(sendVerdict.reason, peerLabel);
277
+ const peerMeta = {
278
+ ...(peerSelfSessionId !== undefined ? { fromSession: peerSelfSessionId } : {}),
279
+ ...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
280
+ ...(senderKey !== undefined ? { senderKey } : {}),
281
+ kind: "peer_message",
282
+ };
283
+ try {
284
+ seq = await opts.mailbox.append(peerScope, handle, { from: ownRowName ?? opts.peerSelfSession?.name ?? senderLabel, content: storedContent, sentAt: now, hopChain: prospectiveChain, peerMeta });
285
+ }
286
+ catch (e) {
287
+ if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
288
+ return peerRefusal("peer_send.invalid_target", `${peerLabel} is being deleted — its box no longer accepts messages. Nothing was parked; do not retry.`);
289
+ }
290
+ return peerRefusal("peer_send.other", `the session box refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
291
+ }
272
292
  }
273
- catch (e) {
274
- if (e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE) {
275
- return peerRefusal("peer_send.invalid_target", `${peerLabel} is being deleted — its box no longer accepts messages. Nothing was parked; do not retry.`);
293
+ let subscription;
294
+ let subscriptionSeq;
295
+ if (notifyWhenIdle) {
296
+ if (peerSelfSessionId === undefined) {
297
+ subscription = { ok: false, code: "peer_send.other", severity: "error", message: "this mount has no session identity, so no idle notice could be addressed back to it — no subscription was made." };
298
+ }
299
+ else {
300
+ const ask = peerIdleMachineFor(peerScope, peerSelfSessionId).requester.request(record.sessionId, peerIdleNoticeLabel(record.name) ?? handle);
301
+ if (!ask.ok) {
302
+ subscription = { ok: false, code: "peer_send.subscription_cap", severity: peerSendVerdictSeverity("peer_send.subscription_cap"), message: `you already hold ${PEER_IDLE_OUTSTANDING_CAP} outstanding idle subscriptions — no new one was made; wait for a notice or for one to expire.` };
303
+ }
304
+ else {
305
+ const subMeta = {
306
+ fromSession: peerSelfSessionId,
307
+ ...(modeClass === "bypass" || modeClass === "prompting" ? { fromMode: modeClass } : {}),
308
+ kind: "idle_subscription",
309
+ };
310
+ try {
311
+ subscriptionSeq = await opts.mailbox.append(peerScope, handle, { from: ownRowName ?? opts.peerSelfSession?.name ?? senderLabel, content: `[idle subscription] ${ownRowName ?? opts.peerSelfSession?.name ?? senderLabel} asked to be told once when this session next goes idle`, sentAt: now, hopChain: [], peerMeta: subMeta });
312
+ subscription = { ok: true, disposition: record.liveness !== "live" ? "parked_offline" : "queued", via: "store" };
313
+ }
314
+ catch (e) {
315
+ peerIdleMachineFor(peerScope, peerSelfSessionId).requester.settle(record.sessionId, "unavailable");
316
+ subscription = { ok: false, code: e?.code === MAILBOX_TOMBSTONED_RECIPIENT_CODE ? "peer_send.invalid_target" : "peer_send.other", severity: "error", message: `the session box refused the subscription record (${(e instanceof Error ? e.message : String(e)).slice(0, 200)}) — no subscription was made.` };
317
+ }
318
+ }
276
319
  }
277
- return peerRefusal("peer_send.other", `the session box refused the message (${e instanceof Error ? e.message : String(e)}) — nothing was parked. ${DEDUP_RETRY_NOTE}`);
278
320
  }
279
321
  let rowRestored = "present";
280
322
  try {
@@ -294,12 +336,26 @@ export function createSendMessageTool(opts) {
294
336
  ? " Note: that session was DELETED while this message was being parked — its box is being retired and this message will not be read; do not resend to it."
295
337
  : "";
296
338
  const offline = record.liveness !== "live";
339
+ const subscriptionNote = subscription === undefined
340
+ ? ""
341
+ : subscription.ok
342
+ ? ` Idle subscription recorded (one-shot, expires unheard after ${PEER_IDLE_SUBSCRIPTION_TTL_MS / 3_600_000} h): you will get ONE automated notice when that session next goes idle or exits${offline ? " — it is offline now, so the ask waits in its box with the message" : ""}. Do not poll for it.`
343
+ : ` Idle subscription NOT made (${subscription.code}): ${subscription.message}`;
344
+ if (seq === undefined) {
345
+ if (subscription === undefined || !subscription.ok) {
346
+ return { content: `Subscription not made: ${subscription?.message ?? "nothing to do"}`, details: { error: subscription?.code ?? "peer_send.other", code: subscription?.code ?? "peer_send.other", to, ...(subscription !== undefined ? { subscription } : {}) }, isError: true };
347
+ }
348
+ return {
349
+ content: `Idle subscription recorded for ${peerLabel} (record seq ${subscriptionSeq}): you will get ONE automated notice when that session next goes idle or exits (one-shot; it expires unheard after ${PEER_IDLE_SUBSCRIPTION_TTL_MS / 3_600_000} h). No message was sent. Do not poll for it; continue with your task.${rowNote}`,
350
+ details: { type: "send-message", status: "peer_idle_subscribed", to, address: handle, subscription, ...(subscriptionSeq !== undefined ? { subscriptionSeq } : {}), ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
351
+ };
352
+ }
297
353
  const verdict = { ok: true, disposition: offline ? "parked_offline" : "queued", via: "store" };
298
354
  return {
299
355
  content: offline
300
- ? `Message parked for ${peerLabel}: that session is offline, so the message was parked durably in its session box (seq ${seq}) and will be delivered when it next runs — subject to the deployment's mailbox retirement policy, which does not notify you. Continue with your task; do not resend the same content.${rowNote}`
301
- : `Message queued for ${peerLabel} (seq ${seq}): it is parked durably in that session's box and will be read at its next turn boundary. This receipt confirms acceptance into the box, not the peer's judgment of the message. Continue with your task; do not resend the same content.${rowNote}`,
302
- details: { type: "send-message", status: offline ? "peer_parked_offline" : "peer_queued", to, address: handle, seq, verdict, ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
356
+ ? `Message parked for ${peerLabel}: that session is offline, so the message was parked durably in its session box (seq ${seq}) and will be delivered when it next runs — subject to the deployment's mailbox retirement policy, which does not notify you. Continue with your task; do not resend the same content.${subscriptionNote}${rowNote}`
357
+ : `Message queued for ${peerLabel} (seq ${seq}): it is parked durably in that session's box and will be read at its next turn boundary. This receipt confirms acceptance into the box, not the peer's judgment of the message. Continue with your task; do not resend the same content.${subscriptionNote}${rowNote}`,
358
+ details: { type: "send-message", status: offline ? "peer_parked_offline" : "peer_queued", to, address: handle, seq, verdict, ...(subscription !== undefined ? { subscription } : {}), ...(subscriptionSeq !== undefined ? { subscriptionSeq } : {}), ...(rowRestored !== "present" ? { directoryRow: rowRestored } : {}) },
303
359
  };
304
360
  };
305
361
  const settlePeerResolution = async (r) => {
@@ -329,6 +385,16 @@ export function createSendMessageTool(opts) {
329
385
  }
330
386
  }
331
387
  };
388
+ if (notifyWhenIdle) {
389
+ if (!peerLane.active) {
390
+ return peerRefusal("peer_send.invalid_target", peerLane.reason !== undefined ? `notify_when_idle is only supported for peer sessions of this engine, and the cross-session lane is unavailable on this mount — ${peerLane.reason}. Nothing was sent.` : `notify_when_idle is only supported for peer sessions of this engine, and cross-session addressing is not mounted here (no peer-session directory). Nothing was sent.`);
391
+ }
392
+ if (senderIsChild) {
393
+ return peerRefusal("peer_send.invalid_target", "notify_when_idle is only available from the main conversation of this session (not from a subagent or teammate). Nothing was sent.");
394
+ }
395
+ const settled = await settlePeerResolution(await resolvePeerTarget());
396
+ return settled ?? peerRefusal("peer_send.invalid_target", `notify_when_idle is only supported for peer sessions of this engine (not teammates, subagents or agents), and no peer session is registered as "${inlineUntrusted(to, 120)}". Nothing was sent.`);
397
+ }
332
398
  if (parsePeerSessionAddress(to) !== undefined) {
333
399
  if (!peerLane.active) {
334
400
  return peerRefusal("peer_send.invalid_target", peerLane.reason !== undefined ? `cross-session addressing is unavailable on this mount — ${peerLane.reason}. Nothing was sent.` : `cross-session addressing is not mounted here (no peer-session directory), so "${inlineUntrusted(to, 120)}" cannot be delivered.`);
@@ -2113,6 +2113,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2113
2113
  : ctx.parentCwd !== undefined
2114
2114
  ? { parentCwd: ctx.parentCwd }
2115
2115
  : {}),
2116
+ ...(reviveClaim === undefined && ctx.fileHistoryLineage !== undefined ? { fileHistoryLineage: ctx.fileHistoryLineage } : {}),
2116
2117
  ...(ctx.onSubagentSpawn ? { onSubagentSpawn: ctx.onSubagentSpawn } : {}),
2117
2118
  };
2118
2119
  const childThinking = def?.thinking ?? ctx.thinkingLevel;
@@ -11,3 +11,14 @@ export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2000;
11
11
  * recipe canonicalizer needs its VALUE and must not load the assembly face (and its SHA-locked assets)
12
12
  * to get it; `auto-mode-prompt.js` re-exports it, so the public name and its module face are unchanged. */
13
13
  export declare const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
14
+ /** CC 2.1.250 `FO = {maxConsecutive: 3, maxTotal: 20}` — the classifier DENIAL LIMITS applied when
15
+ * `RunnerDeps.autoMode.denialLimit` omits a bound. Count-then-judge: the block that reaches a bound is
16
+ * itself the one that falls back to a person (the 3rd consecutive block asks; the 20th total asks). */
17
+ export declare const AUTO_MODE_DENIAL_LIMIT_DEFAULTS: Readonly<{
18
+ maxConsecutive: number;
19
+ maxTotal: number;
20
+ }>;
21
+ /** CC 2.1.250 `AKe = 120000` — the fallback ask's auto-deny window (ms) when `autoDenyAfterMs` is omitted.
22
+ * `0` disarms the window (the ask waits on the approver alone — the upstream original "fall back to
23
+ * prompting" shape). */
24
+ export declare const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120000;
@@ -3,3 +3,5 @@ export const AUTO_MODE_DEFAULT_FAILURE_THRESHOLD = 3;
3
3
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
4
4
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2_000;
5
5
  export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
6
+ export const AUTO_MODE_DENIAL_LIMIT_DEFAULTS = Object.freeze({ maxConsecutive: 3, maxTotal: 20 });
7
+ export const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120_000;
@@ -98,3 +98,62 @@ export interface AutoModeDecider {
98
98
  * One instance per run/session — the breaker state is the session's "退回非 auto" latch.
99
99
  */
100
100
  export declare function createAutoModeDecider(opts: AutoModeDeciderOptions): AutoModeDecider;
101
+ /** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
102
+ * optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
103
+ * loudly at construction (never clamped, never read as the default). */
104
+ export interface AutoModeDenialLimitOptions {
105
+ /** Consecutive classifier blocks (no classifier/human allow between them) that fall back to a person.
106
+ * Default 3. A positive integer. */
107
+ maxConsecutive?: number;
108
+ /** Total classifier blocks per run that fall back to a person and reset the budget. Default 20. A
109
+ * positive integer. */
110
+ maxTotal?: number;
111
+ /** The fallback ask's auto-deny window, ms. Default 120_000; `0` = no window (the ask waits on the
112
+ * approver alone). A non-negative integer no greater than 2147483647 (a host timer truncates larger
113
+ * delays and fires at once — the fail-closed direction, and invisible). */
114
+ autoDenyAfterMs?: number;
115
+ }
116
+ /** The additive member a denial-limit fallback ask carries (`PermissionResult` ask arm, `AskRequest`):
117
+ * the counts that tripped the bound and the auto-deny window this particular ask runs under. */
118
+ export interface DenialLimitFallback {
119
+ /** Consecutive blocks INCLUDING the one that tripped the bound. */
120
+ readonly consecutive: number;
121
+ /** Total blocks this run INCLUDING the one that tripped the bound (read before the total-bound reset). */
122
+ readonly total: number;
123
+ /** Which bound tripped. */
124
+ readonly limit: "consecutive" | "total";
125
+ /** The auto-deny window for THIS ask, ms; `0` = none (disarmed by configuration, or the timed card was
126
+ * already shown for this streak). The ask resolver reads this member to arm its deadline. */
127
+ readonly autoDenyAfterMs: number;
128
+ }
129
+ export type DenialLimitVerdict = {
130
+ limitReached: false;
131
+ } | {
132
+ limitReached: true;
133
+ fallback: DenialLimitFallback;
134
+ };
135
+ /** The per-run denial tracker. Lives beside {@link AutoModeDecider} (same owner, same lifetime). */
136
+ export interface AutoModeDenialTracker {
137
+ /** A classifier `block`: count first, judge second (CC `eme` → `tme`). Returns whether THIS block is
138
+ * the one that falls back to a person, with the fallback's own snapshot. */
139
+ recordBlock(): DenialLimitVerdict;
140
+ /** A classifier `allow`, or a person's allow of a fallback ask (CC `yR`): consecutive → 0 and the
141
+ * timed-card mark cleared. A rule/fast-path allow must NOT call this. */
142
+ recordAllow(): void;
143
+ /** The current counts (diagnostics / pins). */
144
+ snapshot(): {
145
+ consecutive: number;
146
+ total: number;
147
+ timedFallbackShown: boolean;
148
+ };
149
+ }
150
+ /** Build the per-run tracker. Bad knob values throw (the deployment face is read at prepare; a refusal
151
+ * there is the loud exit the knob doctrine requires). */
152
+ export declare function createAutoModeDenialTracker(opts?: AutoModeDenialLimitOptions): AutoModeDenialTracker;
153
+ /** The fallback ask's text — CC's sentence family, single-sourced for every mint site (the main gate
154
+ * and the inherited-lane arms): `Classifier denial limit exceeded, falling back to prompting: <limit
155
+ * sentence>` + a blank line + `Latest blocked action: <the classifier's own reason, or the tool name>`.
156
+ * The reason is the classifier MODEL's text — the caller neutralizes it before it gets here. */
157
+ export declare function denialLimitFallbackMessage(fallback: DenialLimitFallback, latestBlockedAction: string): string;
158
+ /** The limit sentence alone (CC's two forms) — shared by the fallback ask and the headless terminal. */
159
+ export declare function denialLimitSentence(fallback: DenialLimitFallback): string;
@@ -1,4 +1,4 @@
1
- import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS } from "./auto-mode-defaults.js";
1
+ import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS } from "./auto-mode-defaults.js";
2
2
  export function parseAutoModeResponse(text) {
3
3
  const t = text
4
4
  .replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
@@ -99,3 +99,59 @@ class AutoModeTimeout extends Error {
99
99
  super("auto-mode classify timeout");
100
100
  }
101
101
  }
102
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
103
+ export function createAutoModeDenialTracker(opts = {}) {
104
+ const bound = (name, v, dflt) => {
105
+ if (v === undefined)
106
+ return dflt;
107
+ if (!Number.isInteger(v) || v < 1) {
108
+ throw new Error(`RunnerDeps.autoMode.denialLimit.${name} must be a positive integer (got ${JSON.stringify(v)}) — omit it for the default ${dflt}`);
109
+ }
110
+ return v;
111
+ };
112
+ const maxConsecutive = bound("maxConsecutive", opts.maxConsecutive, AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxConsecutive);
113
+ const maxTotal = bound("maxTotal", opts.maxTotal, AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxTotal);
114
+ let autoDenyAfterMs = AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS;
115
+ if (opts.autoDenyAfterMs !== undefined) {
116
+ if (!Number.isInteger(opts.autoDenyAfterMs) || opts.autoDenyAfterMs < 0 || opts.autoDenyAfterMs > MAX_TIMER_DELAY_MS) {
117
+ throw new Error(`RunnerDeps.autoMode.denialLimit.autoDenyAfterMs must be an integer between 0 and ${MAX_TIMER_DELAY_MS} (got ${JSON.stringify(opts.autoDenyAfterMs)}) — 0 disarms the window; omit it for the default ${AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS}`);
118
+ }
119
+ autoDenyAfterMs = opts.autoDenyAfterMs;
120
+ }
121
+ let consecutive = 0;
122
+ let total = 0;
123
+ let timedFallbackShown = false;
124
+ return {
125
+ recordBlock() {
126
+ consecutive += 1;
127
+ total += 1;
128
+ if (consecutive < maxConsecutive && total < maxTotal)
129
+ return { limitReached: false };
130
+ const totalTripped = total >= maxTotal;
131
+ const windowMs = timedFallbackShown || totalTripped ? 0 : autoDenyAfterMs;
132
+ const fallback = { consecutive, total, limit: totalTripped ? "total" : "consecutive", autoDenyAfterMs: windowMs };
133
+ if (totalTripped) {
134
+ consecutive = 0;
135
+ total = 0;
136
+ timedFallbackShown = false;
137
+ }
138
+ else if (windowMs > 0) {
139
+ timedFallbackShown = true;
140
+ }
141
+ return { limitReached: true, fallback };
142
+ },
143
+ recordAllow() {
144
+ consecutive = 0;
145
+ timedFallbackShown = false;
146
+ },
147
+ snapshot: () => ({ consecutive, total, timedFallbackShown }),
148
+ };
149
+ }
150
+ export function denialLimitFallbackMessage(fallback, latestBlockedAction) {
151
+ return `Classifier denial limit exceeded, falling back to prompting: ${denialLimitSentence(fallback)}\n\nLatest blocked action: ${latestBlockedAction}`;
152
+ }
153
+ export function denialLimitSentence(fallback) {
154
+ return fallback.limit === "total"
155
+ ? `${fallback.total} actions were blocked this session. Please review the transcript before continuing.`
156
+ : `${fallback.consecutive} consecutive actions were blocked. Please review the transcript before continuing.`;
157
+ }
@@ -125,7 +125,7 @@ export function buildRiskDescriptor(input) {
125
125
  if (shell || toolName === "Bash") {
126
126
  const cmd = isPlainRecord(args) ? safeDataValue(args, "command") : undefined;
127
127
  if (typeof cmd === "string" && cmd.length > 0)
128
- summary = renderUntrustedCommandText(redactSecrets(stripFormatCharacters(cmd)), SUMMARY_CMD_MAX);
128
+ summary = renderUntrustedCommandText(stripFormatCharacters(redactSecrets(cmd)), SUMMARY_CMD_MAX);
129
129
  const bg = isPlainRecord(args) ? safeDataValue(args, "run_in_background") : undefined;
130
130
  if (bg === true)
131
131
  summary = `[background persistent process — no per-step recheck] ${summary ?? ""}`.trimEnd();
@@ -140,7 +140,7 @@ export function buildRiskDescriptor(input) {
140
140
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
141
141
  const encDigest = (s) => s.replace(/%/g, "%25").replace(/=/g, "%3D").replace(/ /g, "%20");
142
142
  const k = encDigest(renderUntrustedCommandText(key, 40));
143
- const val = encDigest(renderUntrustedCommandText(redactSecrets(stripFormatCharacters(String(v))), SUMMARY_VALUE_MAX));
143
+ const val = encDigest(renderUntrustedCommandText(stripFormatCharacters(redactSecrets(String(v))), SUMMARY_VALUE_MAX));
144
144
  parts.push(`${k}=${val}`);
145
145
  }
146
146
  }
@@ -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", "config.durable_gate_unavailable", "config.peer_lane_unmounted", "peer.inbound_disposition", "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"];
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", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "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. */
@@ -102,8 +102,12 @@ export const ENGINE_NOTICE_CODES = [
102
102
  "config.tool_model_gate_unknown_class",
103
103
  "config.tool_model_gate_env_invalid",
104
104
  "config.durable_gate_unavailable",
105
+ "config.peer_admission_out_of_range",
105
106
  "config.peer_lane_unmounted",
106
107
  "peer.inbound_disposition",
108
+ "peer.held_settled",
109
+ "peer.idle_subscription",
110
+ "classifier.denial_limit",
107
111
  "delegation.transcript_integrity",
108
112
  "mcp.revocation_probe_failed",
109
113
  "workflow.governance_key_stripped",
@@ -142,6 +146,7 @@ const NOTICE_AUDIENCE_TABLE = {
142
146
  "memory.hold_disposed": "user",
143
147
  "task.user_steer_undrained": "user",
144
148
  "task.user_followup_undrained": "user",
149
+ "classifier.denial_limit": "user",
145
150
  "task.turn_interrupted": "user",
146
151
  "steering.parked_input_blocked": "user",
147
152
  "task.halt_unconsumed": "user",
@@ -159,7 +164,10 @@ const NOTICE_AUDIENCE_TABLE = {
159
164
  "config.tool_model_gate_unknown_class": "operator",
160
165
  "config.tool_model_gate_env_invalid": "operator",
161
166
  "config.peer_lane_unmounted": "operator",
167
+ "config.peer_admission_out_of_range": "operator",
162
168
  "peer.inbound_disposition": "user",
169
+ "peer.held_settled": "user",
170
+ "peer.idle_subscription": "user",
163
171
  "delegation.transcript_integrity": "operator",
164
172
  "mcp.revocation_probe_failed": "operator",
165
173
  "workflow.governance_key_stripped": "operator",
@@ -2,6 +2,7 @@ import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
3
  import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import { type AskClass } from "./ask-class.js";
5
+ import { type AutoModeDenialTracker } from "./auto-mode.js";
5
6
  import type { WiringLegKind } from "./wiring-manifest.js";
6
7
  /**
7
8
  * In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
@@ -429,6 +430,10 @@ export interface PermissionDeniedPayload {
429
430
  * screen; a policy's direct deny, a hook deny, and the crash/plan-mode/compliance emissions carry
430
431
  * none. See {@link import("./tool-policy.js").AskDenyResolution}. */
431
432
  resolution?: import("./tool-policy.js").AskDenyResolution;
433
+ /** #548 — the deny is the classifier denial-limit fallback's AUTO-DENY (core's own window elapsed;
434
+ * see {@link import("./tool-policy.js").ResolvedAsk.autoDenied}). Carried verbatim from the resolver;
435
+ * absent on every other deny. */
436
+ autoDenied?: true;
432
437
  /** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
433
438
  * already happened and nothing this callback does can change it, so the signal says exactly one
434
439
  * thing: stop reading, nobody is waiting for your answer any more. */
@@ -1458,7 +1463,32 @@ export interface ToolGateInput {
1458
1463
  */
1459
1464
  autoMode?: {
1460
1465
  decider: import("./auto-mode.js").AutoModeDecider;
1466
+ /**
1467
+ * #548 — the per-run DENIAL-LIMIT tracker beside the decider (CC 2.1.250 `FO`/`Wie`; same owner,
1468
+ * same lifetime). Absent ⇒ the pre-#548 chain byte for byte: every block is a deny, without bound.
1469
+ * Present ⇒ a block first counts, then judges; the block that reaches a bound becomes the fallback
1470
+ * ask (`requiresRealApproval: true` + `denialLimitFallback`), and a classifier allow or a person's
1471
+ * allow of that ask resets the consecutive count. The Runner builds it from
1472
+ * `RunnerDeps.autoMode.denialLimit`; a host driving the gate directly may hand its own.
1473
+ */
1474
+ denialTracking?: AutoModeDenialTracker;
1461
1475
  };
1476
+ /**
1477
+ * #548 — the HEADLESS arm of the denial-limit fallback: the fallback ask was resolved by a deny no
1478
+ * person made (no approver wired; a blanket `"allow"` refused as no approver; an approver that
1479
+ * reported unavailable with no park to take it). The fallback had nowhere to go, so the caller — which
1480
+ * owns the run — stops it (`TaskResult.errorCode = "classifier.denial_limit"`, CC's "too many
1481
+ * classifier denials in headless mode" abort). The deny itself still stands; this gate only names the
1482
+ * fact. A TOP-LEVEL seat, deliberately NOT under `autoMode`: the fallback can arrive from an ANCESTOR's
1483
+ * frozen tracker through the fold (a delegated child with no armed classifier of its own), and that
1484
+ * child's run is the one that has to stop. Observe-only for the gate: a throwing seat never alters
1485
+ * the deny.
1486
+ */
1487
+ onHeadlessDenialLimit?: (info: {
1488
+ toolName: string;
1489
+ toolCallId: string;
1490
+ fallback: import("./auto-mode.js").DenialLimitFallback;
1491
+ }) => void;
1462
1492
  /**
1463
1493
  * design/153 §2/§7.4 (件4 复审, MED): true means this call's ask is MARKED — an inherited ancestor
1464
1494
  * constraint already determined "no synchronous layer may resolve this ask" (the ancestor's frozen
@@ -1,5 +1,6 @@
1
- import { coreMintedResolutionOf, decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
1
+ import { coreMintedAutoDeniedOf, coreMintedResolutionOf, decisionText, describeThrown, isAskDenyResolution, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { brandPolicyAskClass } from "./ask-class.js";
3
+ import { denialLimitFallbackMessage } from "./auto-mode.js";
3
4
  import { inlineUntrusted } from "./untrusted-text.js";
4
5
  import { mintSystemReminder } from "./reminder-mint.js";
5
6
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
@@ -443,6 +444,7 @@ export async function runToolGate(input) {
443
444
  let hookAsk;
444
445
  let parkFailed;
445
446
  let askDenyResolution;
447
+ let askAutoDenied = false;
446
448
  const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
447
449
  const hookSeatMs = resolveHookTimeoutMs(input.hookTimeoutMs, (err) => traceHookCrash(input, err, notifier));
448
450
  const seatBound = (abortEnds) => ({
@@ -804,12 +806,14 @@ export async function runToolGate(input) {
804
806
  decision.action === "ask" &&
805
807
  decision.decisionReason !== "hook" &&
806
808
  decision.matchedAskRule === undefined &&
809
+ decision.denialLimitFallback === undefined &&
807
810
  req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
808
811
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
809
812
  const verdict = await input.autoMode.decider
810
813
  .decide({ req, askMessage: decisionText(decision) }, input.abortSignal)
811
814
  .catch(() => ({ kind: "unavailable", cause: "error" }));
812
815
  if (verdict.kind === "allow") {
816
+ input.autoMode.denialTracking?.recordAllow();
813
817
  decision = {
814
818
  action: "allow",
815
819
  message: "auto-mode classifier allowed this call",
@@ -820,12 +824,25 @@ export async function runToolGate(input) {
820
824
  else if (verdict.kind === "block") {
821
825
  const reason = verdict.reason ? inlineUntrusted(verdict.reason) : "";
822
826
  const category = verdict.category ? inlineUntrusted(verdict.category) : "";
823
- decision = {
824
- action: "deny",
825
- message: `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}`,
826
- decisionReason: "classifier",
827
- };
828
- denySource = "classifier";
827
+ const tracked = input.autoMode.denialTracking?.recordBlock();
828
+ if (tracked?.limitReached === true) {
829
+ decision = {
830
+ ...decision,
831
+ message: denialLimitFallbackMessage(tracked.fallback, reason || category || toolName),
832
+ decisionReason: "classifier",
833
+ requiresRealApproval: true,
834
+ denialLimitFallback: tracked.fallback,
835
+ };
836
+ denySource = "classifier";
837
+ }
838
+ else {
839
+ decision = {
840
+ action: "deny",
841
+ message: `auto-mode classifier blocked this call${reason ? `: ${reason}` : category ? `: [${category}]` : ""}`,
842
+ decisionReason: "classifier",
843
+ };
844
+ denySource = "classifier";
845
+ }
829
846
  }
830
847
  }
831
848
  if (input.sandboxAdmission !== undefined &&
@@ -910,6 +927,8 @@ export async function runToolGate(input) {
910
927
  resolvedApprover = resolved.approver;
911
928
  if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
912
929
  askDenyResolution = resolved.resolution;
930
+ if (resolved.action === "deny" && resolved.autoDenied === true)
931
+ askAutoDenied = true;
913
932
  decision = resolved;
914
933
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
915
934
  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];
@@ -921,6 +940,21 @@ export async function runToolGate(input) {
921
940
  return { suspend: suspended, preToolContext };
922
941
  }
923
942
  }
943
+ if (askBeforeResolve.action === "ask" && askBeforeResolve.denialLimitFallback !== undefined) {
944
+ if (decision.action === "allow") {
945
+ input.autoMode?.denialTracking?.recordAllow();
946
+ }
947
+ else if (askDenyResolution === "no_approver" ||
948
+ askDenyResolution === "blanket_allow_refused" ||
949
+ askDenyResolution === "approver_unavailable" ||
950
+ resolved.approverUnavailable === true) {
951
+ try {
952
+ input.onHeadlessDenialLimit?.({ toolName, toolCallId, fallback: askBeforeResolve.denialLimitFallback });
953
+ }
954
+ catch {
955
+ }
956
+ }
957
+ }
924
958
  if (decision.action === "allow" && decision.updatedInput !== undefined) {
925
959
  let editArgs = decision.updatedInput;
926
960
  let editDenied;
@@ -1062,7 +1096,8 @@ export async function runToolGate(input) {
1062
1096
  currentInput = decision.updatedInput;
1063
1097
  }
1064
1098
  const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
1065
- await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
1099
+ const denyAutoDenied = askAutoDenied || coreMintedAutoDeniedOf(decision, { toolCallId, toolName });
1100
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(denyAutoDenied ? { autoDenied: true } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
1066
1101
  const denySettledBy = decision.settledBy;
1067
1102
  const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
1068
1103
  return {