@rulvar/rulvar 1.21.0 → 1.22.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,7 +1,21 @@
1
+ import { 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
20
  return `${amount.toFixed(4)} USD`;
7
21
  }
@@ -10,9 +24,10 @@ function usd(amount) {
10
24
  * the final run:end line.
11
25
  */
12
26
  async function renderProgress(events, options) {
13
- const write = options?.write ?? ((line) => {
27
+ const sink = options?.write ?? ((line) => {
14
28
  process.stderr.write(`${line}\n`);
15
29
  });
30
+ const write = (line) => sink(sanitizeTerminalText(line));
16
31
  const logs = options?.logs ?? true;
17
32
  for await (const event of events) switch (event.type) {
18
33
  case "run:start":
@@ -50,7 +65,45 @@ async function renderProgress(events, options) {
50
65
  }
51
66
  //#endregion
52
67
  //#region src/live-progress.ts
68
+ /**
69
+ * Live terminal progress view (v1.21.0): a claude-workflows-style tree
70
+ * over the WorkflowEvent stream, one row per agent with a status glyph,
71
+ * a running timer, token counts, and USD, plus per-role sub-timings when
72
+ * one agent call spans several invocation phases (loop, summarize,
73
+ * finalize, extract). The minimal line-per-event `renderProgress` stays
74
+ * untouched next door; this renderer is the rich, cursor-addressed
75
+ * sibling with an append-only fallback for pipes and CI.
76
+ *
77
+ * Honesty rules inherited from the event contract
78
+ * (https://docs.rulvar.com/guide/observability): exact token counts
79
+ * exist only at `agent:end`, so running rows show elapsed time and a
80
+ * tilde-marked character estimate from `agent:stream` deltas; run-level
81
+ * USD is live through `budget:update`; per-role dollars appear in the
82
+ * final summary only when the source is a RunHandle (they come from
83
+ * `RunOutcome.cost.byRole`, not from any event). Replayed lifecycle
84
+ * events render dim with a `replay` tag, never spin, and never add to
85
+ * totals: the authoritative money numbers are `budget:update.spentUsd`
86
+ * and `run:end.totalUsd`, which are immune to replay double counting.
87
+ *
88
+ * The reducer is defensive by contract: unknown event types are ignored
89
+ * and every dynamic field, including the required ones, is read
90
+ * defensively (optional chaining with fallbacks), so a malformed event
91
+ * degrades a row rather than throwing and stopping the view; an unknown
92
+ * parent span attaches at the root, and a mid-run attach synthesizes a
93
+ * root instead of failing.
94
+ */
95
+ /**
96
+ * 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.
100
+ */
101
+ function posIntOption(value, fallback, min) {
102
+ if (value === void 0 || !Number.isFinite(value)) return fallback;
103
+ return Math.max(min, Math.floor(value));
104
+ }
53
105
  function fmtDuration(ms) {
106
+ if (!Number.isFinite(ms) || ms < 0) ms = 0;
54
107
  const s = ms / 1e3;
55
108
  if (s < 10) return `${s.toFixed(1)}s`;
56
109
  if (s < 60) return `${String(Math.floor(s))}s`;
@@ -82,6 +135,7 @@ const SPINNER = [
82
135
  "-",
83
136
  "\\"
84
137
  ];
138
+ const SGR_STRIP = /* @__PURE__ */ new RegExp("\\u001B\\[[0-9;]*m", "gu");
85
139
  function newState(title) {
86
140
  return {
87
141
  ...title === void 0 ? {} : { title },
@@ -120,15 +174,15 @@ function nodeOf(state, event, kind, title) {
120
174
  return node;
121
175
  }
122
176
  /**
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.
177
+ * Untrusted wire strings (model ids, tool names, error messages, log
178
+ * text, workflow and label metadata) may carry control characters and
179
+ * escape sequences that break the repaint arithmetic or leak terminal
180
+ * control. The shared core sanitizer strips C0, DEL, C1, and whole
181
+ * ESC-initiated CSI/OSC/DCS sequences before interpolation; the
182
+ * renderer's own SGR is added by paint() afterward (v1.21.0 review
183
+ * P2-1).
127
184
  */
128
- const CONTROL_CHARS = /[\u0000-\u001f\u007f]+/gu;
129
- function scrub(text) {
130
- return text.replace(CONTROL_CHARS, " ");
131
- }
185
+ const scrub = sanitizeTerminalText;
132
186
  function agentTitle(event) {
133
187
  const base = event.agentType === void 0 || event.agentType === "" ? "agent" : scrub(event.agentType);
134
188
  return event.label === void 0 || event.label === "" ? base : `${base} (${scrub(event.label)})`;
@@ -222,7 +276,7 @@ function applyEvent(state, event, now) {
222
276
  }
223
277
  case "agent:error": {
224
278
  const node = state.nodes.get(event.spanId);
225
- if (node !== void 0) node.badge = event.willRetry ? "retry" : scrub(`error: ${event.error.message}`);
279
+ if (node !== void 0) node.badge = event.willRetry ? "retry" : scrub(`error: ${event.error?.message ?? ""}`);
226
280
  break;
227
281
  }
228
282
  case "agent:schema-retry": {
@@ -236,8 +290,8 @@ function applyEvent(state, event, now) {
236
290
  node.status = event.status;
237
291
  node.endedAt = now;
238
292
  node.usage = {
239
- input: event.usage.inputTokens,
240
- output: event.usage.outputTokens
293
+ input: event.usage?.inputTokens ?? 0,
294
+ output: event.usage?.outputTokens ?? 0
241
295
  };
242
296
  node.costUsd = event.costUsd;
243
297
  if (event.replayed === true) node.replayed = true;
@@ -375,10 +429,11 @@ function composeFrame(state, now, tick, style, width, maxRows, maxHeight) {
375
429
  ...lines.slice(lines.length - keepTail)
376
430
  ];
377
431
  }
432
+ const limit = Math.max(0, width - 1);
378
433
  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)) + "...";
434
+ const plain = line.replace(SGR_STRIP, "");
435
+ if (plain.length <= limit) return line;
436
+ return limit >= 4 ? plain.slice(0, limit - 3) + "..." : plain.slice(0, limit);
382
437
  });
383
438
  }
384
439
  function defaultSink() {
@@ -440,10 +495,12 @@ function progress(source, options) {
440
495
  const clock = options?.clock ?? defaultClock();
441
496
  const mode = resolveMode(options?.mode, sink);
442
497
  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);
498
+ const columns = posIntOption(sink.columns, 80, 1);
499
+ const rows = posIntOption(sink.rows, 32, 3);
500
+ const width = posIntOption(options?.width, columns, 1);
501
+ const maxRows = posIntOption(options?.maxRows, Math.max(6, Math.min(24, rows - 8)), 1);
502
+ const fps = Math.min(30, posIntOption(options?.fps, 10, 1));
503
+ const state = newState(options?.title === void 0 ? void 0 : scrub(options.title));
447
504
  let settled = false;
448
505
  let resolveDone = () => void 0;
449
506
  const done = new Promise((resolve) => {
@@ -464,7 +521,8 @@ function progress(source, options) {
464
521
  let paintedLines = 0;
465
522
  let lastLinesBudgetAt;
466
523
  const paintFrame = () => {
467
- const maxHeight = sink.rows === void 0 ? void 0 : Math.max(3, sink.rows - 1);
524
+ const rawRows = sink.rows;
525
+ const maxHeight = typeof rawRows === "number" && Number.isFinite(rawRows) ? Math.max(3, Math.floor(rawRows) - 1) : void 0;
468
526
  const frame = composeFrame(state, clock.now(), tick, style, width, maxRows, maxHeight);
469
527
  const erase = paintedLines > 0 ? `[${String(paintedLines)}A` : "";
470
528
  sink.write(erase + frame.join("\n") + "\n");
@@ -484,9 +542,9 @@ function progress(source, options) {
484
542
  const node = state.nodes.get(event.spanId);
485
543
  const elapsed = node === void 0 || node.replayed || node.startedAt <= 0 ? "" : ` in ${fmtDuration((node.endedAt ?? now) - node.startedAt)}`;
486
544
  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)" : ""}`;
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)" : ""}`;
488
546
  }
489
- case "agent:error": return `agent ${agentTitle(event)} error: ${event.error.message}` + (event.willRetry ? " (will retry)" : "");
547
+ case "agent:error": return `agent ${agentTitle(event)} error: ${event.error?.message ?? ""}` + (event.willRetry ? " (will retry)" : "");
490
548
  case "budget:update":
491
549
  if (lastLinesBudgetAt !== void 0 && now - lastLinesBudgetAt < 1e3) return;
492
550
  lastLinesBudgetAt = now;
@@ -503,7 +561,7 @@ function progress(source, options) {
503
561
  applyEvent(state, event, now);
504
562
  if (mode === "lines") {
505
563
  const line = lineFor(event, now);
506
- if (line !== void 0) sink.write(line + "\n");
564
+ if (line !== void 0) sink.write(scrub(line) + "\n");
507
565
  }
508
566
  };
509
567
  const finishLines = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/rulvar",
3
- "version": "1.21.0",
3
+ "version": "1.22.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.22.0",
26
+ "@rulvar/anthropic": "1.22.0",
27
+ "@rulvar/openai": "1.22.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.22.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",