@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
@@ -1,4 +1,6 @@
1
- import { canonicalizeTarget, writeTargetPath } from "../tools/fs/safety.js";
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ import { canonicalizeTarget, expandHomeTilde, isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
2
4
  import { compileSegmentPattern, matchSegmentPatterns } from "../tools/fs/read-deny.js";
3
5
  const DEFAULT_GUARDED_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
4
6
  export const RECOMMENDED_SENSITIVE_PATTERNS = [
@@ -17,6 +19,9 @@ export const RECOMMENDED_SENSITIVE_PATTERNS = [
17
19
  ".git/modules",
18
20
  ".mcp.json",
19
21
  ".claude*",
22
+ ".sema",
23
+ ".sema.*",
24
+ ".sema-notifier-build",
20
25
  ".aws",
21
26
  ".config/gcloud",
22
27
  ".azure",
@@ -27,6 +32,33 @@ export const RECOMMENDED_SENSITIVE_PATTERNS = [
27
32
  ".zsh_history",
28
33
  ];
29
34
  const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
35
+ function dataRootSpellings(explicit) {
36
+ const raw = explicit ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent");
37
+ const home = homedir();
38
+ const tilde = expandHomeTilde(raw, home);
39
+ const engineForm = isAbsolute(raw) ? raw : resolve(raw);
40
+ const out = [];
41
+ for (const v of [raw, tilde, engineForm]) {
42
+ const anchored = isAbsolutePathForm(v) ? v : resolve(v);
43
+ if (!out.includes(anchored))
44
+ out.push(anchored);
45
+ }
46
+ return out;
47
+ }
48
+ function relativeUnderRoot(targetKey, rootKey) {
49
+ const seps = isWinFormPath(rootKey) ? ["\\", "/"] : ["/"];
50
+ let root = rootKey;
51
+ while (root.length > 0 && seps.includes(root[root.length - 1]))
52
+ root = root.slice(0, -1);
53
+ if (root.length === 0)
54
+ return null;
55
+ if (targetKey === root)
56
+ return "";
57
+ for (const sep of seps)
58
+ if (targetKey.startsWith(root + sep))
59
+ return targetKey.slice(root.length + 1);
60
+ return null;
61
+ }
30
62
  function compilePatterns(patterns) {
31
63
  const out = [];
32
64
  for (const raw of patterns) {
@@ -44,6 +76,20 @@ function matchSensitive(canonicalKey, compiled, aliasResolved) {
44
76
  }
45
77
  export function createSensitivePathPolicy(opts) {
46
78
  const compiled = compilePatterns(opts.patterns);
79
+ const dataRootPaths = dataRootSpellings(opts.dataRoot);
80
+ const dataRootKeys = async (signal) => {
81
+ const keys = [];
82
+ for (const spelling of dataRootPaths) {
83
+ try {
84
+ const r = await canonicalizeTarget(opts.env, spelling, signal);
85
+ if (r.ok && !keys.includes(r.key))
86
+ keys.push(r.key);
87
+ }
88
+ catch {
89
+ }
90
+ }
91
+ return keys;
92
+ };
47
93
  const guarded = new Set(opts.tools ?? DEFAULT_GUARDED_TOOLS);
48
94
  return {
49
95
  async check(req, signal) {
@@ -64,7 +110,16 @@ export function createSensitivePathPolicy(opts) {
64
110
  }
65
111
  return { action: "allow" };
66
112
  }
67
- const hit = matchSensitive(canon.key, compiled, canon.aliasResolved === true);
113
+ let relative = null;
114
+ for (const rootKey of await dataRootKeys(signal)) {
115
+ relative = relativeUnderRoot(canon.key, rootKey);
116
+ if (relative !== null)
117
+ break;
118
+ }
119
+ const judged = relative ?? canon.key;
120
+ if (relative === "")
121
+ return { action: "allow" };
122
+ const hit = matchSensitive(judged, compiled, canon.aliasResolved === true);
68
123
  if (hit) {
69
124
  return {
70
125
  action: "deny",
@@ -167,11 +167,27 @@ export interface TaskNotificationPayload {
167
167
  crossSessionMessage?: import("../agents/cross-session-envelope.js").CrossSessionEnvelopeFields & {
168
168
  body: string;
169
169
  };
170
+ /**
171
+ * design/385 §4.4 / §5.2 (slice 4) — the CROSS-SESSION NOTICE carrier: a delivery receipt about one of
172
+ * THIS session's own outbound messages (held / denied / expired / delivered / refused / dropped) or an
173
+ * idle notice for a `notify_when_idle` ask (idle / exited / unavailable / expired), drained from this
174
+ * session's own box as a notice-kind record. Its PRESENCE is the render discriminator: the model reads
175
+ * the engine-rendered notice line as-is (CC injects `[Cross-session delivery notice] …` /
176
+ * `[Cross-session idle notice] …` as a plain user-frame line) — never a `<task-notification>` shell,
177
+ * never the peer-message envelope (a notice is engine speech about a peer, not a peer's words; the
178
+ * envelope would put it past the parity judgment as a peer's request). `text` is rendered by the
179
+ * drain from the record's TYPED `peerMeta.notice` (the authority); the record's own `content` mirror is
180
+ * never what reaches the model. Minted ONLY by the engine's session-box drain.
181
+ */
182
+ crossSessionNotice?: {
183
+ kind: "delivery_notice" | "idle_notice";
184
+ text: string;
185
+ };
170
186
  /**
171
187
  * design/385 §1.4 d1 / §4.1 — the engine-minted PROVENANCE side record of an engine-injected peer
172
188
  * frame, a typed key (never model text) so a host can attribute and correlate the injection on the
173
189
  * wire. Present exactly when {@link agentMessage} OR {@link crossSessionMessage} is — one record
174
- * per carrier, `kind` naming which. Its fields are read per `kind` (see {@link SemaProvenance}):
190
+ * per carrier (the notice carrier {@link crossSessionNotice} too), `kind` naming which. Its fields are read per `kind` (see {@link SemaProvenance}):
175
191
  * on the agent-message lane `from` is the sender label the frame's attribute spells, `taskId` the
176
192
  * sender's run/agent id, `seq` the producer's per-frame counter (= this payload's `seq`) and
177
193
  * `agentType` the sender's resolved agent type when the producer knows it; on the cross-session
@@ -187,7 +203,7 @@ export interface SemaProvenance {
187
203
  /** `agent_message` = the same-process uplink (§1.4 d1); `cross_session_message` = the session-box
188
204
  * drain (§4.1) — `from` is then the sender's ADDRESS, `taskId` the recipient's own box handle,
189
205
  * `seq` the box seq, and the typed peer record rides in `peerMeta`. */
190
- kind: "agent_message" | "cross_session_message";
206
+ kind: "agent_message" | "cross_session_message" | "cross_session_notice";
191
207
  from: string;
192
208
  taskId: string;
193
209
  seq: number;
@@ -307,6 +323,12 @@ export declare function renderAgentMessageFrame(frame: {
307
323
  * level, another block — sharing either would blur the classifier's lane judgment.
308
324
  */
309
325
  export declare function renderCrossSessionMessageFrame(frame: NonNullable<TaskNotificationPayload["crossSessionMessage"]>): string;
326
+ /**
327
+ * design/385 §4.4 / §5.2 — render a cross-session notice line. The text is engine-authored from typed
328
+ * fields (every untrusted label inside it already passed the lane's label sanitizer); the reminder-tag
329
+ * defusal runs once more here so no carrier can smuggle a system-reminder into the model face.
330
+ */
331
+ export declare function renderCrossSessionNoticeLine(notice: NonNullable<TaskNotificationPayload["crossSessionNotice"]>): string;
310
332
  export declare function renderTaskNotificationXml(n: TaskNotificationPayload): string;
311
333
  /**
312
334
  * The BETWEEN-TURNS pending lane. A task notification born while NO turn is
@@ -57,11 +57,16 @@ export function renderCrossSessionMessageFrame(frame) {
57
57
  const { body, ...fields } = frame;
58
58
  return `${buildCrossSessionEnvelope(fields, body)}\n\n${CROSS_SESSION_MESSAGE_NOTICE}`;
59
59
  }
60
+ export function renderCrossSessionNoticeLine(notice) {
61
+ return sanitizeUntrustedText(notice.text);
62
+ }
60
63
  export function renderTaskNotificationXml(n) {
61
64
  if (n.agentMessage !== undefined)
62
65
  return renderAgentMessageFrame(n.agentMessage);
63
66
  if (n.crossSessionMessage !== undefined)
64
67
  return renderCrossSessionMessageFrame(n.crossSessionMessage);
68
+ if (n.crossSessionNotice !== undefined)
69
+ return renderCrossSessionNoticeLine(n.crossSessionNotice);
65
70
  const usage = n.usage === undefined
66
71
  ? undefined
67
72
  : (() => {
@@ -217,7 +222,7 @@ export function discloseDroppedPending(drained) {
217
222
  if (dropped === undefined || disclosed.has(lane))
218
223
  return [n];
219
224
  disclosed.add(lane);
220
- if (n.agentMessage !== undefined || n.crossSessionMessage !== undefined) {
225
+ if (n.agentMessage !== undefined || n.crossSessionMessage !== undefined || n.crossSessionNotice !== undefined) {
221
226
  const line = {
222
227
  task_id: n.task_id,
223
228
  task_type: n.task_type,
@@ -93,14 +93,25 @@ export type DecisionReason = (typeof DECISION_REASONS)[number];
93
93
  *
94
94
  * **Which windows `"timeout"` speaks for** (#114①, 2026-08-09 — the promise this note used to make was
95
95
  * wider than the code): the engine stamps it at the waits IT owns — `createApprovalPolicy`'s
96
- * `approvalTimeoutMs` window, and the durable park's TTL. The SYNCHRONOUS `onAsk` leg is not one of
97
- * them: there the deployment owns the window (the engine starts no timer for a callback it does not
98
- * schedule), so an unanswered card and a refused one arrive as the same `false` and the engine records
96
+ * `approvalTimeoutMs` window, the durable park's TTL, and (#548) the denial-limit fallback ask's
97
+ * auto-deny window. The SYNCHRONOUS `onAsk` leg is otherwise not one of them: there the deployment owns
98
+ * the window (the engine starts no timer for a callback it does not schedule), so an unanswered card and
99
+ * a refused one arrive as the same `false` and the engine records
99
100
  * `"human"` rather than inventing a cause it did not observe. A host that DOES time its own card out
100
101
  * can say so — {@link AskOutcome}'s object arm carries an optional `settledBy` for exactly this — but a
101
102
  * host that does not is indistinguishable, by construction. Read an absent `"timeout"` as "no window
102
103
  * the engine owns elapsed", never as "nobody's window elapsed".
103
104
  *
105
+ * **The one `onAsk` exception** (#548): a classifier DENIAL-LIMIT fallback ask — the ask that carries
106
+ * `denialLimitFallback`, minted when the auto-mode classifier reaches its consecutive/total bound —
107
+ * IS timed by the engine over the synchronous leg, because that ask exists to bound a classifier that
108
+ * would otherwise deny without end, and an unbounded wait would only move the "without end" onto the
109
+ * person. Its window elapsing produces an engine-stamped `settledBy: "timeout"` with
110
+ * `resolution: "window_expired"` and `autoDenied: true` on the deny — that last bit is the
111
+ * discriminator between core's window and a host self-report ({@link AskOutcome}'s object arm has no
112
+ * `autoDenied` seat, so a host cannot claim the word).
113
+ * `autoDenyAfterMs: 0` (or an ask with no fallback member) arms nothing, which is every other ask.
114
+ *
104
115
  * The three words are exhaustive and mutually exclusive over the ways an approval can end, and the
105
116
  * minimum discrimination a consumer needs — someone refused vs nobody answered — is `"human"` vs the
106
117
  * other two.
@@ -197,6 +208,17 @@ export type PermissionResult = {
197
208
  message?: string;
198
209
  decisionReason?: DecisionReason;
199
210
  requiresRealApproval?: boolean;
211
+ /** #548 (additive): this ask is the classifier DENIAL-LIMIT fallback — the block that reached a
212
+ * bound (CC 2.1.250 `FO`, count-then-judge) handed to a person instead of being denied.
213
+ * ENGINE-STAMPED beside `requiresRealApproval: true` and `decisionReason: "classifier"` at the
214
+ * classifier block sites (the gate's own and the inherited-lane arms); carries the counts and
215
+ * THIS ask's auto-deny window — see {@link import("./auto-mode.js").DenialLimitFallback}.
216
+ * Consumers: the gate's classifier step refuses to re-judge an ask carrying it (the classifier
217
+ * already spoke; rules and classifier stand BEFORE this ask, never after), and the ask resolver
218
+ * arms its deadline from `autoDenyAfterMs`. Carried onto the approval request. A policy that
219
+ * self-declares it only ever opts its own ask OUT of classifier resolution and INTO a bounded
220
+ * wait (tightening — the same safe direction as `matchedAskRule`). */
221
+ denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
200
222
  /** #144 disclosure (additive): a persisted allow rule MATCHED this call but could not clear the
201
223
  * ask, because the ask is MANDATED (operator shellGate:"always", or the tool's own
202
224
  * egress/irreversibility marks) rather than a classifier's hesitation — "allow rules silence
@@ -1163,6 +1185,13 @@ export interface AskRequest {
1163
1185
  * clear this ask (see that field's doc). `readonly`, filled by the gate from the decision, never a
1164
1186
  * caller/worker-settable field. */
1165
1187
  readonly requiresRealApproval?: boolean;
1188
+ /** #548 (additive) — present ⇔ this ask is the classifier DENIAL-LIMIT fallback (see the
1189
+ * {@link PermissionResult} ask-arm member of the same name): the counts that tripped the bound and
1190
+ * this ask's own auto-deny window. Two readers: a card renders it as a countdown; {@link resolveAsk}
1191
+ * arms its deadline from `autoDenyAfterMs` (> 0 ⇒ an unanswered function approver auto-denies at
1192
+ * that deadline — `settledBy:"timeout"`, `resolution:"window_expired"`, `autoDenied:true`). Filled
1193
+ * by the gate from the decision, never a caller/worker-settable field. */
1194
+ readonly denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
1166
1195
  /** Engine-judged risk axes of the action awaiting approval, for the human decision surface: a
1167
1196
  * coarse `requiresRealApproval` cannot tell "cannot be undone" from "data leaves the machine",
1168
1197
  * and those call for different scrutiny. Filled by the gate from the SAME resolved axes the
@@ -1270,7 +1299,8 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1270
1299
  *
1271
1300
  * #114② (2026-08-09) — `settledBy` on the object arm is the SYNCHRONOUS leg's only channel for saying
1272
1301
  * what ended the wait. This leg's window belongs to the HOST (`onAsk` is a callback the deployment
1273
- * owns; the engine starts no timer for it), so a host that timed its own approval card out could
1302
+ * owns; the engine starts no timer for it — with the ONE #548 exception below), so a host that timed
1303
+ * its own approval card out could
1274
1304
  * previously only report a plain `false`, which the engine correctly recorded as `"human"` — a person
1275
1305
  * refusing. The two words the host may self-report:
1276
1306
  * - `"human"` — a person answered (identical to omitting the field);
@@ -1282,6 +1312,15 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1282
1312
  * (abort, throw, unavailable, out-of-contract value), each already stamped at its own arm, and a
1283
1313
  * self-reported one would let a host relabel its refusal as an engine failure.
1284
1314
  *
1315
+ * #548 — the ONE ask on this leg the engine DOES time: a classifier denial-limit fallback (the ask
1316
+ * carrying `denialLimitFallback` with a non-zero `autoDenyAfterMs`). Its window is core's, because
1317
+ * the fallback exists to bound a classifier that would otherwise deny without end. When it elapses
1318
+ * the approver's promise is DETACHED (design/384: the wait is released, not cancelled — an approver
1319
+ * that answers afterwards is not consulted, and an `allow` it returns late becomes the
1320
+ * `task.late_approval` notice), and the engine's own deny carries `settledBy: "timeout"`,
1321
+ * `resolution: "window_expired"` and `autoDenied: true`. See {@link ApprovalSettledBy} for how a
1322
+ * consumer tells that window from the host's.
1323
+ *
1285
1324
  * `reason` — the SYNCHRONOUS leg's seat for a model-readable reason attached to a deny, the exact
1286
1325
  * counterpart of the durable leg's `ResumeOutcome` `policy_ask` `reason` ("Model-readable reason
1287
1326
  * attached to a deny"): the words the decider typed with their "no" (an approval card's rejection
@@ -1416,6 +1455,12 @@ export declare const ASK_DENY_RESOLUTION_VALUES: readonly AskDenyResolution[];
1416
1455
  * files or forwards the word (a policy layer could self-declare the member on its own deny; an
1417
1456
  * out-of-vocabulary word is dropped by the carriers, never coerced or forwarded). */
1418
1457
  export declare function isAskDenyResolution(v: unknown): v is AskDenyResolution;
1458
+ /** #548: the engine-attested auto-deny marker off a funneled decision, bound to the named call like
1459
+ * {@link coreMintedResolutionOf} (absent ⇒ not an attested auto-deny of THIS call). */
1460
+ export declare function coreMintedAutoDeniedOf(d: unknown, call: {
1461
+ toolCallId: string;
1462
+ toolName: string;
1463
+ }): boolean;
1419
1464
  /** Read the engine-attested resolution off a funneled decision (the gate's single deny exit is the
1420
1465
  * one consumer), FOR the named call: an attestation bound to a different toolCallId/toolName is a
1421
1466
  * replayed object, not this call's settlement — the reader answers absence (the safe direction; the
@@ -1452,6 +1497,12 @@ export type ResolvedAsk = PermissionResult & {
1452
1497
  * text; stamped only at the composing arm. Not stamped on the timeout deny (nobody answered)
1453
1498
  * or on any engine-produced fail-closed refusal. */
1454
1499
  humanRefusalNote?: true;
1500
+ /** #548 — present exactly when this deny is the classifier denial-limit fallback's AUTO-DENY: the
1501
+ * ask's own window (`AskRequest.denialLimitFallback.autoDenyAfterMs`, CC `AKe`) elapsed with no
1502
+ * answer. Rides beside `settledBy:"timeout"` / `resolution:"window_expired"` (the established approval
1503
+ * factory's deadline arm speaks the same two words, so a consumer classifying on them needs no new
1504
+ * branch) and says WHOSE window it was: core's, not the host's. Stamped only at the composing arm. */
1505
+ autoDenied?: true;
1455
1506
  /** The deny-arm classification (see {@link AskDenyResolution}) — present on every deny this
1456
1507
  * resolver composes, absent on every allow. Carried by the gate to its block exit, the
1457
1508
  * permission-denied observer payload and the settlement sideband (thence the call's `tool_end`
@@ -382,6 +382,7 @@ export function combinePolicies(...policies) {
382
382
  let settled;
383
383
  let ruleAskText;
384
384
  let probeMandateSeen = false;
385
+ let fallbackSeen;
385
386
  for (const p of policies) {
386
387
  const d = refuseOutOfContractDecision(await p.check(current, signal));
387
388
  if (d.action === "deny") {
@@ -411,12 +412,15 @@ export function combinePolicies(...policies) {
411
412
  }
412
413
  if (d.action === "ask" && d.probeMandated === true)
413
414
  probeMandateSeen = true;
415
+ if (d.action === "ask" && d.denialLimitFallback !== undefined && fallbackSeen === undefined)
416
+ fallbackSeen = d.denialLimitFallback;
414
417
  }
415
418
  if (asked) {
416
419
  const merged = rewrite?.updatedInput;
417
420
  const withRuleAsk = ruleAskText !== undefined && asked.matchedAskRule === undefined ? { ...asked, matchedAskRule: ruleAskText } : asked;
418
421
  const withMark = probeMandateSeen && withRuleAsk.probeMandated !== true ? { ...withRuleAsk, probeMandated: true } : withRuleAsk;
419
- return merged !== undefined ? { ...withMark, updatedInput: merged } : withMark;
422
+ const withFallback = fallbackSeen !== undefined && withMark.denialLimitFallback === undefined ? { ...withMark, denialLimitFallback: fallbackSeen, requiresRealApproval: true } : withMark;
423
+ return merged !== undefined ? { ...withFallback, updatedInput: merged } : withFallback;
420
424
  }
421
425
  const allowed = rewrite ?? ALLOW;
422
426
  return settled !== undefined ? { ...allowed, settledBy: settled } : allowed;
@@ -919,10 +923,16 @@ export function isAskDenyResolution(v) {
919
923
  return typeof v === "string" && ASK_DENY_RESOLUTION_SET.has(v);
920
924
  }
921
925
  const coreMintedResolutions = new WeakMap();
922
- function withCoreMintedResolution(d, resolution, call) {
923
- coreMintedResolutions.set(d, { resolution, toolCallId: call.toolCallId, toolName: call.toolName });
926
+ function withCoreMintedResolution(d, resolution, call, autoDenied) {
927
+ coreMintedResolutions.set(d, { resolution, toolCallId: call.toolCallId, toolName: call.toolName, ...(autoDenied === true ? { autoDenied: true } : {}) });
924
928
  return d;
925
929
  }
930
+ export function coreMintedAutoDeniedOf(d, call) {
931
+ if (typeof d !== "object" || d === null)
932
+ return false;
933
+ const v = coreMintedResolutions.get(d);
934
+ return v !== undefined && v.toolCallId === call.toolCallId && v.toolName === call.toolName && v.autoDenied === true;
935
+ }
926
936
  export function coreMintedResolutionOf(d, call) {
927
937
  if (typeof d !== "object" || d === null)
928
938
  return undefined;
@@ -1006,7 +1016,7 @@ export function carriesBidiControls(value, limits) {
1006
1016
  export async function resolveAsk(req, onAsk, signal, onLateSettlement) {
1007
1017
  const r = await resolveAskArms(req, onAsk, signal, onLateSettlement);
1008
1018
  if (r.action === "deny" && isAskDenyResolution(r.resolution))
1009
- return withCoreMintedResolution(r, r.resolution, req);
1019
+ return withCoreMintedResolution(r, r.resolution, req, r.autoDenied === true ? true : undefined);
1010
1020
  return r;
1011
1021
  }
1012
1022
  function raceAskWaitAgainstSignal(wait, signal) {
@@ -1110,7 +1120,20 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1110
1120
  args: approverView.value,
1111
1121
  ...(bidi ? { hasBidiControls: true } : {}),
1112
1122
  }, signal));
1113
- const raced = await raceAskWaitAgainstSignal(wait, signal);
1123
+ const windowMs = req.denialLimitFallback !== undefined && req.denialLimitFallback.autoDenyAfterMs > 0 ? req.denialLimitFallback.autoDenyAfterMs : undefined;
1124
+ const raced = await withTimeout(raceAskWaitAgainstSignal(wait, signal), windowMs, () => DEADLINE_ELAPSED);
1125
+ if (raced === DEADLINE_ELAPSED) {
1126
+ detachLateAskWait(wait, onLateSettlement);
1127
+ return {
1128
+ action: "deny",
1129
+ message: `no one answered the classifier denial-limit approval for "${req.toolName}" — the ${String(windowMs)}ms window ` +
1130
+ `elapsed with no answer; auto-denied: ${req.message}`,
1131
+ decisionReason: "mode",
1132
+ resolution: "window_expired",
1133
+ settledBy: "timeout",
1134
+ autoDenied: true,
1135
+ };
1136
+ }
1114
1137
  if (raced.tag === "aborted") {
1115
1138
  detachLateAskWait(wait, onLateSettlement);
1116
1139
  return { action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)`, decisionReason: "mode",