@fosterg4/pi-subagent 1.0.3 → 1.0.4

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 (3) hide show
  1. package/index.ts +32 -1
  2. package/package.json +1 -1
  3. package/ui.ts +176 -0
package/index.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from "./agents.ts";
37
37
  import { type ValidationResult, validateSchema } from "./validate.ts";
38
38
  import { fmt, usageLine, sumUsage } from "./utils.ts";
39
+ import { AgentWidget, type WidgetEntry } from "./ui.ts";
39
40
 
40
41
  const MAX_PARALLEL_TASKS = 8;
41
42
  const MAX_CONCURRENCY = 4;
@@ -236,6 +237,7 @@ async function runSingleAgent(
236
237
  signal: AbortSignal | undefined,
237
238
  onUpdate: OnUpdateCallback | undefined,
238
239
  makeDetails: (results: SingleResult[]) => SubagentDetails,
240
+ onStats?: (stats: { turns: number; tokens: number }) => void,
239
241
  ): Promise<SingleResult> {
240
242
  const agent = agents.find((a) => a.name === agentName);
241
243
 
@@ -373,6 +375,8 @@ async function runSingleAgent(
373
375
  currentResult.usage.cost += usage.cost?.total || 0;
374
376
  currentResult.usage.contextTokens = usage.totalTokens || 0;
375
377
  }
378
+ const total = currentResult.usage.input + currentResult.usage.output + currentResult.usage.cacheRead;
379
+ onStats?.({ turns: currentResult.usage.turns, tokens: total });
376
380
  if (!currentResult.model && msg.model)
377
381
  currentResult.model = msg.model;
378
382
  if (msg.stopReason) currentResult.stopReason = msg.stopReason;
@@ -640,8 +644,18 @@ export default function (pi: ExtensionAPI) {
640
644
  if (params.chain && params.chain.length > 0) {
641
645
  const results: SingleResult[] = [];
642
646
  let previousStructured: Record<string, unknown> | undefined;
647
+ let widgetHandle: ReturnType<typeof pi.ui.custom> | undefined;
648
+
649
+ const closeWidget = () => {
650
+ if (widgetHandle) {
651
+ widgetHandle.close();
652
+ widgetHandle = undefined;
653
+ }
654
+ };
643
655
 
644
656
  for (let i = 0; i < params.chain.length; i++) {
657
+ closeWidget();
658
+
645
659
  const step = params.chain[i];
646
660
  let taskWithContext = step.task;
647
661
 
@@ -651,13 +665,26 @@ export default function (pi: ExtensionAPI) {
651
665
  /\{previous\}/g,
652
666
  JSON.stringify(previousStructured, null, 2),
653
667
  );
654
- } else {
668
+ } else if (i > 0) {
655
669
  taskWithContext = taskWithContext.replace(
656
670
  /\{previous\}/g,
657
671
  getFinalOutput(results[i - 1]?.messages ?? ""),
658
672
  );
659
673
  }
660
674
 
675
+ // Spawn live widget for this step
676
+ if (ctx.hasUI) {
677
+ const widget = new AgentWidget();
678
+ widget.addAgent(step.agent, step.task.replace(/\{[^}]+\}/g, "").trim());
679
+ widgetHandle = ctx.ui.custom(widget, { overlay: true });
680
+ }
681
+
682
+ const stepStats = (stats: { turns: number; tokens: number }) => {
683
+ if (widgetHandle) {
684
+ widgetHandle.requestRender();
685
+ }
686
+ };
687
+
661
688
  const chainUpdate: OnUpdateCallback | undefined = onUpdate
662
689
  ? (partial) => {
663
690
  const currentResult = partial.details?.results[0];
@@ -681,6 +708,7 @@ export default function (pi: ExtensionAPI) {
681
708
  signal,
682
709
  chainUpdate,
683
710
  makeDetails("chain"),
711
+ stepStats,
684
712
  );
685
713
  results.push(result);
686
714
 
@@ -697,6 +725,7 @@ export default function (pi: ExtensionAPI) {
697
725
  }
698
726
 
699
727
  if (isFailedResult(result)) {
728
+ closeWidget();
700
729
  const errorMsg = getResultOutput(result);
701
730
  return {
702
731
  content: [
@@ -711,6 +740,8 @@ export default function (pi: ExtensionAPI) {
711
740
  }
712
741
  }
713
742
 
743
+ closeWidget();
744
+
714
745
  const lastResult = results[results.length - 1];
715
746
  return {
716
747
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fosterg4/pi-subagent",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Delegate tasks to specialized subagents with isolated context windows, structured JSON handoff, contract schemas, and live TUI streaming",
5
5
  "keywords": [
6
6
  "pi-package",
package/ui.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Claude Code-style live progress widget for subagent execution.
3
+ *
4
+ * Renders a compact overlay showing spinner + agent name + stats.
5
+ * Designed to be used with pi's ctx.ui.custom(…) overlay system.
6
+ *
7
+ * Usage:
8
+ * const widget = new AgentWidget();
9
+ * widget.addAgent("scout", "Find auth files");
10
+ * const handle = ctx.ui.custom(widget, { overlay: true });
11
+ * widget.updateAgent("scout", { turns: 3, tokens: 12400, elapsedMs: 4100 });
12
+ * handle.close();
13
+ */
14
+
15
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
16
+ import { fmt } from "./utils.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export type AgentStatus = "running" | "done" | "error";
23
+
24
+ export interface WidgetEntry {
25
+ name: string;
26
+ task: string;
27
+ status: AgentStatus;
28
+ turns: number;
29
+ tokens: number;
30
+ contextUsagePct?: number;
31
+ elapsedMs: number;
32
+ model?: string;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Spinner
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function formatTime(ms: number): string {
46
+ const s = ms / 1000;
47
+ if (s < 10) return `${s.toFixed(1)}s`;
48
+ if (s < 60) return `${Math.round(s)}s`;
49
+ const m = Math.floor(s / 60);
50
+ const sec = Math.round(s % 60);
51
+ return `${m}m${sec}s`;
52
+ }
53
+
54
+ function statusIcon(status: AgentStatus, frame: number): string {
55
+ switch (status) {
56
+ case "done":
57
+ return "✓";
58
+ case "error":
59
+ return "✗";
60
+ case "running":
61
+ default:
62
+ return SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Widget
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export class AgentWidget {
71
+ private entries: WidgetEntry[] = [];
72
+ private frame = 0;
73
+ private startTimes: Map<string, number> = new Map();
74
+ private cachedWidth?: number;
75
+ private cachedLines?: string[];
76
+
77
+ addAgent(name: string, task: string): void {
78
+ const exists = this.entries.find((e) => e.name === name);
79
+ if (exists) return;
80
+ this.entries.push({
81
+ name,
82
+ task,
83
+ status: "running",
84
+ turns: 0,
85
+ tokens: 0,
86
+ elapsedMs: 0,
87
+ });
88
+ this.startTimes.set(name, Date.now());
89
+ this.invalidate();
90
+ }
91
+
92
+ updateAgent(name: string, update: Partial<WidgetEntry>): void {
93
+ const entry = this.entries.find((e) => e.name === name);
94
+ if (!entry) return;
95
+ Object.assign(entry, update);
96
+ entry.elapsedMs = Date.now() - (this.startTimes.get(name) ?? Date.now());
97
+ this.invalidate();
98
+ }
99
+
100
+ removeAgent(name: string): void {
101
+ this.entries = this.entries.filter((e) => e.name !== name);
102
+ this.startTimes.delete(name);
103
+ this.invalidate();
104
+ }
105
+
106
+ getAgent(name: string): WidgetEntry | undefined {
107
+ return this.entries.find((e) => e.name === name);
108
+ }
109
+
110
+ invalidate(): void {
111
+ this.cachedWidth = undefined;
112
+ this.cachedLines = undefined;
113
+ }
114
+
115
+ render(width: number): string[] {
116
+ if (this.cachedLines && this.cachedWidth === width) {
117
+ return this.cachedLines;
118
+ }
119
+
120
+ const lines: string[] = [];
121
+
122
+ if (this.entries.length === 0) {
123
+ this.cachedWidth = width;
124
+ this.cachedLines = [];
125
+ return [];
126
+ }
127
+
128
+ // Header
129
+ lines.push("● Agents");
130
+
131
+ for (const entry of this.entries) {
132
+ const icon = statusIcon(entry.status, this.frame);
133
+ const parts: string[] = [];
134
+
135
+ // Turns
136
+ if (entry.turns > 0) parts.push(`↻${entry.turns}`);
137
+
138
+ // Tokens
139
+ if (entry.tokens > 0) {
140
+ const tokStr = fmt(entry.tokens);
141
+ parts.push(`${tokStr} token`);
142
+ if (entry.contextUsagePct !== undefined && entry.contextUsagePct > 0) {
143
+ parts.push(`(${entry.contextUsagePct}%)`);
144
+ }
145
+ }
146
+
147
+ // Time
148
+ if (entry.elapsedMs > 0) parts.push(formatTime(entry.elapsedMs));
149
+
150
+ // Model
151
+ if (entry.model) parts.push(entry.model);
152
+
153
+ const stats = parts.length > 0 ? ` · ${parts.join(" · ")}` : "";
154
+
155
+ let line = ` ${icon} ${entry.name}${stats}`;
156
+ if (line.length > width) {
157
+ line = line.slice(0, width - 1) + "…";
158
+ }
159
+ lines.push(line);
160
+
161
+ // Activity sub-line
162
+ if (entry.task && entry.status === "running") {
163
+ const preview =
164
+ entry.task.length > 60
165
+ ? `${entry.task.slice(0, 57)}…`
166
+ : entry.task;
167
+ lines.push(` ⎿ ${preview}`);
168
+ }
169
+ }
170
+
171
+ this.frame++;
172
+ this.cachedWidth = width;
173
+ this.cachedLines = lines;
174
+ return lines;
175
+ }
176
+ }