@rulvar/rulvar 1.21.0 → 1.23.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.
package/dist/index.d.ts CHANGED
@@ -33,7 +33,7 @@ type ProgressMode = "auto" | "tty" | "lines" | "off";
33
33
  interface ProgressOptions {
34
34
  /** Defaults to process.stderr so application stdout stays clean. */
35
35
  sink?: ProgressSink;
36
- /** Defaults to Date.now plus setInterval. */
36
+ /** Defaults to a monotonic clock (performance.now) plus setInterval. */
37
37
  clock?: ProgressClock;
38
38
  /**
39
39
  * 'auto' (default) picks 'tty' when the sink reports a TTY and the
package/dist/index.js CHANGED
@@ -1,56 +1,128 @@
1
+ import { maskSecrets, sanitizeTerminalText } from "@rulvar/core";
1
2
  import { ANTHROPIC_MODELS, anthropic } from "@rulvar/anthropic";
2
3
  import { OPENAI_MODELS, openai } from "@rulvar/openai";
3
4
  export * from "@rulvar/core";
4
5
  //#region src/render-progress.ts
6
+ /**
7
+ * Minimal terminal progress renderer (M1-T10): consumes the WorkflowEvent
8
+ * stream of a RunHandle and writes one line per lifecycle fact. Plain
9
+ * lines, no cursor control: readable in CI logs and pipes as well as TTYs.
10
+ *
11
+ * Event stream contract: https://docs.rulvar.com/guide/observability
12
+ * (the terminal progress renderer is one of the four stream consumers).
13
+ *
14
+ * Every emitted line passes through the shared terminal sanitizer before
15
+ * it reaches the sink, so an untrusted provider/tool/log string can never
16
+ * inject a control sequence or a second physical line (v1.21.0 review
17
+ * P2-1).
18
+ */
5
19
  function usd(amount) {
6
- return `${amount.toFixed(4)} USD`;
20
+ return `${(typeof amount === "number" && Number.isFinite(amount) ? amount : 0).toFixed(4)} USD`;
21
+ }
22
+ /**
23
+ * Defensive readers: the input is a raw iterable by contract, so a
24
+ * recognized event with a missing or mistyped field degrades its own
25
+ * line instead of throwing out of the render loop (v1.22.0 review
26
+ * P2-3; same discipline as the live progress reducer).
27
+ */
28
+ function str$1(value) {
29
+ return typeof value === "string" ? value : "";
30
+ }
31
+ function num$1(value) {
32
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
33
+ }
34
+ function name(event) {
35
+ const agentType = str$1(event.agentType) || "anonymous";
36
+ const label = str$1(event.label);
37
+ return `${agentType}${label === "" ? "" : ` [${label}]`}`;
7
38
  }
8
39
  /**
9
40
  * Renders events until the stream ends (the run settled). Returns after
10
41
  * the final run:end line.
11
42
  */
12
43
  async function renderProgress(events, options) {
13
- const write = options?.write ?? ((line) => {
44
+ const sink = options?.write ?? ((line) => {
14
45
  process.stderr.write(`${line}\n`);
15
46
  });
47
+ const write = (line) => sink(sanitizeTerminalText(line));
16
48
  const logs = options?.logs ?? true;
17
49
  for await (const event of events) switch (event.type) {
18
50
  case "run:start":
19
- write(`run ${event.runId} started: ${event.workflow}${event.resumed ? " (resumed)" : ""}`);
51
+ write(`run ${str$1(event.runId)} started: ${str$1(event.workflow)}${event.resumed === true ? " (resumed)" : ""}`);
20
52
  break;
21
53
  case "phase:start":
22
- write(`phase: ${event.phase}`);
54
+ write(`phase: ${str$1(event.phase)}`);
23
55
  break;
24
56
  case "agent:queued":
25
- write(`agent ${event.agentType || "anonymous"}${event.label ? ` [${event.label}]` : ""} queued`);
57
+ write(`agent ${name(event)} queued`);
26
58
  break;
27
59
  case "agent:start":
28
- write(`agent ${event.agentType || "anonymous"}${event.label ? ` [${event.label}]` : ""} -> ${event.model} (${event.role})`);
60
+ write(`agent ${name(event)} -> ${str$1(event.model)} (${str$1(event.role)})`);
29
61
  break;
30
62
  case "agent:end":
31
- write(`agent ${event.agentType || "anonymous"}${event.label ? ` [${event.label}]` : ""} ${event.status} (${usd(event.costUsd)}, ${event.usage.outputTokens} out tokens)`);
63
+ write(`agent ${name(event)} ${str$1(event.status)} (${usd(event.costUsd)}, ${String(num$1(event.usage?.outputTokens))} out tokens)`);
32
64
  break;
33
65
  case "agent:error":
34
- write(`agent ${event.agentType || "anonymous"} error: ${event.error.message}${event.willRetry ? " (will retry)" : ""}`);
66
+ write(`agent ${str$1(event.agentType) || "anonymous"} error: ${str$1(event.error?.message)}${event.willRetry === true ? " (will retry)" : ""}`);
35
67
  break;
36
68
  case "agent:schema-retry":
37
- write(`agent ${event.agentType || "anonymous"} schema retry ${event.attempt}/${event.maxAttempts}`);
69
+ write(`agent ${str$1(event.agentType) || "anonymous"} schema retry ${String(num$1(event.attempt))}/${String(num$1(event.maxAttempts))}`);
38
70
  break;
39
71
  case "budget:update":
40
- write(`budget: spent ${usd(event.spentUsd)}` + (event.remainingUsd === null ? "" : `, remaining ${usd(event.remainingUsd)}`));
72
+ write(`budget: spent ${usd(event.spentUsd)}` + (typeof event.remainingUsd === "number" ? `, remaining ${usd(event.remainingUsd)}` : ""));
41
73
  break;
42
74
  case "log":
43
- if (logs && event.level !== "debug") write(`[${event.level}] ${event.msg}`);
75
+ if (logs && event.level !== "debug") write(`[${str$1(event.level)}] ${str$1(event.msg)}`);
44
76
  break;
45
77
  case "run:end":
46
- write(`run finished: ${event.status} (total ${usd(event.totalUsd)})`);
78
+ write(`run finished: ${str$1(event.status)} (total ${usd(event.totalUsd)})`);
47
79
  break;
48
80
  default: break;
49
81
  }
50
82
  }
51
83
  //#endregion
52
84
  //#region src/live-progress.ts
85
+ /**
86
+ * Live terminal progress view (v1.21.0): a claude-workflows-style tree
87
+ * over the WorkflowEvent stream, one row per agent with a status glyph,
88
+ * a running timer, token counts, and USD, plus per-role sub-timings when
89
+ * one agent call spans several invocation phases (loop, summarize,
90
+ * finalize, extract). The minimal line-per-event `renderProgress` stays
91
+ * untouched next door; this renderer is the rich, cursor-addressed
92
+ * sibling with an append-only fallback for pipes and CI.
93
+ *
94
+ * Honesty rules inherited from the event contract
95
+ * (https://docs.rulvar.com/guide/observability): exact token counts
96
+ * exist only at `agent:end`, so running rows show elapsed time and a
97
+ * tilde-marked character estimate from `agent:stream` deltas; run-level
98
+ * USD is live through `budget:update`; per-role dollars appear in the
99
+ * final summary only when the source is a RunHandle (they come from
100
+ * `RunOutcome.cost.byRole`, not from any event). Replayed lifecycle
101
+ * events render dim with a `replay` tag, never spin, and never add to
102
+ * totals: the authoritative money numbers are `budget:update.spentUsd`
103
+ * and `run:end.totalUsd`, which are immune to replay double counting.
104
+ *
105
+ * The reducer is defensive by contract: unknown event types are ignored
106
+ * and every dynamic field, including the required ones, is read
107
+ * defensively (optional chaining with fallbacks), so a malformed event
108
+ * degrades a row rather than throwing and stopping the view; an unknown
109
+ * parent span attaches at the root, and a mid-run attach synthesizes a
110
+ * root instead of failing.
111
+ */
112
+ /**
113
+ * Positive-integer option normalization (v1.21.0 review P3-2): a
114
+ * non-finite caller value falls back to the default and a below-minimum
115
+ * value CLAMPS to the minimum (wording fixed in the v1.22.0 review
116
+ * P4-2; the behavior always clamped), so no caller value can poison the
117
+ * geometry (a NaN width breaks the clip, a NaN fps yields a NaN
118
+ * interval). Fractions floor.
119
+ */
120
+ function posIntOption(value, fallback, min) {
121
+ if (value === void 0 || !Number.isFinite(value)) return fallback;
122
+ return Math.max(min, Math.floor(value));
123
+ }
53
124
  function fmtDuration(ms) {
125
+ if (!Number.isFinite(ms) || ms < 0) ms = 0;
54
126
  const s = ms / 1e3;
55
127
  if (s < 10) return `${s.toFixed(1)}s`;
56
128
  if (s < 60) return `${String(Math.floor(s))}s`;
@@ -82,6 +154,7 @@ const SPINNER = [
82
154
  "-",
83
155
  "\\"
84
156
  ];
157
+ const SGR_STRIP = /* @__PURE__ */ new RegExp("\\u001B\\[[0-9;]*m", "gu");
85
158
  function newState(title) {
86
159
  return {
87
160
  ...title === void 0 ? {} : { title },
@@ -120,18 +193,53 @@ function nodeOf(state, event, kind, title) {
120
193
  return node;
121
194
  }
122
195
  /**
123
- * Untrusted wire strings (model ids, tool names, error messages) may
124
- * carry control characters; a raw newline or escape sequence in a frame
125
- * would break the repaint arithmetic or leak terminal control. One
126
- * space per control run, SGR added only by paint() afterwards.
196
+ * Untrusted wire strings (model ids, tool names, error messages, log
197
+ * text, workflow and label metadata) may carry control characters and
198
+ * escape sequences that break the repaint arithmetic or leak terminal
199
+ * control. The shared core sanitizer strips C0, DEL, C1, and whole
200
+ * ESC-initiated CSI/OSC/DCS sequences before interpolation; the
201
+ * renderer's own SGR is added by paint() afterward (v1.21.0 review
202
+ * P2-1).
127
203
  */
128
- const CONTROL_CHARS = /[\u0000-\u001f\u007f]+/gu;
129
- function scrub(text) {
130
- return text.replace(CONTROL_CHARS, " ");
204
+ const scrub = sanitizeTerminalText;
205
+ /**
206
+ * Defensive readers for recognized event types arriving from a RAW
207
+ * iterable: the engine's own bus always satisfies the types, but the
208
+ * documented raw-iterable source can hand the reducer any shape, and a
209
+ * missing or mistyped field must degrade the one row, never stop the
210
+ * view or reach a template literal as an object whose coercion could
211
+ * throw (v1.22.0 review P2-3).
212
+ */
213
+ function str(value) {
214
+ return typeof value === "string" ? value : "";
215
+ }
216
+ function num(value) {
217
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
218
+ }
219
+ function numOpt(value) {
220
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
221
+ }
222
+ /**
223
+ * One safe form for every error a catch path surfaces: the message is
224
+ * secret-masked FIRST (the thrown value never went through the event
225
+ * masking boundary) and terminal-sanitized second, so a rejected
226
+ * source can neither leak a key-shaped fragment nor inject control
227
+ * sequences or forged lines into the sink (v1.22.0 review P2-2).
228
+ */
229
+ function errorNotice(thrown) {
230
+ let message;
231
+ try {
232
+ message = thrown instanceof Error ? thrown.message : String(thrown);
233
+ } catch {
234
+ message = "unprintable thrown value";
235
+ }
236
+ return scrub(maskSecrets(`error: ${message}`));
131
237
  }
132
238
  function agentTitle(event) {
133
- const base = event.agentType === void 0 || event.agentType === "" ? "agent" : scrub(event.agentType);
134
- return event.label === void 0 || event.label === "" ? base : `${base} (${scrub(event.label)})`;
239
+ const agentType = str(event.agentType);
240
+ const label = str(event.label);
241
+ const base = agentType === "" ? "agent" : scrub(agentType);
242
+ return label === "" ? base : `${base} (${scrub(label)})`;
135
243
  }
136
244
  /** The tool event's span is the agent's own span or a child of it. */
137
245
  function toolTarget(state, event) {
@@ -144,11 +252,11 @@ function applyEvent(state, event, now) {
144
252
  state.runId = event.runId;
145
253
  state.resumed = event.resumed === true;
146
254
  state.startedAt ??= now;
147
- state.title ??= scrub(event.workflow);
255
+ state.title ??= scrub(str(event.workflow));
148
256
  break;
149
257
  case "run:end":
150
- state.status = event.status;
151
- state.totalUsd = event.totalUsd;
258
+ state.status = str(event.status) || "interrupted";
259
+ state.totalUsd = numOpt(event.totalUsd);
152
260
  state.endedAt = now;
153
261
  for (const node of state.nodes.values()) {
154
262
  if (node.endedAt === void 0 && node.kind !== "phase") {
@@ -160,36 +268,36 @@ function applyEvent(state, event, now) {
160
268
  }
161
269
  break;
162
270
  case "phase:start": {
163
- const node = nodeOf(state, event, "phase", scrub(event.phase));
271
+ const node = nodeOf(state, event, "phase", scrub(str(event.phase)));
164
272
  node.startedAt = now;
165
273
  node.status = "ok";
166
274
  break;
167
275
  }
168
276
  case "budget:update":
169
- state.spentUsd = event.spentUsd;
170
- state.ceilingUsd = event.remainingUsd === null ? void 0 : event.spentUsd + event.remainingUsd;
277
+ state.spentUsd = num(event.spentUsd);
278
+ state.ceilingUsd = typeof event.remainingUsd === "number" && Number.isFinite(event.remainingUsd) ? num(event.spentUsd) + event.remainingUsd : void 0;
171
279
  break;
172
280
  case "log":
173
281
  if (event.level === "warn" || event.level === "error") {
174
- state.notices.push(scrub(`${event.level}: ${event.msg}`));
282
+ state.notices.push(scrub(`${str(event.level)}: ${str(event.msg)}`));
175
283
  if (state.notices.length > 2) state.notices.shift();
176
284
  }
177
285
  break;
178
286
  case "external:waiting":
179
- state.banner = scrub(`waiting on external: ${event.key}`);
287
+ state.banner = scrub(`waiting on external: ${str(event.key)}`);
180
288
  break;
181
289
  case "approval:pending":
182
- state.banner = scrub(`approval pending: ${event.toolName}`);
290
+ state.banner = scrub(`approval pending: ${str(event.toolName)}`);
183
291
  break;
184
292
  case "child:start": {
185
- const node = nodeOf(state, event, "child", scrub(`${event.workflow} (${event.scope})`));
293
+ const node = nodeOf(state, event, "child", scrub(`${str(event.workflow)} (${str(event.scope)})`));
186
294
  node.startedAt = now;
187
295
  break;
188
296
  }
189
297
  case "child:end": {
190
298
  const node = state.nodes.get(event.spanId);
191
299
  if (node !== void 0) {
192
- node.status = event.status;
300
+ node.status = str(event.status) || "interrupted";
193
301
  node.endedAt = now;
194
302
  }
195
303
  break;
@@ -205,41 +313,41 @@ function applyEvent(state, event, now) {
205
313
  const node = nodeOf(state, event, "agent", agentTitle(event));
206
314
  if (node.status === "queued" || node.roles.length === 0) node.startedAt = now;
207
315
  node.status = "running";
208
- node.model = scrub(event.model);
316
+ node.model = scrub(str(event.model));
209
317
  if (event.replayed === true) node.replayed = true;
210
318
  const open = node.roles.at(-1);
211
319
  if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
212
320
  node.roles.push({
213
- role: event.role,
321
+ role: str(event.role),
214
322
  startedAt: now
215
323
  });
216
324
  break;
217
325
  }
218
326
  case "agent:stream": {
219
327
  const node = state.nodes.get(event.spanId);
220
- if (node !== void 0) node.streamedChars += event.delta.length;
328
+ if (node !== void 0) node.streamedChars += str(event.delta).length;
221
329
  break;
222
330
  }
223
331
  case "agent:error": {
224
332
  const node = state.nodes.get(event.spanId);
225
- if (node !== void 0) node.badge = event.willRetry ? "retry" : scrub(`error: ${event.error.message}`);
333
+ if (node !== void 0) node.badge = event.willRetry ? "retry" : scrub(`error: ${str(event.error?.message)}`);
226
334
  break;
227
335
  }
228
336
  case "agent:schema-retry": {
229
337
  const node = state.nodes.get(event.spanId);
230
- if (node !== void 0) node.badge = `schema ${String(event.attempt)}/${String(event.maxAttempts)}`;
338
+ if (node !== void 0) node.badge = `schema ${String(num(event.attempt))}/${String(num(event.maxAttempts))}`;
231
339
  break;
232
340
  }
233
341
  case "agent:end": {
234
342
  state.banner = void 0;
235
343
  const node = nodeOf(state, event, "agent", agentTitle(event));
236
- node.status = event.status;
344
+ node.status = str(event.status) || "interrupted";
237
345
  node.endedAt = now;
238
346
  node.usage = {
239
- input: event.usage.inputTokens,
240
- output: event.usage.outputTokens
347
+ input: num(event.usage?.inputTokens),
348
+ output: num(event.usage?.outputTokens)
241
349
  };
242
- node.costUsd = event.costUsd;
350
+ node.costUsd = numOpt(event.costUsd);
243
351
  if (event.replayed === true) node.replayed = true;
244
352
  if (node.status === "ok") node.badge = void 0;
245
353
  const open = node.roles.at(-1);
@@ -248,7 +356,7 @@ function applyEvent(state, event, now) {
248
356
  }
249
357
  case "tool:start": {
250
358
  const node = toolTarget(state, event);
251
- if (node !== void 0) node.toolActive = scrub(event.toolName);
359
+ if (node !== void 0) node.toolActive = scrub(str(event.toolName));
252
360
  break;
253
361
  }
254
362
  case "tool:end": {
@@ -256,7 +364,7 @@ function applyEvent(state, event, now) {
256
364
  if (node !== void 0) {
257
365
  node.toolActive = void 0;
258
366
  node.toolCount += 1;
259
- if (event.outcome === "denied") node.badge = scrub(`tool ${event.toolName} denied`);
367
+ if (event.outcome === "denied") node.badge = scrub(`tool ${str(event.toolName)} denied`);
260
368
  }
261
369
  break;
262
370
  }
@@ -268,7 +376,7 @@ function applyEvent(state, event, now) {
268
376
  break;
269
377
  case "spawn:rejected":
270
378
  state.rejected += 1;
271
- state.notices.push(`spawn rejected: ${event.code}`);
379
+ state.notices.push(scrub(`spawn rejected: ${str(event.code)}`));
272
380
  if (state.notices.length > 2) state.notices.shift();
273
381
  break;
274
382
  default: break;
@@ -285,7 +393,7 @@ const GLYPHS = {
285
393
  escalated: "!"
286
394
  };
287
395
  function paint(text, code, style) {
288
- return style.color ? `[${code}m${text}` : text;
396
+ return style.color ? `\u001B[${code}m${text}\u001B[0m` : text;
289
397
  }
290
398
  function nodeRow(node, now, spinner, style) {
291
399
  const running = node.endedAt === void 0 && node.status === "running";
@@ -375,10 +483,11 @@ function composeFrame(state, now, tick, style, width, maxRows, maxHeight) {
375
483
  ...lines.slice(lines.length - keepTail)
376
484
  ];
377
485
  }
486
+ const limit = Math.max(0, width - 1);
378
487
  return clamped.map((line) => {
379
- const plain = line.replace(/\[[0-9;]*m/gu, "");
380
- if (plain.length <= width - 1) return line;
381
- return plain.slice(0, Math.max(0, width - 4)) + "...";
488
+ const plain = line.replace(SGR_STRIP, "");
489
+ if (plain.length <= limit) return line;
490
+ return limit >= 4 ? plain.slice(0, limit - 3) + "..." : plain.slice(0, limit);
382
491
  });
383
492
  }
384
493
  function defaultSink() {
@@ -440,10 +549,12 @@ function progress(source, options) {
440
549
  const clock = options?.clock ?? defaultClock();
441
550
  const mode = resolveMode(options?.mode, sink);
442
551
  const style = { color: options?.color ?? (mode === "tty" && process.env.NO_COLOR === void 0) };
443
- const width = options?.width ?? sink.columns ?? 80;
444
- const maxRows = options?.maxRows ?? Math.max(6, Math.min(24, (sink.rows ?? 32) - 8));
445
- const fps = Math.min(30, Math.max(1, options?.fps ?? 10));
446
- const state = newState(options?.title);
552
+ const columns = posIntOption(sink.columns, 80, 1);
553
+ const rows = posIntOption(sink.rows, 32, 3);
554
+ const width = posIntOption(options?.width, columns, 1);
555
+ const maxRows = posIntOption(options?.maxRows, Math.max(6, Math.min(24, rows - 8)), 1);
556
+ const fps = Math.min(30, posIntOption(options?.fps, 10, 1));
557
+ const state = newState(options?.title === void 0 ? void 0 : scrub(options.title));
447
558
  let settled = false;
448
559
  let resolveDone = () => void 0;
449
560
  const done = new Promise((resolve) => {
@@ -464,46 +575,60 @@ function progress(source, options) {
464
575
  let paintedLines = 0;
465
576
  let lastLinesBudgetAt;
466
577
  const paintFrame = () => {
467
- const maxHeight = sink.rows === void 0 ? void 0 : Math.max(3, sink.rows - 1);
578
+ const rawRows = sink.rows;
579
+ const maxHeight = typeof rawRows === "number" && Number.isFinite(rawRows) ? Math.max(3, Math.floor(rawRows) - 1) : void 0;
468
580
  const frame = composeFrame(state, clock.now(), tick, style, width, maxRows, maxHeight);
469
- const erase = paintedLines > 0 ? `[${String(paintedLines)}A` : "";
581
+ const erase = paintedLines > 0 ? `\u001B[${String(paintedLines)}A\u001B[0J` : "";
470
582
  sink.write(erase + frame.join("\n") + "\n");
471
583
  paintedLines = frame.length;
472
584
  state.dirty = false;
473
585
  };
474
586
  const lineFor = (event, now) => {
475
587
  switch (event.type) {
476
- case "run:start": return `run ${event.runId} started: ${event.workflow}${event.resumed ? " (resumed)" : ""}`;
477
- case "phase:start": return `phase: ${event.phase}`;
588
+ case "run:start": return `run ${str(event.runId)} started: ${str(event.workflow)}${event.resumed === true ? " (resumed)" : ""}`;
589
+ case "phase:start": return `phase: ${str(event.phase)}`;
478
590
  case "agent:start": {
479
591
  const node = state.nodes.get(event.spanId);
480
592
  const inner = node !== void 0 && node.roles.length > 1;
481
- return `agent ${agentTitle(event)} -> ${event.model} (${event.role})${inner ? " [inner phase]" : ""}`;
593
+ return `agent ${agentTitle(event)} -> ${str(event.model)} (${str(event.role)})${inner ? " [inner phase]" : ""}`;
482
594
  }
483
595
  case "agent:end": {
484
596
  const node = state.nodes.get(event.spanId);
485
597
  const elapsed = node === void 0 || node.replayed || node.startedAt <= 0 ? "" : ` in ${fmtDuration((node.endedAt ?? now) - node.startedAt)}`;
486
598
  const roles = node !== void 0 && node.roles.length > 1 ? ` [${node.roles.map((slice) => slice.role).join(" > ")}]` : "";
487
- return `agent ${agentTitle(event)} ${event.status}${elapsed}: in ${fmtTokens(event.usage.inputTokens)} out ${fmtTokens(event.usage.outputTokens)}, ${fmtUsd(event.costUsd)}${roles}${event.replayed === true ? " (replay)" : ""}`;
599
+ return `agent ${agentTitle(event)} ${str(event.status)}${elapsed}: in ${fmtTokens(num(event.usage?.inputTokens))} out ${fmtTokens(num(event.usage?.outputTokens))}, ${fmtUsd(num(event.costUsd))}${roles}${event.replayed === true ? " (replay)" : ""}`;
488
600
  }
489
- case "agent:error": return `agent ${agentTitle(event)} error: ${event.error.message}` + (event.willRetry ? " (will retry)" : "");
601
+ case "agent:error": return `agent ${agentTitle(event)} error: ${str(event.error?.message)}` + (event.willRetry ? " (will retry)" : "");
490
602
  case "budget:update":
491
603
  if (lastLinesBudgetAt !== void 0 && now - lastLinesBudgetAt < 1e3) return;
492
604
  lastLinesBudgetAt = now;
493
- return `budget: ${fmtUsd(event.spentUsd)}` + (event.remainingUsd === null ? "" : ` of ${fmtUsd(event.spentUsd + event.remainingUsd)}`);
494
- case "log": return event.level === "warn" || event.level === "error" ? `[${event.level}] ${event.msg}` : void 0;
495
- case "external:waiting": return `waiting on external: ${event.key}`;
496
- case "approval:pending": return `approval pending: ${event.toolName}`;
497
- case "run:end": return `run finished: ${event.status} (total ${fmtUsd(event.totalUsd)})`;
605
+ return `budget: ${fmtUsd(num(event.spentUsd))}` + (typeof event.remainingUsd === "number" && Number.isFinite(event.remainingUsd) ? ` of ${fmtUsd(num(event.spentUsd) + event.remainingUsd)}` : "");
606
+ case "log": return event.level === "warn" || event.level === "error" ? `[${str(event.level)}] ${str(event.msg)}` : void 0;
607
+ case "external:waiting": return `waiting on external: ${str(event.key)}`;
608
+ case "approval:pending": return `approval pending: ${str(event.toolName)}`;
609
+ case "run:end": return `run finished: ${str(event.status)} (total ${fmtUsd(num(event.totalUsd))})`;
498
610
  default: return;
499
611
  }
500
612
  };
613
+ const pushNotice = (notice) => {
614
+ state.notices.push(notice);
615
+ if (state.notices.length > 2) state.notices.shift();
616
+ if (mode === "lines") sink.write(scrub(notice) + "\n");
617
+ };
618
+ let malformedNoticed = false;
501
619
  const onEvent = (event) => {
502
620
  const now = clock.now();
503
- applyEvent(state, event, now);
504
- if (mode === "lines") {
505
- const line = lineFor(event, now);
506
- if (line !== void 0) sink.write(line + "\n");
621
+ try {
622
+ applyEvent(state, event, now);
623
+ if (mode === "lines") {
624
+ const line = lineFor(event, now);
625
+ if (line !== void 0) sink.write(scrub(line) + "\n");
626
+ }
627
+ } catch {
628
+ if (!malformedNoticed) {
629
+ malformedNoticed = true;
630
+ pushNotice("progress: skipped malformed event(s); view may be incomplete");
631
+ }
507
632
  }
508
633
  };
509
634
  const finishLines = () => {
@@ -557,7 +682,7 @@ function progress(source, options) {
557
682
  state.totalUsd = outcome.cost.totalUsd;
558
683
  state.endedAt ??= clock.now();
559
684
  }).catch((thrown) => {
560
- state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
685
+ pushNotice(errorNotice(thrown));
561
686
  state.endedAt ??= clock.now();
562
687
  }).finally(() => {
563
688
  handleApi.stop(true);
@@ -567,7 +692,7 @@ function progress(source, options) {
567
692
  else if (typeof source.then === "function") source.then((handle) => {
568
693
  attachHandle(handle);
569
694
  }).catch((thrown) => {
570
- state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
695
+ pushNotice(errorNotice(thrown));
571
696
  state.endedAt ??= clock.now();
572
697
  handleApi.stop(true);
573
698
  });
@@ -578,7 +703,7 @@ function progress(source, options) {
578
703
  onEvent(event);
579
704
  }
580
705
  } catch (thrown) {
581
- state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
706
+ pushNotice(errorNotice(thrown));
582
707
  } finally {
583
708
  state.endedAt ??= clock.now();
584
709
  handleApi.stop(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/rulvar",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "Rulvar umbrella package: re-exports @rulvar/core, both first-class adapters, the file store, and the terminal progress renderer. Also installable through the unscoped alias package rulvar, which re-exports this one.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,16 +22,16 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@rulvar/core": "1.21.0",
26
- "@rulvar/anthropic": "1.21.0",
27
- "@rulvar/openai": "1.21.0"
25
+ "@rulvar/core": "1.23.0",
26
+ "@rulvar/anthropic": "1.23.0",
27
+ "@rulvar/openai": "1.23.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^22.20.0",
31
31
  "tsdown": "^0.22.3",
32
32
  "typescript": "~6.0.3",
33
33
  "zod": "^4.4.3",
34
- "@rulvar/testing": "1.21.0"
34
+ "@rulvar/testing": "1.23.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",