privateer-agent 0.12.34 → 0.12.36

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.
@@ -36,6 +36,8 @@ import { cliPalette, detectScheme } from "../ui/palette.ts";
36
36
  import { accountPosture, privateerChannel } from "../providers/account.ts";
37
37
  import { hasCredentials } from "../auth/privateer.ts";
38
38
  import { writePiDefaultModel } from "../providers/defaultModel.ts";
39
+ import { privacyDisabled, setPrivacyDisabled } from "./privacyDisabled.ts";
40
+ import { updatePostureBadge } from "../../extensions/privateer-posture.ts";
39
41
 
40
42
  // Color-coat pi-privacy's auto-redact notice as the moat acting on your behalf: the red
41
43
  // no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
@@ -67,6 +69,7 @@ export function sharedPrivacyOptions() {
67
69
  const ambient = loadConfig();
68
70
  return {
69
71
  ...ambient,
72
+ disabled: privacyDisabled,
70
73
  // The account channel's real posture. pi-privacy ships a `privateer` provider, but it
71
74
  // is the PUBLIC developer-key channel (sk-priv-…, server-proxied and unverifiable
72
75
  // end-to-end), so from the package alone every privateer/* model floors to
@@ -131,69 +134,161 @@ export function sharedPrivacyOptions() {
131
134
  // Registered from HERE rather than from the extension file, for the reason this module
132
135
  // exists at all: the factory-built sessions and the discovered extension must not drift.
133
136
  function registerPrivacyCommand(pi: any): void {
134
- pi.registerCommand?.("privacy", {
135
- description: "Values the PII gate must not treat as personal data: /privacy [allow <value> | unallow <value>]",
136
- handler: (args: string, ctx: any) => {
137
- const raw = String(args ?? "").trim();
138
- const [verb, ...rest] = raw.split(/\s+/);
139
- const value = rest.join(" ").trim();
140
- const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
141
-
142
- if (verb === "allow" || verb === "unallow") {
143
- if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
144
- const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
145
- return notify(r.message, r.ok ? "info" : "warning");
146
- }
147
- if (verb) return notify(`unknown option "${verb}" — usage: /privacy [allow <value> | unallow <value>]`, "warning");
137
+ const handler = async (args: string, ctx: any) => {
138
+ const raw = String(args ?? "").trim();
139
+ const [verb, ...rest] = raw.split(/\s+/);
140
+ const value = rest.join(" ").trim();
141
+ const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
148
142
 
143
+ if (verb === "off" || verb === "disable") {
144
+ setPrivacyDisabled(true);
145
+ await updatePostureBadge(ctx);
146
+ return notify(
147
+ "⚑ pi-privacy is completely OFF for this session. Outbound requests will not be scanned or gated for PII, " +
148
+ "and tool exfiltration, result, and downgrade guards are disabled. Run /privacy on to restore.",
149
+ "warning",
150
+ );
151
+ }
152
+
153
+ if (verb === "on" || verb === "enable" || verb === "restore") {
154
+ setPrivacyDisabled(false);
155
+ await updatePostureBadge(ctx);
156
+ return notify(
157
+ "pi-privacy is ON for this session. PII scanning, tool exfiltration guards, and privacy checks restored.",
158
+ "info",
159
+ );
160
+ }
161
+
162
+ if (verb === "status") {
163
+ const state = privacyDisabled() ? "OFF (disabled for this session)" : "ON (active)";
149
164
  const mine = piiAllowEntries();
150
- notify(
165
+ return notify(
151
166
  [
167
+ `pi-privacy status: ${state}`,
152
168
  mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
153
- "Reserved shapes (example.com, loopback, noreply@…) are allowed by default and not listed here.",
154
- "Add one with /privacy allow <value> — an address (me@acme.com), a domain (@acme.com),",
155
- "an IPv4 block (10.0.0.0/8), or any exact/globbed value.",
169
+ "Reserved shapes (example.com, loopback, noreply@…) are allowed by default.",
170
+ "Commands: /privacy off | /privacy on | /privacy status | /privacy allow <value> | /privacy unallow <value>",
156
171
  ].join("\n"),
157
172
  "info",
158
173
  );
174
+ }
175
+
176
+ if (verb === "allow" || verb === "unallow") {
177
+ if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
178
+ const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
179
+ return notify(r.message, r.ok ? "info" : "warning");
180
+ }
181
+
182
+ if (verb && verb !== "help") {
183
+ return notify(`unknown option "${verb}" — usage: /privacy [off | on | status | allow <value> | unallow <value>]`, "warning");
184
+ }
185
+
186
+ const state = privacyDisabled() ? "OFF (disabled for this session)" : "ON (active)";
187
+ const mine = piiAllowEntries();
188
+ notify(
189
+ [
190
+ `pi-privacy status: ${state}`,
191
+ mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
192
+ "Reserved shapes (example.com, loopback, noreply@…) are allowed by default and not listed here.",
193
+ "Commands:",
194
+ " /privacy off — turn off pi-privacy completely for this session",
195
+ " /privacy on — restore pi-privacy protections",
196
+ " /privacy status — show current status and allowlist",
197
+ " /privacy allow <value> — add value to PII allowlist",
198
+ " /privacy unallow <value> — remove value from PII allowlist",
199
+ ].join("\n"),
200
+ "info",
201
+ );
202
+ };
203
+
204
+ const spec = {
205
+ description: "Manage privacy protections: /privacy [off | on | status | allow <value> | unallow <value>]",
206
+ handler,
207
+ getArgumentCompletions: (prefix: string) => {
208
+ const p = prefix.trim().toLowerCase();
209
+ const verbs = ["off", "on", "status", "allow", "unallow"];
210
+ return verbs.filter((v) => v.startsWith(p)).map((v) => ({ value: v, label: v }));
211
+ },
212
+ };
213
+
214
+ pi.registerCommand?.("privacy", spec);
215
+ pi.registerCommand?.("pi-privacy", {
216
+ ...spec,
217
+ description: "Alias for /privacy: /pi-privacy [off | on | status | allow <value> | unallow <value>]",
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Intercept pi-privacy's event registrations and commands so that when pi-privacy is completely
223
+ * disabled via `/privacy off` (or `PRIVATEER_PRIVACY_OFF=1` / `PI_PRIVACY_OFF=1`),
224
+ * all gating, prompt, and redaction hooks are cleanly bypassed.
225
+ */
226
+ function gatingPrivacyPi(pi: any): any {
227
+ const on = (event: string, handler: any) => {
228
+ if (typeof pi?.on !== "function") return;
229
+ if (typeof handler !== "function") return pi.on(event, handler);
230
+
231
+ if (
232
+ event === "before_provider_request" ||
233
+ event === "tool_call" ||
234
+ event === "user_bash" ||
235
+ event === "tool_result" ||
236
+ event === "model_select"
237
+ ) {
238
+ return pi.on(event, async (ev: any, ctx: any) => {
239
+ if (privacyDisabled()) return undefined;
240
+ return handler(ev, ctx);
241
+ });
242
+ }
243
+
244
+ return pi.on(event, handler);
245
+ };
246
+
247
+ const registerCommand = (name: string, spec: any) => {
248
+ if (typeof pi?.registerCommand !== "function") return;
249
+ if (name === "pii" && spec && typeof spec.handler === "function") {
250
+ const orig = spec.handler;
251
+ const wrappedHandler = async (args: string, ctx: any) => {
252
+ const raw = String(args ?? "").trim().toLowerCase();
253
+ const [verb] = raw.split(/\s+/);
254
+ if (verb === "off" || verb === "disable") {
255
+ setPrivacyDisabled(true);
256
+ await updatePostureBadge(ctx);
257
+ } else if (verb === "on" || verb === "enable" || verb === "restore") {
258
+ setPrivacyDisabled(false);
259
+ await updatePostureBadge(ctx);
260
+ }
261
+ return orig(args, ctx);
262
+ };
263
+ return pi.registerCommand(name, { ...spec, handler: wrappedHandler });
264
+ }
265
+ return pi.registerCommand(name, spec);
266
+ };
267
+
268
+ return new Proxy(pi, {
269
+ get(target, prop, receiver) {
270
+ if (prop === "on") return on;
271
+ if (prop === "registerCommand") return registerCommand;
272
+ const value = Reflect.get(target, prop, receiver);
273
+ return typeof value === "function" ? value.bind(target) : value;
159
274
  },
160
275
  });
161
276
  }
162
277
 
163
278
  /**
164
279
  * The privacy half of the moat as ONE factory: pi-privacy configured the Privateer way,
165
- * plus the `/privacy` command that maintains its allowlist. Both routes into pi-privacy
280
+ * plus the `/privacy` command that maintains its allowlist and toggles. Both routes into pi-privacy
166
281
  * (src/config/moat.ts and extensions/privateer-privacy.ts) use this, so neither can end
167
282
  * up with the gate but not its escape hatch.
168
283
  */
169
284
  export function privacyExtension() {
170
285
  const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
171
286
  return function privateerPrivacyCore(pi: any): void {
172
- privacy(persistingModelPicks(pi));
287
+ privacy(gatingPrivacyPi(persistingModelPicks(pi)));
173
288
  registerPrivacyCommand(pi);
174
289
  };
175
290
  }
176
291
 
177
- /**
178
- * pi-privacy's `/models` picker switched the live session and stopped there, so the
179
- * pick lasted exactly as long as the terminal and the next one launched on whatever
180
- * settings.json still said. Pi persists a switch only for a caller that passes
181
- * `{ persist: true }`, and the extension api it reaches setModel through forwards no
182
- * options at all (see savedPiDefaultSpec in providers/defaultModel.ts).
183
- *
184
- * Pi's own selector splits the two — Enter switches for the session, ctrl+s makes it
185
- * the default. OUR picker has no second key to press and never advertised a
186
- * distinction, so a pick made in it means "this is my model": we persist it.
187
- *
188
- * Wrapped here rather than fixed inside pi-privacy so the rule sits with the rest of
189
- * what Privateer configures on that extension, and so it stays scoped to the picker.
190
- * The brand extension's sign-in switch shares the same api and deliberately does NOT
191
- * get this — writePiDefaultModel says why an automatic switch must not persist.
192
- *
193
- * A Proxy, not a spread: the api object is a plain literal today, but a spread of a
194
- * class instance would silently drop every method and take pi-privacy down with it.
195
- * Forwarding leaves that failure mode impossible.
196
- */
197
292
  function persistingModelPicks(pi: any): any {
198
293
  if (typeof pi?.setModel !== "function") return pi;
199
294
  const setModel = async (model: any) => {
@@ -0,0 +1,74 @@
1
+ // How many output tokens a turn may ask for, once the prompt has taken its share.
2
+ //
3
+ // Mirrors the patched clampMaxTokensToContext in pi-ai (api/simple-options.js) —
4
+ // that copy is what actually runs; this one is where the behaviour is specified and
5
+ // tested. See patches/@earendil-works+pi-ai+0.84.4.patch.
6
+ //
7
+ // ── The bug this exists to fix ───────────────────────────────────────────────
8
+ //
9
+ // Stock pi computes the answer budget as
10
+ //
11
+ // available = contextWindow − estimateContextTokens(context) − 4096
12
+ // maxTokens = min(asked, max(1, available))
13
+ //
14
+ // and that floor is literally `1`. So the moment the estimate reaches the declared
15
+ // window, every turn asks the provider for ONE token. The request still goes out,
16
+ // still sends the whole prompt, and is still billed in full — and comes back with a
17
+ // single token and `finish_reason: "length"`, which the TUI renders as "Response was
18
+ // truncated before completion." Compaction runs once, and if the estimate is still
19
+ // over (it usually is — see below) the session is stuck there: every turn burns a
20
+ // full-price prompt to produce nothing. That is the "it won't resume" failure, and
21
+ // it is reachable on any long session.
22
+ //
23
+ // Two things conspire to reach it early. `estimateContextTokens` is a chars/4
24
+ // approximation over the whole context — system prompt and tool schemas included —
25
+ // so it runs ahead of what the provider actually counts; and Privateer registers
26
+ // every account model with a flat `contextWindow: 128000` (providers/account.ts
27
+ // seedModel) because /api/models does not publish per-model windows. A model with a
28
+ // larger real window therefore hits this ceiling while it still has room.
29
+ //
30
+ // ── The fix ──────────────────────────────────────────────────────────────────
31
+ //
32
+ // Floor the budget at a usable answer instead of at 1. The floor only ever raises
33
+ // `available`; it never raises what the caller asked for, so a deliberately small
34
+ // request (a summariser, a title) keeps its own number — the `min` still wins.
35
+ //
36
+ // It is safe against the window because the floor is smaller than the 4096-token
37
+ // safety margin already subtracted above: when `available` lands between 0 and the
38
+ // floor, the tokens we hand back were inside that margin all along.
39
+ //
40
+ // When `available` is genuinely negative the context really does not fit, and the
41
+ // honest outcome is the provider saying so — a context-length error, which pi
42
+ // classifies via isContextOverflow and answers with compaction, the path designed
43
+ // for exactly this. That is strictly better than the silent one-token stub it
44
+ // replaces: same cost, but the agent recovers instead of looking hung.
45
+ export const CONTEXT_SAFETY_TOKENS = 4096;
46
+
47
+ // pi-ai's own MIN_ANSWER_TOKENS — the floor it already reserves when a thinking
48
+ // budget shares the response ceiling (clampThinkingBudgetToAnswerRoom). Below this a
49
+ // turn cannot produce a usable answer or even a complete tool call, so asking for
50
+ // less is never worth a request.
51
+ //
52
+ // The patch REFERENCES that constant rather than declaring its own: simple-options.js
53
+ // exports it from the same module scope, so a second top-level `const` of that name
54
+ // is a SyntaxError — and since every provider imports this module, that one would not
55
+ // fail quietly, it would stop the CLI from starting. This copy exists so the value is
56
+ // stated and tested here; tests/contextBudget.test.ts pins both halves together.
57
+ export const MIN_ANSWER_TOKENS = 1024;
58
+
59
+ export interface ContextBudgetModel {
60
+ contextWindow: number;
61
+ }
62
+
63
+ export function clampMaxTokensToContext(
64
+ model: ContextBudgetModel,
65
+ estimatedContextTokens: number,
66
+ maxTokens: number,
67
+ ): number {
68
+ // An unknown window (0 or absent) means "we cannot reason about room" — pass the
69
+ // ask through rather than inventing a ceiling. Stock behaviour, kept verbatim:
70
+ // the 1 here guards a zero/negative ask, it is not the floor this patch changes.
71
+ if (!(model.contextWindow > 0)) return Math.max(1, maxTokens);
72
+ const available = model.contextWindow - estimatedContextTokens - CONTEXT_SAFETY_TOKENS;
73
+ return Math.min(maxTokens, Math.max(MIN_ANSWER_TOKENS, available));
74
+ }
@@ -37,7 +37,7 @@ const HOST_LABELS: Record<string, string> = {
37
37
  // provider's machine code (which comes out of the response body) — these mean the
38
38
  // request never got a response at all.
39
39
  const NETWORK_ERRNO =
40
- /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/;
40
+ /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|UND_ERR_BODY_TIMEOUT|UND_ERR_HEADERS_TIMEOUT)$/;
41
41
 
42
42
  // Pull structured fields off an unknown error without trusting any one shape.
43
43
  //
@@ -117,6 +117,30 @@ export function isAccountCapCode(code: string | null | undefined): boolean {
117
117
  return typeof code === "string" && CAP_CODE.test(code);
118
118
  }
119
119
 
120
+ /**
121
+ * Balance guidance for the signed-in Privateer account channel ONLY. A bare 429
122
+ * is not evidence of an empty balance; neither is a daily cap or provider quota.
123
+ * The SDK may retain the JSON body (flat or OpenAI-shaped), or just its message.
124
+ */
125
+ export function describeAccountBalanceError(text: string): DescribedError | null {
126
+ if (!/^\s*(?:402|429)\b/.test(text) || /<!doctype html|<html[\s>]/i.test(text)) return null;
127
+ const bodyStart = text.indexOf("{");
128
+ const facts = bodyStart < 0 ? {} : extract({ responseBody: text.slice(bodyStart) });
129
+ const code = facts.code;
130
+ const message = facts.providerMessage ?? text;
131
+ const balanceCode = /^(?:INSUFFICIENT_(?:BALANCE|FUNDS|CREDITS?)|(?:BALANCE|CREDITS?)_EXHAUSTED)$/i;
132
+ const balanceText = /\b(?:insufficient (?:credit(?:s| balance)?|balance|funds)|out of credits?|(?:credit balance|balance|credits?) (?:is |are )?exhausted)\b/i;
133
+ // If a machine code exists, it decides — don't turn a rate limit whose help
134
+ // text mentions credits into a billing failure.
135
+ if (code ? !balanceCode.test(code) : !balanceText.test(message)) return null;
136
+ return {
137
+ message: "Your Privateer account has insufficient balance.",
138
+ // Use our known destination, never a URL copied from an untrusted error body.
139
+ hint: "Top up at https://privateer.pro/top-up, then try again.",
140
+ retryable: false,
141
+ };
142
+ }
143
+
120
144
  // ── Oversized / non-API error bodies ─────────────────────────────────────────
121
145
  //
122
146
  // An inference endpoint does not always answer as an API. Put a WAF, a proxy or a
@@ -217,6 +241,35 @@ export function isHardHttpFailure(text: string | null | undefined): boolean {
217
241
  return status >= 400 && status < 500 && !TRANSIENT_CLIENT_STATUS.has(status);
218
242
  }
219
243
 
244
+ // ── Idle timeouts: a stalled stream, said plainly ────────────────────────────
245
+ //
246
+ // undici guards a connection that has gone quiet with `bodyTimeout` (the gap
247
+ // between response chunks) and `headersTimeout` (the wait for the first byte). Pi
248
+ // wires both from `httpIdleTimeoutMs` (default 5 min, see http-dispatcher.js). When
249
+ // either fires, undici throws BodyTimeoutError / HeadersTimeoutError with an exact,
250
+ // stable message and code — and NO HTTP status.
251
+ //
252
+ // That absence is the whole problem. `describeErrorText` only describes text that
253
+ // opens with a 3-digit status, so the bare string "Body Timeout Error" printed
254
+ // through untouched; and pi's retry regex matches the word "timeout", so the agent
255
+ // silently re-sent the entire turn across its whole retry budget — a single stalled
256
+ // stream became three 5-minute waits before the user saw any message at all. The
257
+ // transport timing out is a fact, not a guess from a body substring, so recognise
258
+ // it exactly (message OR code) and treat it as terminal: retrying re-bills the same
259
+ // request and, on a genuinely stalled provider, will stall again.
260
+ const IDLE_TIMEOUT_CODE = /^UND_ERR_(?:BODY|HEADERS)_TIMEOUT$/;
261
+ const IDLE_TIMEOUT_TEXT = /(?:^|\b)(?:Body|Headers) Timeout Error\b/i;
262
+
263
+ export function isIdleTimeoutError(text: string | null | undefined): boolean {
264
+ const s = typeof text === "string" ? text : "";
265
+ return IDLE_TIMEOUT_TEXT.test(s) || IDLE_TIMEOUT_CODE.test(s.trim());
266
+ }
267
+
268
+ const IDLE_TIMEOUT_DESCRIPTION: DescribedError = {
269
+ message: "The provider stopped responding — the connection went idle.",
270
+ hint: "No data arrived for the whole idle-timeout window, so the turn was cut off. Send it again, or run /model to switch providers — a slow model can be given longer under /settings → HTTP idle timeout.",
271
+ };
272
+
220
273
  function rawMessage(err: unknown): string {
221
274
  if (err instanceof Error) return err.message;
222
275
  if (typeof err === "string") return err;
@@ -294,6 +347,17 @@ export function describeError(err: unknown): DescribedError {
294
347
  hint: "Check the model id — run /model to switch.",
295
348
  });
296
349
  }
350
+ if (
351
+ status === 413 ||
352
+ facts.code === "PAYLOAD_TOO_LARGE" ||
353
+ facts.code === "REQUEST_TOO_LARGE" ||
354
+ /payload too large|request_too_large|request entity too large/i.test(text)
355
+ ) {
356
+ return out({
357
+ message: `Request payload too large${forProvider} (413).`,
358
+ hint: "The conversation history or attached files exceed the server limit. Start a new session (/new) or remove large attachments.",
359
+ });
360
+ }
297
361
  if (status === 429) {
298
362
  return out({
299
363
  message: `Rate limited${forProvider} (429).`,
@@ -308,6 +372,12 @@ export function describeError(err: unknown): DescribedError {
308
372
  retryable: true,
309
373
  });
310
374
  }
375
+ // A stream that stopped producing data. This carries no status, so without this
376
+ // branch it fell through to the generic "Network error" below, and pi re-sent the
377
+ // whole turn on the substring "timeout". Say what actually happened, and stop.
378
+ if (isIdleTimeoutError(text) || isIdleTimeoutError(facts.errno)) {
379
+ return out(IDLE_TIMEOUT_DESCRIPTION);
380
+ }
311
381
  if (
312
382
  facts.errno != null ||
313
383
  /fetch failed|cannot connect|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)
@@ -445,7 +515,18 @@ export function retryDelayMs(
445
515
  export function describeErrorText(text: string | null | undefined): DescribedError | null {
446
516
  const s = typeof text === "string" ? text : "";
447
517
  const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
448
- if (!Number.isFinite(status)) return null;
518
+ if (!Number.isFinite(status)) {
519
+ // No leading status: the one message we can still describe with certainty is a
520
+ // transport idle timeout, which undici names exactly. Everything else is printed
521
+ // unchanged, as before.
522
+ if (isIdleTimeoutError(s)) {
523
+ return {
524
+ message: redactText(IDLE_TIMEOUT_DESCRIPTION.message),
525
+ hint: IDLE_TIMEOUT_DESCRIPTION.hint,
526
+ };
527
+ }
528
+ return null;
529
+ }
449
530
 
450
531
  if (status === 429) {
451
532
  const stated = retryAfterMs(s);
@@ -469,6 +550,12 @@ export function describeErrorText(text: string | null | undefined): DescribedErr
469
550
  hint: "Check the model id — run /model to switch.",
470
551
  };
471
552
  }
553
+ if (status === 413 || /413\b|payload too large|request_too_large|request entity too large/i.test(s)) {
554
+ return {
555
+ message: redactText(`Request payload too large (413).`),
556
+ hint: "The conversation history or attached files exceed the server limit. Start a new session (/new) or remove large attachments.",
557
+ };
558
+ }
472
559
  if (status >= 500) {
473
560
  return {
474
561
  message: redactText(compactProviderError(s)),
@@ -18,6 +18,8 @@ export type EngineEvent =
18
18
  | { type: "text"; text: string }
19
19
  | { type: "reasoning"; text: string }
20
20
  | { type: "tool-call"; id: string; name: string; input: unknown }
21
+ // Latest output snapshot; transports bound it, without settling the running card.
22
+ | { type: "tool-progress"; id: string; name: string; output: string }
21
23
  | { type: "tool-result"; id: string; name: string; output: unknown }
22
24
  | { type: "tool-error"; id: string; name: string; error: string }
23
25
  | { type: "step-finish" }
@@ -42,7 +44,7 @@ export type EngineEvent =
42
44
  // engine — the engine has no clock and no view of the turn as a whole.
43
45
  //
44
46
  // It exists because silence is ambiguous and the app cannot resolve it: a tool
45
- // call emits at its start and its end and nothing in between, so a fifteen-minute
47
+ // call may emit at its start and its end with nothing in between, so a fifteen-minute
46
48
  // build, a stalled model socket and a dead agent all look identical from the far
47
49
  // side of the relay. This is the frame that says which. `tool`/`toolMs` name what
48
50
  // is being waited on when something is.