@sema-agent/core 7.2.0 → 7.3.1

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 (64) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/agents/cross-session-envelope.d.ts +7 -0
  3. package/dist/agents/cross-session-envelope.js +4 -0
  4. package/dist/agents/list-agents-tool.d.ts +55 -0
  5. package/dist/agents/list-agents-tool.js +94 -0
  6. package/dist/agents/peer-admission.d.ts +17 -1
  7. package/dist/agents/peer-admission.js +19 -2
  8. package/dist/agents/peer-directory.d.ts +208 -0
  9. package/dist/agents/peer-directory.js +272 -0
  10. package/dist/agents/peer-session-drain.d.ts +159 -0
  11. package/dist/agents/peer-session-drain.js +245 -0
  12. package/dist/agents/send-message-tool.d.ts +31 -0
  13. package/dist/agents/send-message-tool.js +145 -4
  14. package/dist/agents/subagent-steps.d.ts +11 -0
  15. package/dist/agents/subagent-steps.js +27 -4
  16. package/dist/brain/status-sink.d.ts +10 -0
  17. package/dist/brain/status-sink.js +13 -4
  18. package/dist/brain/stream-engine.d.ts +11 -0
  19. package/dist/brain/stream-engine.js +39 -3
  20. package/dist/core/arg-summary.d.ts +13 -3
  21. package/dist/core/arg-summary.js +138 -7
  22. package/dist/core/auto-mode-arming.d.ts +11 -0
  23. package/dist/core/auto-mode-arming.js +7 -1
  24. package/dist/core/auto-mode-prompt.d.ts +5 -0
  25. package/dist/core/auto-mode-prompt.js +2 -1
  26. package/dist/core/auto-mode-rebuild.d.ts +2 -1
  27. package/dist/core/auto-mode-rebuild.js +2 -0
  28. package/dist/core/checkpoint-store.d.ts +14 -0
  29. package/dist/core/checkpoint-store.js +4 -3
  30. package/dist/core/governance-codes.d.ts +1 -1
  31. package/dist/core/governance-codes.js +6 -0
  32. package/dist/core/mailbox-store.d.ts +89 -2
  33. package/dist/core/mailbox-store.js +77 -2
  34. package/dist/core/permission-rule-model.d.ts +9 -0
  35. package/dist/core/permission-rule-model.js +4 -1
  36. package/dist/core/runner/prepare-task.d.ts +20 -0
  37. package/dist/core/runner/prepare-task.js +152 -37
  38. package/dist/core/runner/runtask.js +5 -2
  39. package/dist/core/runner/tool-output-projection.js +1 -0
  40. package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
  41. package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
  42. package/dist/core/task-notification.d.ts +38 -9
  43. package/dist/core/task-notification.js +8 -2
  44. package/dist/core/tools.js +1 -0
  45. package/dist/core/types.d.ts +176 -26
  46. package/dist/core/wiring-manifest.d.ts +62 -5
  47. package/dist/core/wiring-manifest.js +9 -0
  48. package/dist/engine/harness/agent-harness.d.ts +1 -0
  49. package/dist/engine/harness/agent-harness.js +3 -0
  50. package/dist/engine/harness/types.d.ts +3 -0
  51. package/dist/engine/loop/agent-loop.d.ts +7 -0
  52. package/dist/engine/loop/agent-loop.js +79 -0
  53. package/dist/engine/loop/types.d.ts +42 -0
  54. package/dist/index.d.ts +12 -5
  55. package/dist/index.js +11 -4
  56. package/dist/internal/harness-types.d.ts +1 -1
  57. package/dist/stores/cc/mailbox-store.d.ts +1 -1
  58. package/dist/stores/cc/mailbox-store.js +13 -0
  59. package/dist/stores/file/adoption/marker.d.ts +1 -1
  60. package/dist/stores/file/mailbox-store.d.ts +57 -0
  61. package/dist/stores/file/mailbox-store.js +369 -18
  62. package/dist/tools/fs/fs-write.js +69 -3
  63. package/package.json +1 -1
  64. package/test/export-surface.snapshot.json +121 -1
@@ -3,6 +3,8 @@ import type { Runner } from "../core/runner/runtask.js";
3
3
  import type { TaskNotificationPayload } from "../core/task-notification.js";
4
4
  import { type ToolCtxEnricher } from "../core/tools.js";
5
5
  import { type TaskAccess } from "../core/task-registry.js";
6
+ import type { PermissionModeClass } from "./cross-session-envelope.js";
7
+ import { type PeerDirectory } from "./peer-directory.js";
6
8
  import { SubagentRetainLedger } from "./retain-ledger.js";
7
9
  import { type SubagentSteerHandle } from "./subagent.js";
8
10
  import { type PeerAdmissionConfig, type PeerInboundChainRef, type PeerSelfRef } from "./peer-admission.js";
@@ -164,6 +166,35 @@ export interface SendMessageToolOptions {
164
166
  * per-call honest refusal is the only loudness (unchanged behavior).
165
167
  */
166
168
  onTranscriptIntegrityGap?: (handle: string, scope: string | undefined) => void;
169
+ /**
170
+ * design/385 §2 — the peer-session DIRECTORY ({@link import("../core/types.js").RunnerDeps.peerDirectory}):
171
+ * opens the cross-session lane's two rungs on this face — the explicit `session.<id>` address arm
172
+ * (ranked beside the precise a* id) and the LAST name rung (`name [ref]` over the directory's full
173
+ * set, after every agent rung missed). Needs {@link mailbox} beside it AND that store's
174
+ * `crossProcessSafe: true` declaration: with the directory wired but either missing, the lane stays
175
+ * closed and every peer-addressed send refuses with the named reason (never a silent fall-through to
176
+ * "no agent matches"). Absent ⇒ byte-identical pre-385 behavior.
177
+ */
178
+ peerDirectory?: PeerDirectory;
179
+ /**
180
+ * design/385 — THIS session's own identity on the cross-session lane: its session id (the sender's
181
+ * `from-session` / reply address, and the self-target guard), the scope its directory rows live in,
182
+ * its display name (the `from-name` attestation), and the live fold of its own permission-mode class
183
+ * ({@link import("./cross-session-judge.js").foldPermissionModeClass} over the wiring manifest's
184
+ * `ask.effective`) — read at SEND time, attested on the parked record's typed `peerMeta.fromMode`.
185
+ * The Runner mount fills it; a direct mount without it sends with no session attestation (the
186
+ * recipient then judges it as "no class asserted").
187
+ */
188
+ peerSelfSession?: {
189
+ sessionId: string;
190
+ scope?: string;
191
+ name?: string;
192
+ modeClass?: () => PermissionModeClass | "unknown";
193
+ };
194
+ /** design/385 §5.1 — is `ListAgents` mounted beside this tool? Drives the "list them with ListAgents"
195
+ * phrase (a face never names a tool the roster does not carry — an `excludeTools` may drop one of the
196
+ * pair). Absent = unknown ⇒ the phrase is hedged. */
197
+ peerListingMounted?: boolean;
167
198
  }
168
199
  /** RB-382 — max chars of the `summary` arg (the short recap label, distinct from `message`'s much
169
200
  * larger {@link UPLINK_RESULT_MAX}) before ITS OWN truncation. Exported for direct unit-testing only
@@ -3,7 +3,9 @@ import { defineTool } from "../core/tools.js";
3
3
  import { normalizeAgentName, DURABLE_AGENT_HANDLE_RE, DURABLE_AGENT_HEARTBEAT_MS } from "../core/task-registry.js";
4
4
  import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord, clearRevivedRowTerminalPayload } from "../core/background-agent-store.js";
5
5
  import { uuidv7 } from "../internal/harness.js";
6
- import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
6
+ import { MAILBOX_TOMBSTONED_RECIPIENT_CODE, mailboxCrossProcessMountVerdict } from "../core/mailbox-store.js";
7
+ import { peerSendVerdictSeverity } from "./cross-session-judge.js";
8
+ import { parsePeerSessionAddress, peerSessionBoxHandle, resolvePeerSessions } from "./peer-directory.js";
7
9
  import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
8
10
  import { inlineUntrusted } from "../core/untrusted-text.js";
9
11
  import { neutralizePeerBody } from "./cross-session-envelope.js";
@@ -29,6 +31,7 @@ function frameTeammateMessage(args) {
29
31
  return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${escapeAttributeValue(args.from)}"${summaryAttr}>\n${body}\n</${TEAMMATE_MESSAGE_TAG}>`;
30
32
  }
31
33
  const REVIVE_LEASE_TTL_MS = 5 * 60_000;
34
+ const PEER_REFUSAL_TEXT_MAX = 1200;
32
35
  const sendMessageTargetLanes = new Map();
33
36
  function withTargetLane(key, fn) {
34
37
  const prev = sendMessageTargetLanes.get(key) ?? Promise.resolve();
@@ -47,6 +50,14 @@ function targetLaneKey(scope, targetId) {
47
50
  const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
48
51
  export function createSendMessageTool(opts) {
49
52
  const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
53
+ const peerLane = (() => {
54
+ if (opts.peerDirectory === undefined)
55
+ return { active: false };
56
+ if (opts.mailbox === undefined)
57
+ return { active: false, reason: "a peer-session directory is wired but no mailbox store is (the lane parks messages in the recipient's durable session box)" };
58
+ const verdict = mailboxCrossProcessMountVerdict(opts.mailbox);
59
+ return verdict.ok ? { active: true } : { active: false, reason: verdict.reason };
60
+ })();
50
61
  const retrievalMounted = opts.retrievalToolMounted;
51
62
  const retrievalHedge = retrievalMounted === undefined ? " (where that tool is mounted)" : "";
52
63
  const RETRIEVE_NOW = retrievalMounted === false
@@ -129,7 +140,10 @@ export function createSendMessageTool(opts) {
129
140
  (tier3Capable
130
141
  ? `Continuing a finished agent works for agents with a durable record — by name or task_id, even across restarts — and for runs that retain sub-agent sessions; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
131
142
  : `Continuing a finished agent requires the run to retain sub-agent sessions; when the session was not ` +
132
- `retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`),
143
+ `retained (or is no longer held) you get an honest error and should launch a new agent with the needed context instead.`) +
144
+ (peerLane.active
145
+ ? ` 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
+ : ""),
133
147
  parameters: Type.Object({
134
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.' }),
135
149
  message: Type.String({ description: "The follow-up request. The agent continues from its full prior context." }),
@@ -179,9 +193,9 @@ export function createSendMessageTool(opts) {
179
193
  return { content: OBSERVER_SENDMESSAGE_TARGET_REFUSAL, details: { error: "observer_target", to }, isError: true };
180
194
  }
181
195
  const admissionConfig = resolvePeerAdmissionConfig(opts.admission);
182
- const guardScope = ctx.principal ?? opts.scope;
196
+ const guardScope = ctx.principal ?? opts.scope ?? opts.peerSelfSession?.scope;
183
197
  const selfRef = ctx.peerSelfRef ?? opts.peerSelf;
184
- const directMountSessionId = ctx.sessionId ?? opts.sessionId;
198
+ const directMountSessionId = ctx.sessionId ?? opts.sessionId ?? opts.peerSelfSession?.sessionId;
185
199
  const directMountTaskId = ctx.taskId ?? opts.owner;
186
200
  const senderKey = selfRef?.current.key ??
187
201
  (directMountSessionId !== undefined
@@ -203,6 +217,125 @@ export function createSendMessageTool(opts) {
203
217
  : `this message's forwarding chain is too long (runaway relay) — stop relaying it; act on it or drop it.`;
204
218
  return { content: `Message not sent: ${text}`, details: { error: reason, code: reason, to }, isError: true };
205
219
  };
220
+ const peerRefusal = (code, text, extra) => {
221
+ const verdict = { ok: false, code, severity: peerSendVerdictSeverity(code), message: text };
222
+ return { content: `Message not sent: ${text.slice(0, PEER_REFUSAL_TEXT_MAX)}`, details: { error: code, code, to, verdict, ...extra }, isError: true };
223
+ };
224
+ const peerSelfSessionId = opts.peerSelfSession?.sessionId ?? directMountSessionId;
225
+ const peerScope = guardScope ?? opts.peerSelfSession?.scope;
226
+ let ownRowName;
227
+ const resolvePeerTarget = async () => {
228
+ try {
229
+ const rows = await opts.peerDirectory.listPeerSessions({ scope: peerScope ?? "" });
230
+ if (peerSelfSessionId !== undefined)
231
+ ownRowName = rows.find((r) => r.sessionId.toLowerCase() === peerSelfSessionId.toLowerCase())?.name;
232
+ return resolvePeerSessions(rows, to, peerSelfSessionId !== undefined ? { sessionId: peerSelfSessionId } : undefined);
233
+ }
234
+ catch (e) {
235
+ return { status: "directory_fault", message: e instanceof Error ? e.message : String(e) };
236
+ }
237
+ };
238
+ const deliverToPeerSession = async (c) => {
239
+ const record = c.record;
240
+ const handle = peerSessionBoxHandle(record.sessionId);
241
+ const peerLabel = `peer session "${inlineUntrusted(record.name, 64)}" (${handle})`;
242
+ if (peerScope === undefined)
243
+ return peerRefusal("peer_send.other", `this mount has no scope axis, so ${peerLabel}'s box cannot be addressed.`);
244
+ if (record.inboundPosture === "unavailable") {
245
+ return peerRefusal("peer_send.recipient_refuses", `${peerLabel} advertises that it does not accept cross-session messages (its crossSessionInbound is "refuse"). Nothing was parked.`);
246
+ }
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);
252
+ let boxFull = false;
253
+ try {
254
+ boxFull = (await opts.mailbox.peekCount(peerScope, handle)) >= admissionConfig.maxQueuedPeerMessages;
255
+ }
256
+ catch {
257
+ }
258
+ if (boxFull) {
259
+ 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
+ }
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
+ 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 });
272
+ }
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.`);
276
+ }
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
+ }
279
+ let rowRestored = "present";
280
+ try {
281
+ const rowsNow = await opts.peerDirectory.listPeerSessions({ scope: peerScope });
282
+ const nowRow = rowsNow.find((r) => r.sessionId.toLowerCase() === record.sessionId.toLowerCase());
283
+ if (nowRow?.liveness === "deleted")
284
+ rowRestored = "deleted_meanwhile";
285
+ else if (nowRow === undefined) {
286
+ rowRestored = opts.peerDirectory.restorePeerSession !== undefined && (await opts.peerDirectory.restorePeerSession(record)) ? "restored" : "unrestorable";
287
+ }
288
+ }
289
+ catch {
290
+ }
291
+ const rowNote = rowRestored === "unrestorable"
292
+ ? " Note: the peer's directory row disappeared while this message was being parked and this directory cannot restore it — the message is safe in the box (it drains by session id), but the name may not resolve until that session next registers; its session.<id> address still does."
293
+ : rowRestored === "deleted_meanwhile"
294
+ ? " 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
+ : "";
296
+ const offline = record.liveness !== "live";
297
+ const verdict = { ok: true, disposition: offline ? "parked_offline" : "queued", via: "store" };
298
+ return {
299
+ 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 } : {}) },
303
+ };
304
+ };
305
+ const settlePeerResolution = async (r) => {
306
+ switch (r.status) {
307
+ case "directory_fault":
308
+ return peerRefusal("peer_send.other", `the peer-session directory could not be read (${r.message}) — nothing was sent.`);
309
+ case "not_found":
310
+ return undefined;
311
+ case "self":
312
+ return peerRefusal("peer_send.invalid_target", `"${inlineUntrusted(to, 120)}" is this session — a session cannot send a cross-session message to itself.`);
313
+ case "deleted":
314
+ return peerRefusal("peer_send.invalid_target", `peer session "${inlineUntrusted(r.candidate.record.name, 64)}" was deleted — its address no longer accepts messages. Nothing was parked.`);
315
+ case "stale_ref":
316
+ return peerRefusal("peer_send.invalid_target", `the ref [${r.ref}] no longer resolves (the peer set changed since you listed it) — re-run ListAgents and re-send with a current ref.`);
317
+ case "ambiguous":
318
+ return { content: `Message not sent: ${inlineUntrusted(r.message, 1000)}`, details: { error: "ambiguous", to, candidates: r.candidates.map((c) => ({ name: c.record.name, ref: c.ref, address: peerSessionBoxHandle(c.record.sessionId), liveness: c.record.liveness })) }, isError: true };
319
+ case "found": {
320
+ const guard = pinGuard(peerSessionBoxHandle(r.candidate.record.sessionId), r.candidate.record.name, r.rung === "name" ? "name" : "ref", to);
321
+ if (guard !== undefined)
322
+ return guard;
323
+ return await withTargetLane(targetLaneKey(peerScope, peerSessionBoxHandle(r.candidate.record.sessionId)), () => deliverToPeerSession(r.candidate));
324
+ }
325
+ default: {
326
+ const _exhaustive = r;
327
+ void _exhaustive;
328
+ return undefined;
329
+ }
330
+ }
331
+ };
332
+ if (parsePeerSessionAddress(to) !== undefined) {
333
+ if (!peerLane.active) {
334
+ 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.`);
335
+ }
336
+ const settled = await settlePeerResolution(await resolvePeerTarget());
337
+ return settled ?? peerRefusal("peer_send.invalid_target", `no session is registered at "${inlineUntrusted(to, 120)}" — nothing was parked (a message is only ever parked for a session that exists or existed).`);
338
+ }
206
339
  if (normalizeAgentName(to) === "main") {
207
340
  if (opts.uplink && senderId !== undefined) {
208
341
  const uplinkIdentity = opts.uplinkRecipient?.current;
@@ -618,6 +751,14 @@ export function createSendMessageTool(opts) {
618
751
  const labels = parentAccess !== undefined
619
752
  ? [...ownLabels, ...opts.registry.runningBackgroundAgentLabels(parentAccess).filter((l) => !ownLabels.includes(l))]
620
753
  : ownLabels;
754
+ if (peerLane.active) {
755
+ const settled = await settlePeerResolution(await resolvePeerTarget());
756
+ if (settled !== undefined)
757
+ return settled;
758
+ }
759
+ else if (peerLane.reason !== undefined) {
760
+ return peerRefusal("peer_send.other", `no agent matches "${inlineUntrusted(to, 120)}", and the cross-session lane that could reach a peer session by that name is unavailable on this mount — ${peerLane.reason}.`);
761
+ }
621
762
  if (DURABLE_AGENT_HANDLE_RE.test(to)) {
622
763
  return {
623
764
  content: `Message not sent: no transcript found for agent ${to} — launch a new agent with the needed context instead.`,
@@ -41,6 +41,17 @@ export interface SubagentEditedFile {
41
41
  }
42
42
  /** Keep the last N tool steps (CC's tail is ~this deep; older steps are rarely load-bearing for resume). */
43
43
  export declare const STEP_CAP = 10;
44
+ /** Hard-bound a line to `max` UTF-16 units — a pure prefix, no marker appended. When the cut would split a
45
+ * redaction marker, it moves back to the marker's start (the bound holds, and the output never shows a
46
+ * marker fragment); when it would split a surrogate pair, it moves back one unit (never a lone surrogate). */
47
+ export declare function cutAt(line: string, max: number): string;
48
+ /**
49
+ * Redact, THEN bound — the order every display cut in this module (and the runner's `steering_injected`
50
+ * previews) uses. Exported so a preview minted elsewhere gets the same marker-aware cut rather than a
51
+ * raw `slice` that can leave a half marker at the bound. Keeps newlines: a multi-line body is the
52
+ * caller's shape; only the bound and the redaction are this function's.
53
+ */
54
+ export declare function redactThenCut(s: string, max: number): string;
44
55
  /** The tool's primary argument as a single short line (best-effort; unknown shapes → a compact JSON head). */
45
56
  export declare function extractTarget(args: unknown): string;
46
57
  /**
@@ -1,3 +1,4 @@
1
+ import { redactSecrets } from "../core/untrusted-egress.js";
1
2
  export const STEP_CAP = 10;
2
3
  const FIELD_MAX = 80;
3
4
  const EDITED_FILES_CAP = 32;
@@ -6,9 +7,31 @@ function firstLine(s) {
6
7
  const nl = s.indexOf("\n");
7
8
  return nl === -1 ? s : s.slice(0, nl);
8
9
  }
10
+ const REDACTION_MARKER_RE = /\[redacted(?:-[a-z]+)?\]/g;
11
+ export function cutAt(line, max) {
12
+ if (line.length <= max)
13
+ return line;
14
+ let at = max;
15
+ for (const m of line.matchAll(REDACTION_MARKER_RE)) {
16
+ const start = m.index;
17
+ const end = start + m[0].length;
18
+ if (start < at && at < end) {
19
+ at = start;
20
+ break;
21
+ }
22
+ if (start >= at)
23
+ break;
24
+ }
25
+ const lead = line.charCodeAt(at - 1);
26
+ if (at > 0 && lead >= 0xd800 && lead <= 0xdbff)
27
+ at -= 1;
28
+ return line.slice(0, at);
29
+ }
30
+ export function redactThenCut(s, max) {
31
+ return cutAt(redactSecrets(s), max);
32
+ }
9
33
  function clip(s) {
10
- const line = firstLine(s).trim();
11
- return line.length > FIELD_MAX ? line.slice(0, FIELD_MAX) : line;
34
+ return redactThenCut(firstLine(s).trim(), FIELD_MAX);
12
35
  }
13
36
  export function extractTarget(args) {
14
37
  if (args === null || typeof args !== "object")
@@ -109,7 +132,7 @@ export class SubagentStepRecorder {
109
132
  const tool = start?.tool ?? e.label ?? e.toolName;
110
133
  const target = start?.target ?? "";
111
134
  const body = outputFirstLine(e.output);
112
- const outcome = e.isError ? `error: ${body}`.slice(0, FIELD_MAX) : body;
135
+ const outcome = e.isError ? cutAt(`error: ${body}`, FIELD_MAX) : body;
113
136
  this.steps.push({ tool, target, outcome });
114
137
  if (this.steps.length > STEP_CAP)
115
138
  this.steps.shift();
@@ -156,7 +179,7 @@ export function stepsFromMessages(messages, lastN) {
156
179
  if (b.type !== "toolCall" || typeof b.name !== "string")
157
180
  continue;
158
181
  const res = typeof b.id === "string" ? results.get(b.id) : undefined;
159
- const outcome = res ? (res.isError ? `error: ${res.body}`.slice(0, FIELD_MAX) : res.body) : "";
182
+ const outcome = res ? (res.isError ? cutAt(`error: ${res.body}`, FIELD_MAX) : res.body) : "";
160
183
  steps.push({ tool: b.name, target: extractTarget(b.arguments), outcome });
161
184
  }
162
185
  }
@@ -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