@d3ara1n/pi-subagent 0.10.4 → 1.0.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/src/utils.ts CHANGED
@@ -1,20 +1,33 @@
1
1
  /**
2
- * Pure helpers for pi-subagent: formatting, sanitization, and the concurrency
3
- * semaphore. No pi-API or I/O dependencies — safe to unit-test.
2
+ * Pure helpers for pi-subagent: formatting, sanitization, the concurrency
3
+ * semaphore, render-side timers, and notification throttling. No pi-API or
4
+ * I/O dependencies — safe to unit-test.
4
5
  */
5
6
 
6
7
  import * as os from "node:os";
7
8
  import type {
8
9
  ActivityEntry,
10
+ FallbackFrom,
11
+ RunState,
9
12
  SubagentDetails,
10
13
  SubagentRole,
11
14
  SubagentResult,
15
+ SubagentUsage,
12
16
  ToolStatus,
17
+ WaitDetails,
13
18
  } from "./types.ts";
14
19
 
15
20
  /** Max output chars fed to the main model and the expanded TUI. Larger outputs are compressed (or truncated) to fit. */
16
21
  export const MAX_OUTPUT_CHARS = 50_000;
17
22
 
23
+ /** Coalesce bursty progress events so the TUI repaints at most this often. */
24
+ export const PROGRESS_THROTTLE_MS = 50;
25
+
26
+ /** A zeroed usage block — frames and synthesized failures start from this. */
27
+ export function emptyUsage(): SubagentUsage {
28
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
29
+ }
30
+
18
31
  export function formatTokens(count: number): string {
19
32
  if (count < 1000) return count.toString();
20
33
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@@ -22,16 +35,28 @@ export function formatTokens(count: number): string {
22
35
  return `${(count / 1000000).toFixed(1)}M`;
23
36
  }
24
37
 
25
- export function formatUsageStats(usage: SubagentResult["usage"], model?: string): string {
38
+ /**
39
+ * Usage parts shared by the TUI stats line and the LLM usage footer.
40
+ * `withCache` adds the cache-read/write and peak-context figures (TUI only —
41
+ * the LLM footer stays lean).
42
+ */
43
+ function usageParts(usage: SubagentUsage, model: string | undefined, withCache: boolean): string[] {
26
44
  const parts: string[] = [];
27
45
  if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
28
46
  if (usage.input) parts.push(`\u2191${formatTokens(usage.input)}`);
29
47
  if (usage.output) parts.push(`\u2193${formatTokens(usage.output)}`);
30
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
31
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
48
+ if (withCache) {
49
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
50
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
51
+ if (usage.contextTokens) parts.push(`ctx${formatTokens(usage.contextTokens)}`);
52
+ }
32
53
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
33
54
  if (model) parts.push(model);
34
- return parts.join(" ");
55
+ return parts;
56
+ }
57
+
58
+ export function formatUsageStats(usage: SubagentUsage, model?: string): string {
59
+ return usageParts(usage, model, true).join(" ");
35
60
  }
36
61
 
37
62
  /**
@@ -57,6 +82,31 @@ export function elapsedSeconds(r: {
57
82
  return undefined;
58
83
  }
59
84
 
85
+ /**
86
+ * Elapsed/budget time text for a frame: `42s/900s(+7s)` while budgeted (grace
87
+ * = time the child spent inside nested delegates, live-computed from an open
88
+ * pause), `42s` without a budget. null when no time is known (queued frames).
89
+ */
90
+ export function formatTimePart(r: {
91
+ exitCode: number;
92
+ startTime?: number;
93
+ elapsedMs?: number;
94
+ budgetMs?: number;
95
+ graceMs?: number;
96
+ pauseStart?: number;
97
+ }): string | null {
98
+ const secs = elapsedSeconds(r);
99
+ if (secs == null) return null;
100
+ const budgetSec = r.budgetMs ? Math.round(r.budgetMs / 1000) : 0;
101
+ const liveGraceMs = (r.graceMs ?? 0) + (r.pauseStart ? Date.now() - r.pauseStart : 0);
102
+ const graceSec = Math.round(liveGraceMs / 1000);
103
+ return budgetSec > 0
104
+ ? graceSec > 0
105
+ ? `${secs}s/${budgetSec}s(+${graceSec}s)`
106
+ : `${secs}s/${budgetSec}s`
107
+ : `${secs}s`;
108
+ }
109
+
60
110
  export type DisplayItem =
61
111
  | { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
62
112
  | { type: "thinking"; status?: ToolStatus };
@@ -84,8 +134,9 @@ export function formatToolCall(
84
134
  fg: (color: string, text: string) => string,
85
135
  ): string {
86
136
  switch (toolName) {
87
- case "delegate": {
137
+ case "subagent_delegate": {
88
138
  const subRole = args.role as string | undefined;
139
+ // Compact display label — the full tool name is subagent_delegate.
89
140
  return fg("muted", "delegate ") + fg("accent", subRole ?? "...");
90
141
  }
91
142
  case "bash": {
@@ -191,7 +242,127 @@ export function renderDisplayItems(
191
242
  return text.trimEnd();
192
243
  }
193
244
 
194
- export function isFailedResult(r: SubagentResult): boolean {
245
+ // ── Shared result-view composition ─────────────────────────────
246
+ // Used by the foreground delegate row (./render.ts) and the background
247
+ // wait/check rows (./render-async.ts) so every view renders the same
248
+ // outcome the same way.
249
+
250
+ /** Plain-text fallback when details are missing (thrown errors, malformed results). */
251
+ export function contentText(result: { content: Array<{ type: string; text?: string }> }): string {
252
+ const text = result.content[0];
253
+ return text?.type === "text" && text.text ? text.text : "(no output)";
254
+ }
255
+
256
+ /** First line of the task, truncated to one row (the always-visible anchor). */
257
+ export function taskPreview(task: string): string {
258
+ const firstLine = task.split("\n")[0];
259
+ return firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
260
+ }
261
+
262
+ /** Status icon for a run frame: ⏸ queued / ⏳ running / ⏱ timeout / ⏲ budget / ✗ failed / ✓ ok */
263
+ export function runIcon(
264
+ r: { exitCode: number; queued?: boolean; stopReason?: string },
265
+ fg: (color: string, text: string) => string,
266
+ ): string {
267
+ const state = deriveRunState(r);
268
+ if (state === "queued") return fg("warning", "\u23F8");
269
+ if (state === "running") return fg("warning", "\u23F3");
270
+ if (r.stopReason === "timeout") return fg("warning", "\u23F1");
271
+ if (r.stopReason === "budget_exceeded") return fg("warning", "\u23F2");
272
+ if (state === "failed") return fg("error", "\u2717");
273
+ return fg("success", "\u2713");
274
+ }
275
+
276
+ /** Failure result-line content: errorMessage → stop-reason label → "failed". */
277
+ function failureResultText(r: {
278
+ errorMessage?: string;
279
+ stopReason?: string;
280
+ }): { content: string; col: "warning" | "error" } {
281
+ const isTimeout = r.stopReason === "timeout";
282
+ const isBudget = r.stopReason === "budget_exceeded";
283
+ return {
284
+ content: r.errorMessage || (isTimeout ? "Timed out" : isBudget ? "Budget exceeded" : "failed"),
285
+ col: isTimeout || isBudget ? "warning" : "error",
286
+ };
287
+ }
288
+
289
+ /** Success result-line content: AI summary → output first line → placeholder. */
290
+ function successResultText(r: {
291
+ summary?: string;
292
+ output: string;
293
+ }): { content: string; col: "text" | "muted" } {
294
+ const firstLine = r.output.trim().split("\n")[0] ?? "";
295
+ const preview = firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
296
+ const content = r.summary || preview;
297
+ return { content: content || "(no output)", col: content ? "text" : "muted" };
298
+ }
299
+
300
+ /**
301
+ * Composed terminal result line `<icon> <content>`. Budget stops count as the
302
+ * failure presentation (their output is partial and the reason matters), even
303
+ * though the run state itself is "finished". `finishedText` (wait's
304
+ * "finished") replaces the success chain for status-only views.
305
+ */
306
+ export function terminalResultLine(
307
+ r: {
308
+ exitCode: number;
309
+ queued?: boolean;
310
+ stopReason?: string;
311
+ errorMessage?: string;
312
+ summary?: string;
313
+ output: string;
314
+ },
315
+ fg: (color: string, text: string) => string,
316
+ finishedText?: string,
317
+ ): string {
318
+ const icon = runIcon(r, fg);
319
+ if (isFailedResult(r) || r.stopReason === "budget_exceeded") {
320
+ const t = failureResultText(r);
321
+ return `${icon} ${fg(t.col, t.content)}`;
322
+ }
323
+ if (finishedText !== undefined) return `${icon} ${fg("text", finishedText)}`;
324
+ const t = successResultText(r);
325
+ return `${icon} ${fg(t.col, t.content)}`;
326
+ }
327
+
328
+ // ── Render-side elapsed-time animation ─────────────────────────
329
+
330
+ /** Per-row render state slot holding the elapsed-time animation timer. */
331
+ interface ElapsedTimerState {
332
+ elapsedTimer?: ReturnType<typeof setInterval>;
333
+ }
334
+
335
+ /**
336
+ * While a run is live, force a TUI repaint every second so the elapsed time
337
+ * ticks up even when the child process is idle. Uses context.invalidate()
338
+ * (pi's official re-render hook) rather than pushing data via onUpdate — the
339
+ * render recomputes elapsed time fresh from Date.now().
340
+ */
341
+ export function ensureElapsedTimer(context: {
342
+ state: Record<string, unknown>;
343
+ invalidate?: () => void;
344
+ }): void {
345
+ const state = context.state as ElapsedTimerState;
346
+ if (state.elapsedTimer) return;
347
+ if (typeof context.invalidate !== "function") return;
348
+ state.elapsedTimer = setInterval(() => {
349
+ try {
350
+ context.invalidate?.();
351
+ } catch {
352
+ /* ignore — invalidate must never break rendering */
353
+ }
354
+ }, 1000);
355
+ }
356
+
357
+ /** Stop the elapsed-time animation once the run reaches a terminal state. */
358
+ export function clearElapsedTimer(context: { state: Record<string, unknown> }): void {
359
+ const state = context.state as ElapsedTimerState;
360
+ if (!state.elapsedTimer) return;
361
+ clearInterval(state.elapsedTimer);
362
+ state.elapsedTimer = undefined;
363
+ }
364
+
365
+ export function isFailedResult(r: { exitCode: number; stopReason?: string }): boolean {
195
366
  return (
196
367
  r.exitCode !== 0 ||
197
368
  r.stopReason === "error" ||
@@ -205,12 +376,135 @@ export function hasFailedSubagentResult(details: unknown): boolean {
205
376
  return Array.isArray(d?.results) && d.results.some(isFailedResult);
206
377
  }
207
378
 
379
+ /** Provider-error keywords that make a failed run worth retrying on the fallback role. */
380
+ const PROVIDER_ERROR_RE =
381
+ /429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i;
382
+
208
383
  /** Heuristic: does this result look like a provider-side failure worth retrying on the fallback role? */
209
384
  export function isProviderError(result: SubagentResult): boolean {
210
- const haystack = `${result.stderr || ""}\n${result.errorMessage || ""}`;
211
- return /429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i.test(
212
- haystack,
213
- );
385
+ return PROVIDER_ERROR_RE.test(`${result.stderr || ""}\n${result.errorMessage || ""}`);
386
+ }
387
+
388
+ /** Cap for the stderr tail kept in fallback diagnostics. */
389
+ export const FALLBACK_STDERR_TAIL = 400;
390
+
391
+ const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g;
392
+
393
+ /**
394
+ * Best-effort diagnosis from stderr when the child died before any message_end
395
+ * (e.g. 429 on the very first request — model/errorMessage/stopReason all unset).
396
+ * Returns the last stderr line mentioning a provider-error keyword: pi prints
397
+ * the fatal error near exit, so the last match is the most specific.
398
+ */
399
+ export function extractProviderReason(text: string): string | undefined {
400
+ const lines = text
401
+ .replace(ANSI_ESCAPE, "")
402
+ .split("\n")
403
+ .map((l) => l.trim())
404
+ .filter(Boolean);
405
+ for (let i = lines.length - 1; i >= 0; i--) {
406
+ if (PROVIDER_ERROR_RE.test(lines[i])) return lines[i];
407
+ }
408
+ return undefined;
409
+ }
410
+
411
+ /**
412
+ * Snapshot a failed first attempt for fallback observability.
413
+ * The fallback retry overwrites the result, so this snapshot is the only
414
+ * trace of why the first attempt died. stderr is noisy (TUI teardown
415
+ * escape sequences) — only a truncated tail is kept.
416
+ *
417
+ * `requestedModel` fills the model field when the child died before any
418
+ * message_end — the parent always knows what it asked for.
419
+ */
420
+ export function buildFallbackFrom(first: SubagentResult, requestedModel?: string): FallbackFrom {
421
+ const tail = first.stderr.slice(-FALLBACK_STDERR_TAIL).trim();
422
+ return {
423
+ model: first.model ?? requestedModel,
424
+ stopReason: first.stopReason,
425
+ errorMessage: first.errorMessage || extractProviderReason(tail),
426
+ stderrTail: tail || undefined,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * One-line human-readable fallback reason, e.g.
432
+ * `first attempt deepseek-v4-flash failed (Timed out after 900s)`.
433
+ * Prefer errorMessage; fall back to stopReason, else a generic label.
434
+ */
435
+ export function formatFallback(f: FallbackFrom): string {
436
+ const reason = f.errorMessage || f.stopReason || "provider error";
437
+ const firstLine = reason.split("\n")[0];
438
+ const short = firstLine.length > 100 ? `${firstLine.slice(0, 100)}...` : firstLine;
439
+ return `first attempt ${f.model ?? "unknown model"} failed (${short})`;
440
+ }
441
+
442
+ // ── Background runs (delegate background:true / wait / check) ────────────
443
+
444
+ /** Derive the lifecycle state of a run from one of its frames (live or terminal). */
445
+ export function deriveRunState(r: { exitCode: number; queued?: boolean; stopReason?: string }): RunState {
446
+ if (r.exitCode === -1) return r.queued ? "queued" : "running";
447
+ return isFailedResult(r) ? "failed" : "finished";
448
+ }
449
+
450
+ /** True when wait tool result details carries the timeout flag. */
451
+ export function isWaitTimedOut(details: unknown): boolean {
452
+ return (details as WaitDetails | undefined)?.timedOut === true;
453
+ }
454
+
455
+ /** Human-readable description of what a running subagent is doing right now (latest activity item). */
456
+ export function describeCurrentActivity(r: { activityLog: ActivityEntry[] }): string {
457
+ const last = r.activityLog[r.activityLog.length - 1];
458
+ if (!last) return "waiting for first event";
459
+ if (last.kind === "thinking") return last.status === "running" ? "thinking" : "thought";
460
+ return formatToolCall(last.toolName ?? "?", last.args ?? {}, (_color, text) => text);
461
+ }
462
+
463
+ /** Footer appended to terminal results for the main model: `\n\n--- 3 turns ↑12k ↓1k $0.01 model ---` (empty when nothing to show). */
464
+ export function formatUsageFooter(r: { usage: SubagentUsage; model?: string }): string {
465
+ const parts = usageParts(r.usage, r.model, false);
466
+ return parts.length > 0 ? `\n\n--- ${parts.join(" ")} ---` : "";
467
+ }
468
+
469
+ /** Fallback provenance note appended to terminal results (empty when no retry happened). */
470
+ export function formatFallbackNote(r: { fallbackFrom?: FallbackFrom; model?: string }): string {
471
+ return r.fallbackFrom
472
+ ? `\n\n--- fallback: ${formatFallback(r.fallbackFrom)}; retried on ${r.model ?? "fallback role"} ---`
473
+ : "";
474
+ }
475
+
476
+ /**
477
+ * Budget-stop note appended to terminal results (empty unless the run was
478
+ * killed for exceeding its turn/cost budget). Budget stops are intentional
479
+ * successes, but the reader must know the output is partial.
480
+ */
481
+ export function formatBudgetNote(r: { stopReason?: string; errorMessage?: string }): string {
482
+ return r.stopReason === "budget_exceeded"
483
+ ? `\n\n--- ${r.errorMessage || "Budget exceeded"} ---`
484
+ : "";
485
+ }
486
+
487
+ /** LLM-facing text returned by the check tool for one run snapshot. */
488
+ export function formatCheckText(id: string, role: string, r: SubagentResult): string {
489
+ const state = deriveRunState(r);
490
+ const head = `${id} (${role})`;
491
+ if (state === "queued") return `${head}: queued — waiting for a concurrency slot.`;
492
+ if (state === "running") return `${head}: running — ${describeCurrentActivity(r)}`;
493
+ if (state === "failed") {
494
+ return `${head}: failed — ${r.errorMessage || r.stderr || "unknown error"}\n\nPartial output:\n${r.output}${formatFallbackNote(r)}`;
495
+ }
496
+ return `${head}: finished\n\n${r.output}${formatBudgetNote(r)}${formatFallbackNote(r)}${formatUsageFooter(r)}`;
497
+ }
498
+
499
+ /** Freeze a live frame into a static snapshot: stop the elapsed clock and fold the open pause into grace. */
500
+ export function freezeFrame(r: SubagentResult): SubagentResult {
501
+ return {
502
+ ...r,
503
+ startTime: undefined,
504
+ elapsedMs: r.startTime ? Date.now() - r.startTime : r.elapsedMs,
505
+ graceMs: (r.graceMs ?? 0) + (r.pauseStart ? Date.now() - r.pauseStart : 0),
506
+ pauseStart: undefined,
507
+ };
214
508
  }
215
509
 
216
510
  /**
@@ -298,6 +592,36 @@ export class AsyncSemaphore {
298
592
  }
299
593
  }
300
594
 
595
+ // ── Notification throttling ─────────────────────────────────────
596
+
597
+ /**
598
+ * Coalesce bursty notifications into at most one `fire` per
599
+ * {@link PROGRESS_THROTTLE_MS}. `cancel()` drops any pending fire — call it
600
+ * when the owning tool call exits, so a stale update never fires afterwards.
601
+ */
602
+ export function createThrottler(fire: () => void): { notify(): void; cancel(): void } {
603
+ let pending = false;
604
+ let handle: ReturnType<typeof setTimeout> | undefined;
605
+ return {
606
+ notify() {
607
+ pending = true;
608
+ if (handle !== undefined) return;
609
+ handle = setTimeout(() => {
610
+ handle = undefined;
611
+ if (pending) {
612
+ pending = false;
613
+ fire();
614
+ }
615
+ }, PROGRESS_THROTTLE_MS);
616
+ },
617
+ cancel() {
618
+ if (handle !== undefined) clearTimeout(handle);
619
+ handle = undefined;
620
+ pending = false;
621
+ },
622
+ };
623
+ }
624
+
301
625
  // ── Timeout policy ────────────────────────────────────────
302
626
 
303
627
  /**