@rulvar/rulvar 1.20.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/README.md CHANGED
@@ -2,11 +2,20 @@
2
2
 
3
3
  The batteries-included Rulvar install: re-exports the entire
4
4
  `@rulvar/core` surface plus both first-class adapters (`anthropic`,
5
- `openai`), the terminal progress renderer, and `recommendedDefaults`,
5
+ `openai`), two terminal progress renderers, and `recommendedDefaults`,
6
6
  the only place the project names strong default models for the
7
7
  orchestrate and plan roles. Also installable through the unscoped alias
8
8
  package `rulvar`, which re-exports this one.
9
9
 
10
+ The renderers are `progress()`, the live view (one row per agent with a
11
+ status glyph, a running timer, token counts, and USD, per-role
12
+ sub-timings when one call spans several invocation phases, the run
13
+ header with spend against the ceiling, and a final per-role cost
14
+ summary; repaints in place on a TTY and degrades to append-only lines in
15
+ pipes and CI), and `renderProgress()`, the minimal one line per
16
+ lifecycle fact. Both consume the public `WorkflowEvent` stream and
17
+ nothing else.
18
+
10
19
  Part of [Rulvar](https://rulvar.com), an embeddable TypeScript engine
11
20
  for durable, budget-bounded multi-agent LLM workflows, where a completed
12
21
  LLM call is never paid for twice. Full documentation:
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { InvocationRole, ModelSpec, QualityFloors, WorkflowEvent } from "@rulvar/core";
1
+ import { InvocationRole, ModelSpec, QualityFloors, RunHandle, WorkflowEvent } from "@rulvar/core";
2
2
  import { ANTHROPIC_MODELS, AnthropicAdapterOptions, anthropic } from "@rulvar/anthropic";
3
3
  import { OPENAI_MODELS, OpenAiAdapterOptions, openai } from "@rulvar/openai";
4
4
  export * from "@rulvar/core";
@@ -16,6 +16,69 @@ interface RenderProgressOptions {
16
16
  */
17
17
  declare function renderProgress(events: AsyncIterable<WorkflowEvent>, options?: RenderProgressOptions): Promise<void>;
18
18
  //#endregion
19
+ //#region src/live-progress.d.ts
20
+ /** Raw output sink; chunks may contain ANSI and partial lines. */
21
+ interface ProgressSink {
22
+ write(chunk: string): void;
23
+ isTTY?: boolean;
24
+ columns?: number;
25
+ rows?: number;
26
+ }
27
+ /** Injectable time source; every() returns a cancel function. */
28
+ interface ProgressClock {
29
+ now(): number;
30
+ every(ms: number, fn: () => void): () => void;
31
+ }
32
+ type ProgressMode = "auto" | "tty" | "lines" | "off";
33
+ interface ProgressOptions {
34
+ /** Defaults to process.stderr so application stdout stays clean. */
35
+ sink?: ProgressSink;
36
+ /** Defaults to a monotonic clock (performance.now) plus setInterval. */
37
+ clock?: ProgressClock;
38
+ /**
39
+ * 'auto' (default) picks 'tty' when the sink reports a TTY and the
40
+ * environment is not CI or TERM=dumb, else 'lines'.
41
+ */
42
+ mode?: ProgressMode;
43
+ /** Repaints per second in tty mode, clamped to 1..30. Default 10. */
44
+ fps?: number;
45
+ /** SGR colors. Default: true in tty mode unless NO_COLOR is set. */
46
+ color?: boolean;
47
+ /** Column override. Default sink.columns, else 80. */
48
+ width?: number;
49
+ /** Body rows before the oldest completed rows collapse. Default 24. */
50
+ maxRows?: number;
51
+ /** Header title. Default: the workflow name from run:start. */
52
+ title?: string;
53
+ }
54
+ interface ProgressHandle {
55
+ /** The resolved mode after auto detection. */
56
+ readonly mode: "tty" | "lines" | "off";
57
+ /** Settles after the final frame is written; never rejects. */
58
+ readonly done: Promise<void>;
59
+ /** Force an immediate repaint outside the tick (tests, custom pacing). */
60
+ render(): void;
61
+ /**
62
+ * Idempotent. final=true (default) paints the settle frame; false
63
+ * freezes the current frame in scrollback. Always restores the cursor
64
+ * and resolves `done`.
65
+ */
66
+ stop(final?: boolean): void;
67
+ }
68
+ type ProgressSource = RunHandle<unknown> | Promise<RunHandle<unknown>> | AsyncIterable<WorkflowEvent>;
69
+ /**
70
+ * Attaches a live progress view to a run and returns its handle. Accepts
71
+ * a RunHandle (subscribes through `on()`, leaving `handle.events` free
72
+ * for the host, and enriches the final frame from `RunOutcome.cost`;
73
+ * `orchestrate` returns exactly such a handle, so
74
+ * `progress(orchestrate(...))` composes directly), a promise resolving
75
+ * to a handle (for wrappers that construct one asynchronously), or a
76
+ * raw WorkflowEvent iterable (the gapless path for resumes:
77
+ * `progress(resumed.events)`; note it consumes that one-shot iterable).
78
+ * The view auto-stops when the run settles.
79
+ */
80
+ declare function progress(source: ProgressSource, options?: ProgressOptions): ProgressHandle;
81
+ //#endregion
19
82
  //#region src/defaults.d.ts
20
83
  /**
21
84
  * Drop-in engine defaults: `createEngine({ ..., defaults: { routing:
@@ -31,4 +94,4 @@ declare const recommendedDefaults: {
31
94
  floors: QualityFloors;
32
95
  };
33
96
  //#endregion
34
- export { ANTHROPIC_MODELS, type AnthropicAdapterOptions, OPENAI_MODELS, type OpenAiAdapterOptions, type RenderProgressOptions, anthropic, openai, recommendedDefaults, renderProgress };
97
+ export { ANTHROPIC_MODELS, type AnthropicAdapterOptions, OPENAI_MODELS, type OpenAiAdapterOptions, type ProgressClock, type ProgressHandle, type ProgressMode, type ProgressOptions, type ProgressSink, type ProgressSource, type RenderProgressOptions, anthropic, openai, progress, recommendedDefaults, renderProgress };
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":
@@ -49,6 +64,587 @@ async function renderProgress(events, options) {
49
64
  }
50
65
  }
51
66
  //#endregion
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
+ }
105
+ function fmtDuration(ms) {
106
+ if (!Number.isFinite(ms) || ms < 0) ms = 0;
107
+ const s = ms / 1e3;
108
+ if (s < 10) return `${s.toFixed(1)}s`;
109
+ if (s < 60) return `${String(Math.floor(s))}s`;
110
+ const minutes = Math.floor(s / 60);
111
+ if (minutes < 60) return `${String(minutes)}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`;
112
+ return `${String(Math.floor(minutes / 60))}h ${String(minutes % 60).padStart(2, "0")}m`;
113
+ }
114
+ function fmtTokens(count) {
115
+ if (count < 1e3) return String(count);
116
+ if (count < 1e4) return `${(count / 1e3).toFixed(1)}k`;
117
+ if (count < 1e6) return `${String(Math.round(count / 1e3))}k`;
118
+ return `${(count / 1e6).toFixed(1)}M`;
119
+ }
120
+ function fmtUsd(amount) {
121
+ if (amount === 0) return "$0";
122
+ if (amount >= 100) return `$${String(Math.round(amount))}`;
123
+ if (amount >= 1) return `$${amount.toFixed(2)}`;
124
+ if (amount >= .01) return `$${amount.toFixed(3)}`;
125
+ return `$${amount.toFixed(4)}`;
126
+ }
127
+ function bar(spent, ceiling, cells) {
128
+ const ratio = ceiling <= 0 ? 1 : Math.min(1, spent / ceiling);
129
+ const filled = Math.round(ratio * cells);
130
+ return "#".repeat(filled) + ".".repeat(cells - filled);
131
+ }
132
+ const SPINNER = [
133
+ "|",
134
+ "/",
135
+ "-",
136
+ "\\"
137
+ ];
138
+ const SGR_STRIP = /* @__PURE__ */ new RegExp("\\u001B\\[[0-9;]*m", "gu");
139
+ function newState(title) {
140
+ return {
141
+ ...title === void 0 ? {} : { title },
142
+ resumed: false,
143
+ status: "running",
144
+ spentUsd: 0,
145
+ nodes: /* @__PURE__ */ new Map(),
146
+ roots: [],
147
+ notices: [],
148
+ wakes: 0,
149
+ admitted: 0,
150
+ rejected: 0,
151
+ dirty: true
152
+ };
153
+ }
154
+ function nodeOf(state, event, kind, title) {
155
+ const existing = state.nodes.get(event.spanId);
156
+ if (existing !== void 0) return existing;
157
+ const node = {
158
+ spanId: event.spanId,
159
+ ...event.parentSpanId === void 0 ? {} : { parentSpanId: event.parentSpanId },
160
+ kind,
161
+ title,
162
+ status: "running",
163
+ replayed: event.replayed === true,
164
+ roles: [],
165
+ streamedChars: 0,
166
+ toolCount: 0,
167
+ children: [],
168
+ startedAt: 0
169
+ };
170
+ state.nodes.set(event.spanId, node);
171
+ const parent = event.parentSpanId === void 0 || event.parentSpanId === event.spanId ? void 0 : state.nodes.get(event.parentSpanId);
172
+ if (parent !== void 0) parent.children.push(event.spanId);
173
+ else state.roots.push(event.spanId);
174
+ return node;
175
+ }
176
+ /**
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).
184
+ */
185
+ const scrub = sanitizeTerminalText;
186
+ 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)})`;
189
+ }
190
+ /** The tool event's span is the agent's own span or a child of it. */
191
+ function toolTarget(state, event) {
192
+ return state.nodes.get(event.spanId) ?? (event.parentSpanId === void 0 ? void 0 : state.nodes.get(event.parentSpanId));
193
+ }
194
+ function applyEvent(state, event, now) {
195
+ state.dirty = true;
196
+ switch (event.type) {
197
+ case "run:start":
198
+ state.runId = event.runId;
199
+ state.resumed = event.resumed === true;
200
+ state.startedAt ??= now;
201
+ state.title ??= scrub(event.workflow);
202
+ break;
203
+ case "run:end":
204
+ state.status = event.status;
205
+ state.totalUsd = event.totalUsd;
206
+ state.endedAt = now;
207
+ for (const node of state.nodes.values()) {
208
+ if (node.endedAt === void 0 && node.kind !== "phase") {
209
+ node.endedAt = now;
210
+ if (node.status === "running" || node.status === "queued") node.status = "interrupted";
211
+ }
212
+ const open = node.roles.at(-1);
213
+ if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
214
+ }
215
+ break;
216
+ case "phase:start": {
217
+ const node = nodeOf(state, event, "phase", scrub(event.phase));
218
+ node.startedAt = now;
219
+ node.status = "ok";
220
+ break;
221
+ }
222
+ case "budget:update":
223
+ state.spentUsd = event.spentUsd;
224
+ state.ceilingUsd = event.remainingUsd === null ? void 0 : event.spentUsd + event.remainingUsd;
225
+ break;
226
+ case "log":
227
+ if (event.level === "warn" || event.level === "error") {
228
+ state.notices.push(scrub(`${event.level}: ${event.msg}`));
229
+ if (state.notices.length > 2) state.notices.shift();
230
+ }
231
+ break;
232
+ case "external:waiting":
233
+ state.banner = scrub(`waiting on external: ${event.key}`);
234
+ break;
235
+ case "approval:pending":
236
+ state.banner = scrub(`approval pending: ${event.toolName}`);
237
+ break;
238
+ case "child:start": {
239
+ const node = nodeOf(state, event, "child", scrub(`${event.workflow} (${event.scope})`));
240
+ node.startedAt = now;
241
+ break;
242
+ }
243
+ case "child:end": {
244
+ const node = state.nodes.get(event.spanId);
245
+ if (node !== void 0) {
246
+ node.status = event.status;
247
+ node.endedAt = now;
248
+ }
249
+ break;
250
+ }
251
+ case "agent:queued": {
252
+ const node = nodeOf(state, event, "agent", agentTitle(event));
253
+ node.status = "queued";
254
+ node.startedAt = now;
255
+ break;
256
+ }
257
+ case "agent:start": {
258
+ state.banner = void 0;
259
+ const node = nodeOf(state, event, "agent", agentTitle(event));
260
+ if (node.status === "queued" || node.roles.length === 0) node.startedAt = now;
261
+ node.status = "running";
262
+ node.model = scrub(event.model);
263
+ if (event.replayed === true) node.replayed = true;
264
+ const open = node.roles.at(-1);
265
+ if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
266
+ node.roles.push({
267
+ role: event.role,
268
+ startedAt: now
269
+ });
270
+ break;
271
+ }
272
+ case "agent:stream": {
273
+ const node = state.nodes.get(event.spanId);
274
+ if (node !== void 0) node.streamedChars += event.delta.length;
275
+ break;
276
+ }
277
+ case "agent:error": {
278
+ const node = state.nodes.get(event.spanId);
279
+ if (node !== void 0) node.badge = event.willRetry ? "retry" : scrub(`error: ${event.error?.message ?? ""}`);
280
+ break;
281
+ }
282
+ case "agent:schema-retry": {
283
+ const node = state.nodes.get(event.spanId);
284
+ if (node !== void 0) node.badge = `schema ${String(event.attempt)}/${String(event.maxAttempts)}`;
285
+ break;
286
+ }
287
+ case "agent:end": {
288
+ state.banner = void 0;
289
+ const node = nodeOf(state, event, "agent", agentTitle(event));
290
+ node.status = event.status;
291
+ node.endedAt = now;
292
+ node.usage = {
293
+ input: event.usage?.inputTokens ?? 0,
294
+ output: event.usage?.outputTokens ?? 0
295
+ };
296
+ node.costUsd = event.costUsd;
297
+ if (event.replayed === true) node.replayed = true;
298
+ if (node.status === "ok") node.badge = void 0;
299
+ const open = node.roles.at(-1);
300
+ if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
301
+ break;
302
+ }
303
+ case "tool:start": {
304
+ const node = toolTarget(state, event);
305
+ if (node !== void 0) node.toolActive = scrub(event.toolName);
306
+ break;
307
+ }
308
+ case "tool:end": {
309
+ const node = toolTarget(state, event);
310
+ if (node !== void 0) {
311
+ node.toolActive = void 0;
312
+ node.toolCount += 1;
313
+ if (event.outcome === "denied") node.badge = scrub(`tool ${event.toolName} denied`);
314
+ }
315
+ break;
316
+ }
317
+ case "orchestrator:woke":
318
+ state.wakes += 1;
319
+ break;
320
+ case "spawn:admitted":
321
+ state.admitted += 1;
322
+ break;
323
+ case "spawn:rejected":
324
+ state.rejected += 1;
325
+ state.notices.push(`spawn rejected: ${event.code}`);
326
+ if (state.notices.length > 2) state.notices.shift();
327
+ break;
328
+ default: break;
329
+ }
330
+ }
331
+ const GLYPHS = {
332
+ ok: "*",
333
+ running: "",
334
+ queued: "o",
335
+ error: "x",
336
+ cancelled: "x",
337
+ exhausted: "x",
338
+ interrupted: "x",
339
+ escalated: "!"
340
+ };
341
+ function paint(text, code, style) {
342
+ return style.color ? `[${code}m${text}` : text;
343
+ }
344
+ function nodeRow(node, now, spinner, style) {
345
+ const running = node.endedAt === void 0 && node.status === "running";
346
+ const glyph = running ? node.replayed ? "*" : spinner : GLYPHS[node.status] ?? "*";
347
+ const parts = [glyph === "" ? spinner : glyph, node.title];
348
+ if (node.model !== void 0) parts.push(paint(node.model, "2", style));
349
+ const currentRole = node.roles.at(-1)?.role;
350
+ if (currentRole !== void 0 && currentRole !== "loop") parts.push(paint(`> ${currentRole}`, "36", style));
351
+ if (node.replayed) parts.push(paint("replay", "2", style));
352
+ else if (node.startedAt > 0 && node.kind !== "phase") {
353
+ const elapsed = (node.endedAt ?? now) - node.startedAt;
354
+ parts.push(fmtDuration(elapsed));
355
+ }
356
+ if (node.usage !== void 0) parts.push(`in ${fmtTokens(node.usage.input)} out ${fmtTokens(node.usage.output)}`);
357
+ else if (running && node.streamedChars > 0) parts.push(paint(`~${fmtTokens(Math.ceil(node.streamedChars / 4))} out`, "2", style));
358
+ if (node.costUsd !== void 0 && !node.replayed) parts.push(fmtUsd(node.costUsd));
359
+ if (node.toolActive !== void 0) parts.push(paint(`tool: ${node.toolActive}`, "33", style));
360
+ else if (node.toolCount > 0) parts.push(paint(`${String(node.toolCount)} tool${node.toolCount === 1 ? "" : "s"}`, "2", style));
361
+ if (node.badge !== void 0) parts.push(paint(node.badge, node.badge.startsWith("error") ? "31" : "33", style));
362
+ if (node.status === "queued") parts.push(paint("queued", "2", style));
363
+ return parts.join(" ");
364
+ }
365
+ function roleRow(node, now) {
366
+ if (node.roles.length < 2) return;
367
+ return `roles: ${node.roles.map((slice) => {
368
+ const elapsed = (slice.endedAt ?? now) - slice.startedAt;
369
+ return `${slice.role} ${fmtDuration(elapsed)}${slice.endedAt === void 0 ? ".." : ""}`;
370
+ }).join(" · ")}`;
371
+ }
372
+ function composeTree(state, now, spinner, style) {
373
+ const lines = [];
374
+ const visited = /* @__PURE__ */ new Set();
375
+ const walk = (spanId, depth) => {
376
+ const node = state.nodes.get(spanId);
377
+ if (node === void 0 || visited.has(spanId)) return;
378
+ visited.add(spanId);
379
+ const indent = " ".repeat(depth + 1);
380
+ if (node.kind === "phase") lines.push(`${indent}${paint(`[${node.title}]`, "1", style)}`);
381
+ else {
382
+ lines.push(indent + nodeRow(node, now, spinner, style));
383
+ const roles = roleRow(node, now);
384
+ if (roles !== void 0) lines.push(indent + " " + paint(roles, "2", style));
385
+ }
386
+ for (const child of node.children) walk(child, depth + 1);
387
+ };
388
+ for (const root of state.roots) walk(root, 0);
389
+ return lines;
390
+ }
391
+ function composeFrame(state, now, tick, style, width, maxRows, maxHeight) {
392
+ const spinner = SPINNER[tick % SPINNER.length];
393
+ const lines = [];
394
+ const running = state.endedAt === void 0;
395
+ const head = [running ? spinner : state.status === "ok" ? "*" : "x", state.title ?? "run"];
396
+ if (state.runId !== void 0) head.push(paint(`run ${state.runId}`, "2", style));
397
+ if (state.resumed) head.push(paint("(resumed)", "2", style));
398
+ if (state.startedAt !== void 0) head.push(fmtDuration((state.endedAt ?? now) - state.startedAt));
399
+ const spent = state.totalUsd ?? state.spentUsd;
400
+ if (state.ceilingUsd !== void 0) {
401
+ head.push(`${fmtUsd(spent)} / ${fmtUsd(state.ceilingUsd)}`);
402
+ head.push(bar(spent, state.ceilingUsd, 12));
403
+ } else if (spent > 0) head.push(fmtUsd(spent));
404
+ lines.push(head.join(" "));
405
+ let body = composeTree(state, now, spinner, style);
406
+ if (body.length > maxRows) {
407
+ const hidden = body.length - maxRows;
408
+ body = [paint(` ... ${String(hidden)} earlier rows hidden`, "2", style), ...body.slice(hidden)];
409
+ }
410
+ lines.push(...body);
411
+ if (state.wakes + state.admitted + state.rejected > 0) lines.push(" " + paint(`orch: wakes ${String(state.wakes)} · spawns ${String(state.admitted)} admitted, ${String(state.rejected)} rejected`, "2", style));
412
+ if (state.banner !== void 0 && running) lines.push(" " + paint(state.banner, "33", style));
413
+ for (const notice of state.notices) lines.push(" " + paint(notice, "33", style));
414
+ if (!running) {
415
+ const roleCosts = state.cost?.byRole;
416
+ if (roleCosts !== void 0) {
417
+ const nonzero = Object.entries(roleCosts).filter(([, value]) => value > 0);
418
+ if (nonzero.length > 0) lines.push(" " + nonzero.map(([role, value]) => `${role} ${fmtUsd(value)}`).join(" · "));
419
+ }
420
+ lines.push(`${state.status === "ok" ? "*" : "x"} ${state.status}` + (state.totalUsd === void 0 ? "" : ` total ${fmtUsd(state.totalUsd)}`));
421
+ }
422
+ let clamped = lines;
423
+ if (maxHeight !== void 0 && lines.length > maxHeight && maxHeight >= 3) {
424
+ const keepTail = Math.min(running ? 1 : 3, maxHeight - 2);
425
+ const hidden = lines.length - 1 - keepTail - 1;
426
+ clamped = [
427
+ lines[0] ?? "",
428
+ paint(` ... ${String(hidden)} lines hidden (terminal too short)`, "2", style),
429
+ ...lines.slice(lines.length - keepTail)
430
+ ];
431
+ }
432
+ const limit = Math.max(0, width - 1);
433
+ return clamped.map((line) => {
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);
437
+ });
438
+ }
439
+ function defaultSink() {
440
+ return process.stderr;
441
+ }
442
+ function defaultClock() {
443
+ return {
444
+ now: () => performance.now(),
445
+ every: (ms, fn) => {
446
+ const timer = setInterval(fn, ms);
447
+ return () => clearInterval(timer);
448
+ }
449
+ };
450
+ }
451
+ function resolveMode(option, sink) {
452
+ if (option !== void 0 && option !== "auto") return option;
453
+ const dumb = process.env.TERM === "dumb" || process.env.CI !== void 0;
454
+ return sink.isTTY === true && !dumb ? "tty" : "lines";
455
+ }
456
+ function isHandle(source) {
457
+ return typeof source.on === "function" && typeof source.result?.then === "function";
458
+ }
459
+ /** Every event type the reducer renders; handle mode subscribes per type. */
460
+ const CONSUMED_TYPES = [
461
+ "run:start",
462
+ "run:end",
463
+ "phase:start",
464
+ "budget:update",
465
+ "log",
466
+ "external:waiting",
467
+ "approval:pending",
468
+ "child:start",
469
+ "child:end",
470
+ "agent:queued",
471
+ "agent:start",
472
+ "agent:stream",
473
+ "agent:error",
474
+ "agent:schema-retry",
475
+ "agent:end",
476
+ "tool:start",
477
+ "tool:end",
478
+ "orchestrator:woke",
479
+ "spawn:admitted",
480
+ "spawn:rejected"
481
+ ];
482
+ /**
483
+ * Attaches a live progress view to a run and returns its handle. Accepts
484
+ * a RunHandle (subscribes through `on()`, leaving `handle.events` free
485
+ * for the host, and enriches the final frame from `RunOutcome.cost`;
486
+ * `orchestrate` returns exactly such a handle, so
487
+ * `progress(orchestrate(...))` composes directly), a promise resolving
488
+ * to a handle (for wrappers that construct one asynchronously), or a
489
+ * raw WorkflowEvent iterable (the gapless path for resumes:
490
+ * `progress(resumed.events)`; note it consumes that one-shot iterable).
491
+ * The view auto-stops when the run settles.
492
+ */
493
+ function progress(source, options) {
494
+ const sink = options?.sink ?? defaultSink();
495
+ const clock = options?.clock ?? defaultClock();
496
+ const mode = resolveMode(options?.mode, sink);
497
+ const style = { color: options?.color ?? (mode === "tty" && process.env.NO_COLOR === void 0) };
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));
504
+ let settled = false;
505
+ let resolveDone = () => void 0;
506
+ const done = new Promise((resolve) => {
507
+ resolveDone = resolve;
508
+ });
509
+ if (mode === "off") {
510
+ if (!isHandle(source) && typeof source.then === "function") source.catch(() => void 0);
511
+ settled = true;
512
+ resolveDone();
513
+ return {
514
+ mode,
515
+ done,
516
+ render: () => void 0,
517
+ stop: () => void 0
518
+ };
519
+ }
520
+ let tick = 0;
521
+ let paintedLines = 0;
522
+ let lastLinesBudgetAt;
523
+ const paintFrame = () => {
524
+ const rawRows = sink.rows;
525
+ const maxHeight = typeof rawRows === "number" && Number.isFinite(rawRows) ? Math.max(3, Math.floor(rawRows) - 1) : void 0;
526
+ const frame = composeFrame(state, clock.now(), tick, style, width, maxRows, maxHeight);
527
+ const erase = paintedLines > 0 ? `[${String(paintedLines)}A` : "";
528
+ sink.write(erase + frame.join("\n") + "\n");
529
+ paintedLines = frame.length;
530
+ state.dirty = false;
531
+ };
532
+ const lineFor = (event, now) => {
533
+ 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}`;
536
+ case "agent:start": {
537
+ const node = state.nodes.get(event.spanId);
538
+ const inner = node !== void 0 && node.roles.length > 1;
539
+ return `agent ${agentTitle(event)} -> ${event.model} (${event.role})${inner ? " [inner phase]" : ""}`;
540
+ }
541
+ case "agent:end": {
542
+ const node = state.nodes.get(event.spanId);
543
+ const elapsed = node === void 0 || node.replayed || node.startedAt <= 0 ? "" : ` in ${fmtDuration((node.endedAt ?? now) - node.startedAt)}`;
544
+ 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)" : ""}`;
546
+ }
547
+ case "agent:error": return `agent ${agentTitle(event)} error: ${event.error?.message ?? ""}` + (event.willRetry ? " (will retry)" : "");
548
+ case "budget:update":
549
+ if (lastLinesBudgetAt !== void 0 && now - lastLinesBudgetAt < 1e3) return;
550
+ 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)})`;
556
+ default: return;
557
+ }
558
+ };
559
+ const onEvent = (event) => {
560
+ 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");
565
+ }
566
+ };
567
+ const finishLines = () => {
568
+ const roleCosts = state.cost?.byRole;
569
+ if (roleCosts === void 0) return;
570
+ const nonzero = Object.entries(roleCosts).filter(([, value]) => value > 0);
571
+ if (nonzero.length > 0) sink.write(`cost by role: ${nonzero.map(([r, v]) => `${r} ${fmtUsd(v)}`).join(", ")}\n`);
572
+ };
573
+ let cancelTick;
574
+ let unsubscribe = [];
575
+ const handleApi = {
576
+ mode,
577
+ done,
578
+ render: () => {
579
+ if (!settled && mode === "tty") {
580
+ tick += 1;
581
+ paintFrame();
582
+ }
583
+ },
584
+ stop: (final = true) => {
585
+ if (settled) return;
586
+ settled = true;
587
+ cancelTick?.();
588
+ for (const off of unsubscribe) off();
589
+ unsubscribe = [];
590
+ if (mode === "tty") {
591
+ if (final) paintFrame();
592
+ sink.write("\x1B[?25h");
593
+ } else if (final) finishLines();
594
+ resolveDone();
595
+ }
596
+ };
597
+ if (mode === "tty") {
598
+ sink.write("\x1B[?25l");
599
+ cancelTick = clock.every(Math.round(1e3 / fps), () => {
600
+ if (settled) return;
601
+ tick += 1;
602
+ const anyRunning = state.endedAt === void 0;
603
+ if (state.dirty || anyRunning) paintFrame();
604
+ });
605
+ }
606
+ const attachHandle = (handle) => {
607
+ if (settled) {
608
+ handle.result.catch(() => void 0);
609
+ return;
610
+ }
611
+ for (const type of CONSUMED_TYPES) unsubscribe.push(handle.on(type, onEvent));
612
+ handle.result.then((outcome) => {
613
+ state.cost = outcome.cost;
614
+ state.status = outcome.status;
615
+ state.totalUsd = outcome.cost.totalUsd;
616
+ state.endedAt ??= clock.now();
617
+ }).catch((thrown) => {
618
+ state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
619
+ state.endedAt ??= clock.now();
620
+ }).finally(() => {
621
+ handleApi.stop(true);
622
+ });
623
+ };
624
+ if (isHandle(source)) attachHandle(source);
625
+ else if (typeof source.then === "function") source.then((handle) => {
626
+ attachHandle(handle);
627
+ }).catch((thrown) => {
628
+ state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
629
+ state.endedAt ??= clock.now();
630
+ handleApi.stop(true);
631
+ });
632
+ else (async () => {
633
+ try {
634
+ for await (const event of source) {
635
+ if (settled) break;
636
+ onEvent(event);
637
+ }
638
+ } catch (thrown) {
639
+ state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
640
+ } finally {
641
+ state.endedAt ??= clock.now();
642
+ handleApi.stop(true);
643
+ }
644
+ })();
645
+ return handleApi;
646
+ }
647
+ //#endregion
52
648
  //#region src/defaults.ts
53
649
  /**
54
650
  * Drop-in engine defaults: `createEngine({ ..., defaults: { routing:
@@ -98,4 +694,4 @@ const recommendedDefaults = {
98
694
  } }
99
695
  };
100
696
  //#endregion
101
- export { ANTHROPIC_MODELS, OPENAI_MODELS, anthropic, openai, recommendedDefaults, renderProgress };
697
+ export { ANTHROPIC_MODELS, OPENAI_MODELS, anthropic, openai, progress, recommendedDefaults, renderProgress };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/rulvar",
3
- "version": "1.20.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.20.0",
26
- "@rulvar/anthropic": "1.20.0",
27
- "@rulvar/openai": "1.20.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.20.0"
34
+ "@rulvar/testing": "1.22.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",