@rulvar/rulvar 1.22.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.
Files changed (2) hide show
  1. package/dist/index.js +126 -59
  2. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { sanitizeTerminalText } from "@rulvar/core";
1
+ import { maskSecrets, sanitizeTerminalText } from "@rulvar/core";
2
2
  import { ANTHROPIC_MODELS, anthropic } from "@rulvar/anthropic";
3
3
  import { OPENAI_MODELS, openai } from "@rulvar/openai";
4
4
  export * from "@rulvar/core";
@@ -17,7 +17,24 @@ export * from "@rulvar/core";
17
17
  * P2-1).
18
18
  */
19
19
  function usd(amount) {
20
- 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}]`}`;
21
38
  }
22
39
  /**
23
40
  * Renders events until the stream ends (the run settled). Returns after
@@ -31,34 +48,34 @@ async function renderProgress(events, options) {
31
48
  const logs = options?.logs ?? true;
32
49
  for await (const event of events) switch (event.type) {
33
50
  case "run:start":
34
- 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)" : ""}`);
35
52
  break;
36
53
  case "phase:start":
37
- write(`phase: ${event.phase}`);
54
+ write(`phase: ${str$1(event.phase)}`);
38
55
  break;
39
56
  case "agent:queued":
40
- write(`agent ${event.agentType || "anonymous"}${event.label ? ` [${event.label}]` : ""} queued`);
57
+ write(`agent ${name(event)} queued`);
41
58
  break;
42
59
  case "agent:start":
43
- 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)})`);
44
61
  break;
45
62
  case "agent:end":
46
- 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)`);
47
64
  break;
48
65
  case "agent:error":
49
- 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)" : ""}`);
50
67
  break;
51
68
  case "agent:schema-retry":
52
- 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))}`);
53
70
  break;
54
71
  case "budget:update":
55
- 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)}` : ""));
56
73
  break;
57
74
  case "log":
58
- 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)}`);
59
76
  break;
60
77
  case "run:end":
61
- write(`run finished: ${event.status} (total ${usd(event.totalUsd)})`);
78
+ write(`run finished: ${str$1(event.status)} (total ${usd(event.totalUsd)})`);
62
79
  break;
63
80
  default: break;
64
81
  }
@@ -94,9 +111,11 @@ async function renderProgress(events, options) {
94
111
  */
95
112
  /**
96
113
  * Positive-integer option normalization (v1.21.0 review P3-2): a
97
- * non-finite or below-minimum caller value falls back rather than
98
- * poisoning the geometry (a NaN width breaks the clip, a NaN fps yields
99
- * a NaN interval). Fractions floor.
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.
100
119
  */
101
120
  function posIntOption(value, fallback, min) {
102
121
  if (value === void 0 || !Number.isFinite(value)) return fallback;
@@ -183,9 +202,44 @@ function nodeOf(state, event, kind, title) {
183
202
  * P2-1).
184
203
  */
185
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}`));
237
+ }
186
238
  function agentTitle(event) {
187
- const base = event.agentType === void 0 || event.agentType === "" ? "agent" : scrub(event.agentType);
188
- 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)})`;
189
243
  }
190
244
  /** The tool event's span is the agent's own span or a child of it. */
191
245
  function toolTarget(state, event) {
@@ -198,11 +252,11 @@ function applyEvent(state, event, now) {
198
252
  state.runId = event.runId;
199
253
  state.resumed = event.resumed === true;
200
254
  state.startedAt ??= now;
201
- state.title ??= scrub(event.workflow);
255
+ state.title ??= scrub(str(event.workflow));
202
256
  break;
203
257
  case "run:end":
204
- state.status = event.status;
205
- state.totalUsd = event.totalUsd;
258
+ state.status = str(event.status) || "interrupted";
259
+ state.totalUsd = numOpt(event.totalUsd);
206
260
  state.endedAt = now;
207
261
  for (const node of state.nodes.values()) {
208
262
  if (node.endedAt === void 0 && node.kind !== "phase") {
@@ -214,36 +268,36 @@ function applyEvent(state, event, now) {
214
268
  }
215
269
  break;
216
270
  case "phase:start": {
217
- const node = nodeOf(state, event, "phase", scrub(event.phase));
271
+ const node = nodeOf(state, event, "phase", scrub(str(event.phase)));
218
272
  node.startedAt = now;
219
273
  node.status = "ok";
220
274
  break;
221
275
  }
222
276
  case "budget:update":
223
- state.spentUsd = event.spentUsd;
224
- 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;
225
279
  break;
226
280
  case "log":
227
281
  if (event.level === "warn" || event.level === "error") {
228
- state.notices.push(scrub(`${event.level}: ${event.msg}`));
282
+ state.notices.push(scrub(`${str(event.level)}: ${str(event.msg)}`));
229
283
  if (state.notices.length > 2) state.notices.shift();
230
284
  }
231
285
  break;
232
286
  case "external:waiting":
233
- state.banner = scrub(`waiting on external: ${event.key}`);
287
+ state.banner = scrub(`waiting on external: ${str(event.key)}`);
234
288
  break;
235
289
  case "approval:pending":
236
- state.banner = scrub(`approval pending: ${event.toolName}`);
290
+ state.banner = scrub(`approval pending: ${str(event.toolName)}`);
237
291
  break;
238
292
  case "child:start": {
239
- 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)})`));
240
294
  node.startedAt = now;
241
295
  break;
242
296
  }
243
297
  case "child:end": {
244
298
  const node = state.nodes.get(event.spanId);
245
299
  if (node !== void 0) {
246
- node.status = event.status;
300
+ node.status = str(event.status) || "interrupted";
247
301
  node.endedAt = now;
248
302
  }
249
303
  break;
@@ -259,41 +313,41 @@ function applyEvent(state, event, now) {
259
313
  const node = nodeOf(state, event, "agent", agentTitle(event));
260
314
  if (node.status === "queued" || node.roles.length === 0) node.startedAt = now;
261
315
  node.status = "running";
262
- node.model = scrub(event.model);
316
+ node.model = scrub(str(event.model));
263
317
  if (event.replayed === true) node.replayed = true;
264
318
  const open = node.roles.at(-1);
265
319
  if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
266
320
  node.roles.push({
267
- role: event.role,
321
+ role: str(event.role),
268
322
  startedAt: now
269
323
  });
270
324
  break;
271
325
  }
272
326
  case "agent:stream": {
273
327
  const node = state.nodes.get(event.spanId);
274
- if (node !== void 0) node.streamedChars += event.delta.length;
328
+ if (node !== void 0) node.streamedChars += str(event.delta).length;
275
329
  break;
276
330
  }
277
331
  case "agent:error": {
278
332
  const node = state.nodes.get(event.spanId);
279
- 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)}`);
280
334
  break;
281
335
  }
282
336
  case "agent:schema-retry": {
283
337
  const node = state.nodes.get(event.spanId);
284
- 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))}`;
285
339
  break;
286
340
  }
287
341
  case "agent:end": {
288
342
  state.banner = void 0;
289
343
  const node = nodeOf(state, event, "agent", agentTitle(event));
290
- node.status = event.status;
344
+ node.status = str(event.status) || "interrupted";
291
345
  node.endedAt = now;
292
346
  node.usage = {
293
- input: event.usage?.inputTokens ?? 0,
294
- output: event.usage?.outputTokens ?? 0
347
+ input: num(event.usage?.inputTokens),
348
+ output: num(event.usage?.outputTokens)
295
349
  };
296
- node.costUsd = event.costUsd;
350
+ node.costUsd = numOpt(event.costUsd);
297
351
  if (event.replayed === true) node.replayed = true;
298
352
  if (node.status === "ok") node.badge = void 0;
299
353
  const open = node.roles.at(-1);
@@ -302,7 +356,7 @@ function applyEvent(state, event, now) {
302
356
  }
303
357
  case "tool:start": {
304
358
  const node = toolTarget(state, event);
305
- if (node !== void 0) node.toolActive = scrub(event.toolName);
359
+ if (node !== void 0) node.toolActive = scrub(str(event.toolName));
306
360
  break;
307
361
  }
308
362
  case "tool:end": {
@@ -310,7 +364,7 @@ function applyEvent(state, event, now) {
310
364
  if (node !== void 0) {
311
365
  node.toolActive = void 0;
312
366
  node.toolCount += 1;
313
- if (event.outcome === "denied") node.badge = scrub(`tool ${event.toolName} denied`);
367
+ if (event.outcome === "denied") node.badge = scrub(`tool ${str(event.toolName)} denied`);
314
368
  }
315
369
  break;
316
370
  }
@@ -322,7 +376,7 @@ function applyEvent(state, event, now) {
322
376
  break;
323
377
  case "spawn:rejected":
324
378
  state.rejected += 1;
325
- state.notices.push(`spawn rejected: ${event.code}`);
379
+ state.notices.push(scrub(`spawn rejected: ${str(event.code)}`));
326
380
  if (state.notices.length > 2) state.notices.shift();
327
381
  break;
328
382
  default: break;
@@ -339,7 +393,7 @@ const GLYPHS = {
339
393
  escalated: "!"
340
394
  };
341
395
  function paint(text, code, style) {
342
- return style.color ? `[${code}m${text}` : text;
396
+ return style.color ? `\u001B[${code}m${text}\u001B[0m` : text;
343
397
  }
344
398
  function nodeRow(node, now, spinner, style) {
345
399
  const running = node.endedAt === void 0 && node.status === "running";
@@ -524,44 +578,57 @@ function progress(source, options) {
524
578
  const rawRows = sink.rows;
525
579
  const maxHeight = typeof rawRows === "number" && Number.isFinite(rawRows) ? Math.max(3, Math.floor(rawRows) - 1) : void 0;
526
580
  const frame = composeFrame(state, clock.now(), tick, style, width, maxRows, maxHeight);
527
- const erase = paintedLines > 0 ? `[${String(paintedLines)}A` : "";
581
+ const erase = paintedLines > 0 ? `\u001B[${String(paintedLines)}A\u001B[0J` : "";
528
582
  sink.write(erase + frame.join("\n") + "\n");
529
583
  paintedLines = frame.length;
530
584
  state.dirty = false;
531
585
  };
532
586
  const lineFor = (event, now) => {
533
587
  switch (event.type) {
534
- case "run:start": return `run ${event.runId} started: ${event.workflow}${event.resumed ? " (resumed)" : ""}`;
535
- 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)}`;
536
590
  case "agent:start": {
537
591
  const node = state.nodes.get(event.spanId);
538
592
  const inner = node !== void 0 && node.roles.length > 1;
539
- 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]" : ""}`;
540
594
  }
541
595
  case "agent:end": {
542
596
  const node = state.nodes.get(event.spanId);
543
597
  const elapsed = node === void 0 || node.replayed || node.startedAt <= 0 ? "" : ` in ${fmtDuration((node.endedAt ?? now) - node.startedAt)}`;
544
598
  const roles = node !== void 0 && node.roles.length > 1 ? ` [${node.roles.map((slice) => slice.role).join(" > ")}]` : "";
545
- return `agent ${agentTitle(event)} ${event.status}${elapsed}: in ${fmtTokens(event.usage?.inputTokens ?? 0)} out ${fmtTokens(event.usage?.outputTokens ?? 0)}, ${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)" : ""}`;
546
600
  }
547
- 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)" : "");
548
602
  case "budget:update":
549
603
  if (lastLinesBudgetAt !== void 0 && now - lastLinesBudgetAt < 1e3) return;
550
604
  lastLinesBudgetAt = now;
551
- return `budget: ${fmtUsd(event.spentUsd)}` + (event.remainingUsd === null ? "" : ` of ${fmtUsd(event.spentUsd + event.remainingUsd)}`);
552
- case "log": return event.level === "warn" || event.level === "error" ? `[${event.level}] ${event.msg}` : void 0;
553
- case "external:waiting": return `waiting on external: ${event.key}`;
554
- case "approval:pending": return `approval pending: ${event.toolName}`;
555
- 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))})`;
556
610
  default: return;
557
611
  }
558
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;
559
619
  const onEvent = (event) => {
560
620
  const now = clock.now();
561
- applyEvent(state, event, now);
562
- if (mode === "lines") {
563
- const line = lineFor(event, now);
564
- if (line !== void 0) sink.write(scrub(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
+ }
565
632
  }
566
633
  };
567
634
  const finishLines = () => {
@@ -615,7 +682,7 @@ function progress(source, options) {
615
682
  state.totalUsd = outcome.cost.totalUsd;
616
683
  state.endedAt ??= clock.now();
617
684
  }).catch((thrown) => {
618
- state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
685
+ pushNotice(errorNotice(thrown));
619
686
  state.endedAt ??= clock.now();
620
687
  }).finally(() => {
621
688
  handleApi.stop(true);
@@ -625,7 +692,7 @@ function progress(source, options) {
625
692
  else if (typeof source.then === "function") source.then((handle) => {
626
693
  attachHandle(handle);
627
694
  }).catch((thrown) => {
628
- state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
695
+ pushNotice(errorNotice(thrown));
629
696
  state.endedAt ??= clock.now();
630
697
  handleApi.stop(true);
631
698
  });
@@ -636,7 +703,7 @@ function progress(source, options) {
636
703
  onEvent(event);
637
704
  }
638
705
  } catch (thrown) {
639
- state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
706
+ pushNotice(errorNotice(thrown));
640
707
  } finally {
641
708
  state.endedAt ??= clock.now();
642
709
  handleApi.stop(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/rulvar",
3
- "version": "1.22.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.22.0",
26
- "@rulvar/anthropic": "1.22.0",
27
- "@rulvar/openai": "1.22.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.22.0"
34
+ "@rulvar/testing": "1.23.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",