@sema-agent/core 7.3.0 → 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 (72) hide show
  1. package/CHANGELOG.md +49 -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/brain/status-sink.d.ts +10 -0
  18. package/dist/brain/status-sink.js +13 -4
  19. package/dist/brain/stream-engine.d.ts +11 -0
  20. package/dist/brain/stream-engine.js +39 -3
  21. package/dist/core/arg-summary.d.ts +13 -3
  22. package/dist/core/arg-summary.js +138 -7
  23. package/dist/core/auto-mode-defaults.d.ts +11 -0
  24. package/dist/core/auto-mode-defaults.js +2 -0
  25. package/dist/core/auto-mode.d.ts +59 -0
  26. package/dist/core/auto-mode.js +57 -1
  27. package/dist/core/checkpoint-store.js +2 -2
  28. package/dist/core/governance-codes.d.ts +1 -1
  29. package/dist/core/governance-codes.js +8 -0
  30. package/dist/core/hooks.d.ts +30 -0
  31. package/dist/core/hooks.js +43 -8
  32. package/dist/core/mailbox-store.d.ts +33 -1
  33. package/dist/core/mailbox-store.js +42 -2
  34. package/dist/core/runner/assemble-result.d.ts +5 -0
  35. package/dist/core/runner/assemble-result.js +1 -1
  36. package/dist/core/runner/denial-limit-arms.d.ts +149 -0
  37. package/dist/core/runner/denial-limit-arms.js +91 -0
  38. package/dist/core/runner/edited-files-ledger.d.ts +33 -0
  39. package/dist/core/runner/edited-files-ledger.js +14 -0
  40. package/dist/core/runner/prepare-hands-readface.d.ts +5 -0
  41. package/dist/core/runner/prepare-hands-readface.js +1 -0
  42. package/dist/core/runner/prepare-task.d.ts +62 -1
  43. package/dist/core/runner/prepare-task.js +135 -89
  44. package/dist/core/runner/runtask.js +12 -0
  45. package/dist/core/sensitive-path-policy.d.ts +27 -6
  46. package/dist/core/sensitive-path-policy.js +57 -2
  47. package/dist/core/task-notification.d.ts +24 -2
  48. package/dist/core/task-notification.js +6 -1
  49. package/dist/core/tool-policy.d.ts +55 -4
  50. package/dist/core/tool-policy.js +28 -5
  51. package/dist/core/tools.js +1 -0
  52. package/dist/core/types.d.ts +251 -15
  53. package/dist/core/wiring-manifest.d.ts +41 -5
  54. package/dist/core/wiring-manifest.js +8 -0
  55. package/dist/engine/harness/agent-harness.d.ts +1 -0
  56. package/dist/engine/harness/agent-harness.js +3 -0
  57. package/dist/engine/harness/types.d.ts +3 -0
  58. package/dist/engine/loop/agent-loop.d.ts +7 -0
  59. package/dist/engine/loop/agent-loop.js +79 -0
  60. package/dist/engine/loop/types.d.ts +42 -0
  61. package/dist/index.d.ts +12 -6
  62. package/dist/index.js +10 -4
  63. package/dist/internal/harness-types.d.ts +1 -1
  64. package/dist/orchestration/workflow.js +7 -3
  65. package/dist/tools/fs/fs-write.d.ts +4 -4
  66. package/dist/tools/fs/fs-write.js +99 -14
  67. package/dist/tools/fs/index.d.ts +7 -1
  68. package/dist/tools/fs/index.js +1 -1
  69. package/dist/tools/fs/safety.d.ts +29 -8
  70. package/dist/tools/fs/safety.js +11 -1
  71. package/package.json +1 -1
  72. package/test/export-surface.snapshot.json +181 -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;
@@ -16,6 +16,16 @@ export declare function runWithStatusSink<T>(emit: (s: BrainStatus) => void, fn:
16
16
  * call site for this exact reason and states the contract ("an advisory frame must never break a settled
17
17
  * call"); the guard belongs HERE so every call site inherits it rather than each remembering.
18
18
  */
19
+ /**
20
+ * #530 — bind the ACTIVE sink now, for an emit that will happen from a timer callback later. The
21
+ * async-local store propagates through Node's timers, but not through every timer implementation a
22
+ * process may run under (fake clocks in tests replace `setTimeout` outright and run callbacks from
23
+ * their own stack, where `getStore()` is empty). A frame scheduled inside the sink's scope must reach
24
+ * that sink whichever clock fires it, so the scheduler captures the store here and the callback emits
25
+ * through the bound function. Same swallow guard and the same retryAtMs stamp as {@link emitBrainStatus};
26
+ * a no-op function when no sink is active.
27
+ */
28
+ export declare function bindBrainStatusEmitter(): (status: BrainStatus) => void;
19
29
  export declare function emitBrainStatus(status: BrainStatus): void;
20
30
  /**
21
31
  * Silent-fallback telemetry (C1/C4/C5/C6) — the brain→runner TELEMETRY channel, a
@@ -3,16 +3,25 @@ const statusSinkStore = new AsyncLocalStorage();
3
3
  export function runWithStatusSink(emit, fn) {
4
4
  return statusSinkStore.run({ emit }, fn);
5
5
  }
6
- export function emitBrainStatus(status) {
6
+ export function bindBrainStatusEmitter() {
7
+ const sink = statusSinkStore.getStore();
8
+ if (sink === undefined)
9
+ return () => undefined;
10
+ return (status) => deliverBrainStatus(sink, status);
11
+ }
12
+ function deliverBrainStatus(sink, status) {
7
13
  try {
8
- const sink = statusSinkStore.getStore();
9
- if (sink === undefined)
10
- return;
11
14
  sink.emit(withRetryDeadline(status));
12
15
  }
13
16
  catch {
14
17
  }
15
18
  }
19
+ export function emitBrainStatus(status) {
20
+ const sink = statusSinkStore.getStore();
21
+ if (sink === undefined)
22
+ return;
23
+ deliverBrainStatus(sink, status);
24
+ }
16
25
  function withRetryDeadline(status) {
17
26
  if (status.retryAtMs !== undefined)
18
27
  return status;
@@ -89,6 +89,17 @@ export declare function sameRequestModuloCap(original: SSERequest, rebuilt: SSER
89
89
  * Tolerates a `doFetch` (BYOM injection point) whose Response-shaped return has no usable `headers`.
90
90
  */
91
91
  export declare function shouldRetryHeaderVerdict(res: Response | undefined): boolean | undefined;
92
+ /**
93
+ * #530 — the first-token WAIT disclosure. While a first-token watchdog is armed
94
+ * (`firstTokenTimeoutMs > 0`) and no content token has arrived, the engine says so on the status
95
+ * channel: the FIRST `waiting_first_token` frame goes out once the wait has lasted
96
+ * {@link WAITING_FIRST_TOKEN_AFTER_MS}, and one more every {@link WAITING_FIRST_TOKEN_EVERY_MS} after
97
+ * that, until the first token lands, the watchdog fires, or the attempt ends. A short wait (under the
98
+ * threshold) announces nothing — the frame exists for the unattended "no answer for a minute" case,
99
+ * not for every request. The same 30s slice the retry countdown re-announces on.
100
+ */
101
+ export declare const WAITING_FIRST_TOKEN_AFTER_MS = 30000;
102
+ export declare const WAITING_FIRST_TOKEN_EVERY_MS = 30000;
92
103
  export interface SSERequest {
93
104
  url: string;
94
105
  headers: Record<string, string>;
@@ -3,7 +3,7 @@ import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } fr
3
3
  import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
4
4
  import { classifyInputTooLong } from "./input-too-long.js";
5
5
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
6
- import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
6
+ import { bindBrainStatusEmitter, emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
7
7
  import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
8
8
  const DEFAULT_MAX_RETRIES = 10;
9
9
  const MAX_RETRIES_ENV_CEILING = 15;
@@ -189,6 +189,8 @@ export function shouldRetryHeaderVerdict(res) {
189
189
  return undefined;
190
190
  }
191
191
  const RETRY_STATUS_SLICE_MS = 30_000;
192
+ export const WAITING_FIRST_TOKEN_AFTER_MS = 30_000;
193
+ export const WAITING_FIRST_TOKEN_EVERY_MS = 30_000;
192
194
  const THINKING_RETRY_BUDGET = 2;
193
195
  const EMPTY_USAGE = {
194
196
  input: 0,
@@ -249,6 +251,8 @@ export function runStreamingBrain(args) {
249
251
  const out = createAssistantMessageEventStream();
250
252
  let cleanup;
251
253
  let announcedRetry = false;
254
+ let announcedWait = false;
255
+ let terminalWaitDetail = "first token arrived";
252
256
  let terminalRetryPhase = "recovered";
253
257
  let terminalRetryDetail = "recovered after retrying";
254
258
  let requestIdSeen;
@@ -257,6 +261,7 @@ export function runStreamingBrain(args) {
257
261
  const aborted = signal?.aborted === true || isAbortError(err);
258
262
  terminalRetryPhase = "gave_up";
259
263
  terminalRetryDetail = aborted ? "cancelled while retrying" : "retries exhausted";
264
+ terminalWaitDetail = aborted ? "cancelled while waiting for the first token" : "the first-token wait ended in failure";
260
265
  const errorMsg = emptyAssistant(model);
261
266
  errorMsg.stopReason = aborted ? "aborted" : "error";
262
267
  errorMsg.errorMessage = err instanceof Error ? err.message : String(err);
@@ -278,10 +283,10 @@ export function runStreamingBrain(args) {
278
283
  })
279
284
  .finally(() => {
280
285
  cleanup?.();
281
- if (!announcedRetry)
286
+ if (!announcedRetry && !announcedWait)
282
287
  return;
283
288
  try {
284
- emitBrainStatus({ phase: terminalRetryPhase, detail: terminalRetryDetail });
289
+ emitBrainStatus({ phase: terminalRetryPhase, detail: announcedRetry ? terminalRetryDetail : terminalWaitDetail });
285
290
  }
286
291
  catch {
287
292
  }
@@ -468,11 +473,39 @@ export function runStreamingBrain(args) {
468
473
  let firstTokenSeen = false;
469
474
  let firstTokenTimedOut = false;
470
475
  let ftTimer;
476
+ let waitTimer;
477
+ const clearWaitTimer = () => {
478
+ if (waitTimer) {
479
+ clearTimeout(waitTimer);
480
+ waitTimer = undefined;
481
+ }
482
+ };
471
483
  if (firstTokenTimeoutMs && firstTokenTimeoutMs > 0) {
472
484
  ftTimer = setTimeout(() => {
473
485
  firstTokenTimedOut = true;
486
+ clearWaitTimer();
474
487
  void reader.cancel().catch(() => undefined);
475
488
  }, firstTokenTimeoutMs);
489
+ if (firstTokenTimeoutMs > WAITING_FIRST_TOKEN_AFTER_MS) {
490
+ const waitStartedAt = Date.now();
491
+ const emitWaitFrame = bindBrainStatusEmitter();
492
+ const scheduleWaitFrame = (delayMs) => {
493
+ waitTimer = setTimeout(() => {
494
+ waitTimer = undefined;
495
+ if (firstTokenSeen || firstTokenTimedOut || Date.now() - waitStartedAt >= firstTokenTimeoutMs)
496
+ return;
497
+ announcedWait = true;
498
+ emitWaitFrame({
499
+ phase: "waiting_first_token",
500
+ detail: "waiting for the first token",
501
+ elapsedMs: Date.now() - waitStartedAt,
502
+ timeoutMs: firstTokenTimeoutMs,
503
+ });
504
+ scheduleWaitFrame(WAITING_FIRST_TOKEN_EVERY_MS);
505
+ }, delayMs);
506
+ };
507
+ scheduleWaitFrame(WAITING_FIRST_TOKEN_AFTER_MS);
508
+ }
476
509
  }
477
510
  let idleTimedOut = false;
478
511
  let idleTimer;
@@ -497,12 +530,14 @@ export function runStreamingBrain(args) {
497
530
  clearTimeout(ftTimer);
498
531
  ftTimer = undefined;
499
532
  }
533
+ clearWaitTimer();
500
534
  };
501
535
  cleanup = () => {
502
536
  if (ftTimer) {
503
537
  clearTimeout(ftTimer);
504
538
  ftTimer = undefined;
505
539
  }
540
+ clearWaitTimer();
506
541
  if (idleTimer) {
507
542
  clearTimeout(idleTimer);
508
543
  idleTimer = undefined;
@@ -575,6 +610,7 @@ export function runStreamingBrain(args) {
575
610
  clearTimeout(ftTimer);
576
611
  ftTimer = undefined;
577
612
  }
613
+ clearWaitTimer();
578
614
  if (idleTimer) {
579
615
  clearTimeout(idleTimer);
580
616
  idleTimer = undefined;
@@ -71,9 +71,19 @@ export interface RedactionPass {
71
71
  /** A literal replacement (only `$1` substitution is used by these passes) or a replacer fn. */
72
72
  replace: string | ((match: string, ...groups: string[]) => string);
73
73
  }
74
- /** Run the declared passes in order, collecting findings (original-input spans) when a report is given.
75
- * An idempotent no-op replacement (inserted === matched, e.g. re-scrubbing already-redacted text) is
76
- * NOT a finding — nothing was removed. */
74
+ /**
75
+ * Run the declared passes in order, collecting findings (original-input spans) when a report is given.
76
+ *
77
+ * FORMAT CHARACTERS (#532): the passes scan a VIEW of the input with every `\p{Cf}` removed, so a
78
+ * credential split by a zero-width character is one token to the scanner, not two fragments of which
79
+ * only the first (or neither) matches. The OUTPUT keeps every format character that sits OUTSIDE a
80
+ * replaced span — a joiner in an emoji sequence, a non-joiner in Persian text, a soft hyphen in a word
81
+ * — and drops only those INSIDE the span the marker replaces (they were part of the secret). A
82
+ * format-free input takes the exact historical path (byte-identical output). When the replacements
83
+ * cannot be mapped back exactly (a later pass matched across an earlier pass's inserted text), the
84
+ * output is the redacted view itself — every format character dropped: the failure direction is
85
+ * over-stripping, never a leak.
86
+ */
77
87
  export declare function runRedactionPasses(input: string, passes: readonly RedactionPass[], report?: RedactionReport): string;
78
88
  /** The credential-shape passes (the pre-channel SECRET_PATTERNS, now with declared kinds/confidence).
79
89
  * Exported for {@link runRedactionPasses} composition (untrusted-egress chains its URL passes after
@@ -25,10 +25,10 @@ function mapBackOnePass(edits, pos) {
25
25
  return pos - delta;
26
26
  }
27
27
  const PREEXISTING_MARKER_RE = /\[redacted(?:-[a-z]+)?\]/g;
28
- export function runRedactionPasses(input, passes, report) {
29
- if (report !== undefined && report.preexistingMarkers === undefined) {
30
- report.preexistingMarkers = input.match(PREEXISTING_MARKER_RE)?.length ?? 0;
31
- }
28
+ const FORMAT_CHAR_RE = /\p{Cf}/u;
29
+ const MAX_FORMAT_SLOT_SCANS = 64;
30
+ const REDACTED_WHOLE = "[redacted]";
31
+ function applyPasses(input, passes, report, toOriginal) {
32
32
  const batches = [];
33
33
  let cur = input;
34
34
  for (const pass of passes) {
@@ -51,18 +51,149 @@ export function runRedactionPasses(input, passes, report) {
51
51
  report.findings.push({
52
52
  kind: pass.kind,
53
53
  confidence: pass.confidence,
54
- span: [s0, e0],
54
+ span: [toOriginal(s0), toOriginal(e0)],
55
55
  marker: pass.marker,
56
56
  source: pass.source,
57
57
  });
58
58
  }
59
- edits.push({ at: offset, removedLen: match.length, insertedLen: inserted.length });
59
+ edits.push({ at: offset, removedLen: match.length, insertedLen: inserted.length, inserted });
60
60
  }
61
61
  return inserted;
62
62
  });
63
63
  batches.push(edits);
64
64
  }
65
- return cur;
65
+ return { out: cur, batches };
66
+ }
67
+ function replacedSpans(batches) {
68
+ const spans = [];
69
+ for (let j = 0; j < batches.length; j++) {
70
+ for (const e of batches[j]) {
71
+ let s0 = e.at;
72
+ let e0 = e.at + e.removedLen;
73
+ for (let i = j - 1; i >= 0; i--) {
74
+ s0 = mapBackOnePass(batches[i], s0);
75
+ e0 = mapBackOnePass(batches[i], e0);
76
+ }
77
+ if (e0 - s0 !== e.removedLen)
78
+ return undefined;
79
+ spans.push({ start: s0, end: e0, inserted: e.inserted, pass: j });
80
+ }
81
+ }
82
+ spans.sort((a, b) => a.start - b.start);
83
+ for (let i = 1; i < spans.length; i++)
84
+ if (spans[i].start < spans[i - 1].end)
85
+ return undefined;
86
+ return spans;
87
+ }
88
+ export function runRedactionPasses(input, passes, report) {
89
+ if (report !== undefined && report.preexistingMarkers === undefined) {
90
+ report.preexistingMarkers = input.match(PREEXISTING_MARKER_RE)?.length ?? 0;
91
+ }
92
+ if (!FORMAT_CHAR_RE.test(input))
93
+ return applyPasses(input, passes, report, (pos) => pos).out;
94
+ const viewChars = [];
95
+ const viewToOriginal = [];
96
+ const formatAtSlot = [];
97
+ const charStartOffsets = [];
98
+ {
99
+ let orig = 0;
100
+ let viewOff = 0;
101
+ let slotBuf = "";
102
+ for (const ch of input) {
103
+ if (FORMAT_CHAR_RE.test(ch)) {
104
+ slotBuf += ch;
105
+ }
106
+ else {
107
+ formatAtSlot.push(slotBuf);
108
+ slotBuf = "";
109
+ viewToOriginal.push(orig);
110
+ charStartOffsets.push(viewOff);
111
+ viewChars.push(ch);
112
+ viewOff += ch.length;
113
+ }
114
+ orig += ch.length;
115
+ }
116
+ formatAtSlot.push(slotBuf);
117
+ viewToOriginal.push(input.length);
118
+ charStartOffsets.push(viewOff);
119
+ }
120
+ const view = viewChars.join("");
121
+ const offsetToCharIndex = new Map();
122
+ charStartOffsets.forEach((off, idx) => offsetToCharIndex.set(off, idx));
123
+ const toOriginal = (pos) => {
124
+ const idx = offsetToCharIndex.get(pos);
125
+ return idx === undefined ? input.length : viewToOriginal[idx];
126
+ };
127
+ const { out, batches } = applyPasses(view, passes, report, toOriginal);
128
+ const viewSpans = replacedSpans(batches);
129
+ if (viewSpans === undefined)
130
+ return out;
131
+ const spans = viewSpans.map((sp) => {
132
+ const startIdx = offsetToCharIndex.get(sp.start) ?? viewChars.length;
133
+ const endIdx = offsetToCharIndex.get(sp.end) ?? viewChars.length;
134
+ const lastIdx = endIdx - 1;
135
+ const endOrig = lastIdx >= startIdx && lastIdx < viewChars.length ? viewToOriginal[lastIdx] + viewChars[lastIdx].length : viewToOriginal[startIdx];
136
+ return { start: viewToOriginal[startIdx], end: endOrig, inserted: sp.inserted };
137
+ });
138
+ const viewOffsetToOriginal = (viewStart, viewEnd) => {
139
+ const startIdx = offsetToCharIndex.get(viewStart) ?? viewChars.length;
140
+ const endIdx = offsetToCharIndex.get(viewEnd) ?? viewChars.length;
141
+ const lastIdx = endIdx - 1;
142
+ const endOrig = lastIdx >= startIdx && lastIdx < viewChars.length ? viewToOriginal[lastIdx] + viewChars[lastIdx].length : viewToOriginal[startIdx];
143
+ return { start: viewToOriginal[startIdx], end: endOrig };
144
+ };
145
+ const cutOffsets = [];
146
+ for (let k = 1; k < viewChars.length; k++)
147
+ if (formatAtSlot[k] !== "")
148
+ cutOffsets.push(charStartOffsets[k]);
149
+ if (cutOffsets.length > MAX_FORMAT_SLOT_SCANS)
150
+ return REDACTED_WHOLE;
151
+ const extra = [];
152
+ const rawSpans = replacedSpans(applyPasses(input, passes, undefined, (pos) => pos).batches);
153
+ if (rawSpans !== undefined)
154
+ extra.push(...rawSpans);
155
+ for (const cut of cutOffsets) {
156
+ const cutSpans = replacedSpans(applyPasses(view.slice(cut), passes, undefined, (pos) => pos).batches);
157
+ if (cutSpans === undefined)
158
+ continue;
159
+ for (const c of cutSpans) {
160
+ const o = viewOffsetToOriginal(c.start + cut, c.end + cut);
161
+ extra.push({ start: o.start, end: o.end, inserted: c.inserted, pass: c.pass });
162
+ }
163
+ }
164
+ extra.sort((a, b) => a.start - b.start);
165
+ for (const r of extra) {
166
+ const overlapping = spans.find((v) => r.start < v.end && v.start < r.end);
167
+ if (overlapping !== undefined) {
168
+ if (r.start < overlapping.start)
169
+ overlapping.start = r.start;
170
+ if (r.end > overlapping.end)
171
+ overlapping.end = r.end;
172
+ continue;
173
+ }
174
+ spans.push({ start: r.start, end: r.end, inserted: r.inserted });
175
+ if (report !== undefined) {
176
+ const pass = passes[r.pass];
177
+ report.findings.push({ kind: pass.kind, confidence: pass.confidence, span: [r.start, r.end], marker: pass.marker, source: pass.source });
178
+ }
179
+ spans.sort((a, b) => a.start - b.start);
180
+ }
181
+ for (let i = 1; i < spans.length;) {
182
+ if (spans[i].start < spans[i - 1].end) {
183
+ spans[i - 1].end = Math.max(spans[i - 1].end, spans[i].end);
184
+ spans.splice(i, 1);
185
+ }
186
+ else
187
+ i++;
188
+ }
189
+ let result = "";
190
+ let cursor = 0;
191
+ for (const sp of spans) {
192
+ result += input.slice(cursor, sp.start) + sp.inserted;
193
+ cursor = sp.end;
194
+ }
195
+ result += input.slice(cursor);
196
+ return result;
66
197
  }
67
198
  export const SECRET_PASSES = [
68
199
  { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur])[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
@@ -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;