@fosterg4/pi-subagent 1.0.3 → 1.0.6

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 +40 -1
  2. package/package.json +1 -1
  3. package/ui.ts +174 -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,20 @@ 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 (widgetTimer) { clearInterval(widgetTimer); widgetTimer = undefined; }
651
+ if (widgetHandle) {
652
+ widgetHandle.close();
653
+ widgetHandle = undefined;
654
+ }
655
+ widgetRef = undefined;
656
+ };
643
657
 
644
658
  for (let i = 0; i < params.chain.length; i++) {
659
+ closeWidget();
660
+
645
661
  const step = params.chain[i];
646
662
  let taskWithContext = step.task;
647
663
 
@@ -651,13 +667,32 @@ export default function (pi: ExtensionAPI) {
651
667
  /\{previous\}/g,
652
668
  JSON.stringify(previousStructured, null, 2),
653
669
  );
654
- } else {
670
+ } else if (i > 0) {
655
671
  taskWithContext = taskWithContext.replace(
656
672
  /\{previous\}/g,
657
673
  getFinalOutput(results[i - 1]?.messages ?? ""),
658
674
  );
659
675
  }
660
676
 
677
+ // Spawn live widget for this step
678
+ let widgetRef: AgentWidget | undefined;
679
+ let widgetTimer: ReturnType<typeof setInterval> | undefined;
680
+ if (ctx.hasUI) {
681
+ widgetRef = new AgentWidget();
682
+ widgetRef.addAgent(step.agent, step.task.replace(/\{[^}]+\}/g, "").trim());
683
+ widgetHandle = ctx.ui.custom(
684
+ (_tui, _theme, _kb, done) => {
685
+ widgetTimer = setInterval(() => widgetRef?.invalidate(), 200);
686
+ return widgetRef!;
687
+ },
688
+ { overlay: true },
689
+ );
690
+ }
691
+
692
+ const stepStats = (stats: { turns: number; tokens: number }) => {
693
+ widgetRef?.invalidate();
694
+ };
695
+
661
696
  const chainUpdate: OnUpdateCallback | undefined = onUpdate
662
697
  ? (partial) => {
663
698
  const currentResult = partial.details?.results[0];
@@ -681,6 +716,7 @@ export default function (pi: ExtensionAPI) {
681
716
  signal,
682
717
  chainUpdate,
683
718
  makeDetails("chain"),
719
+ stepStats,
684
720
  );
685
721
  results.push(result);
686
722
 
@@ -697,6 +733,7 @@ export default function (pi: ExtensionAPI) {
697
733
  }
698
734
 
699
735
  if (isFailedResult(result)) {
736
+ closeWidget();
700
737
  const errorMsg = getResultOutput(result);
701
738
  return {
702
739
  content: [
@@ -711,6 +748,8 @@ export default function (pi: ExtensionAPI) {
711
748
  }
712
749
  }
713
750
 
751
+ closeWidget();
752
+
714
753
  const lastResult = results[results.length - 1];
715
754
  return {
716
755
  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.6",
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,174 @@
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 { fmt } from "./utils.ts";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export type AgentStatus = "running" | "done" | "error";
22
+
23
+ export interface WidgetEntry {
24
+ name: string;
25
+ task: string;
26
+ status: AgentStatus;
27
+ turns: number;
28
+ tokens: number;
29
+ contextUsagePct?: number;
30
+ elapsedMs: number;
31
+ model?: string;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Spinner
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function formatTime(ms: number): string {
45
+ const s = ms / 1000;
46
+ if (s < 10) return `${s.toFixed(1)}s`;
47
+ if (s < 60) return `${Math.round(s)}s`;
48
+ const m = Math.floor(s / 60);
49
+ const sec = Math.round(s % 60);
50
+ return `${m}m${sec}s`;
51
+ }
52
+
53
+ function statusIcon(status: AgentStatus, frame: number): string {
54
+ switch (status) {
55
+ case "done":
56
+ return "✓";
57
+ case "error":
58
+ return "✗";
59
+ case "running":
60
+ default:
61
+ return SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Widget
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export class AgentWidget {
70
+ private entries: WidgetEntry[] = [];
71
+ private frame = 0;
72
+ private startTimes: Map<string, number> = new Map();
73
+
74
+ addAgent(name: string, task: string): void {
75
+ const exists = this.entries.find((e) => e.name === name);
76
+ if (exists) return;
77
+ this.entries.push({
78
+ name,
79
+ task,
80
+ status: "running",
81
+ turns: 0,
82
+ tokens: 0,
83
+ elapsedMs: 0,
84
+ });
85
+ this.startTimes.set(name, Date.now());
86
+ this.invalidate();
87
+ }
88
+
89
+ updateAgent(name: string, update: Partial<WidgetEntry>): void {
90
+ const entry = this.entries.find((e) => e.name === name);
91
+ if (!entry) return;
92
+ Object.assign(entry, update);
93
+ this.invalidate();
94
+ }
95
+
96
+ removeAgent(name: string): void {
97
+ this.entries = this.entries.filter((e) => e.name !== name);
98
+ this.startTimes.delete(name);
99
+ this.invalidate();
100
+ }
101
+
102
+ getAgent(name: string): WidgetEntry | undefined {
103
+ return this.entries.find((e) => e.name === name);
104
+ }
105
+
106
+ markDone(name: string): void {
107
+ const entry = this.entries.find((e) => e.name === name);
108
+ if (entry) { entry.status = "done"; }
109
+ this.invalidate();
110
+ }
111
+
112
+ invalidate(): void {
113
+ // No cache to invalidate — render() is always fresh
114
+ }
115
+
116
+ private entryLine(entry: WidgetEntry, width: number): string[] {
117
+ const lines: string[] = [];
118
+ const now = Date.now();
119
+ const elapsedMs = entry.elapsedMs || (now - (this.startTimes.get(entry.name) ?? now));
120
+
121
+ const icon = statusIcon(entry.status, this.frame);
122
+ const parts: string[] = [];
123
+
124
+ // Always show icon + name
125
+ let line = `\u00A0${icon} ${entry.name}`;
126
+
127
+ // Turns
128
+ parts.push(entry.turns > 0 ? `↻${entry.turns}` : "↻0");
129
+
130
+ // Tokens
131
+ if (entry.tokens > 0) {
132
+ const tokStr = fmt(entry.tokens);
133
+ parts.push(`${tokStr} token`);
134
+ if (entry.contextUsagePct !== undefined && entry.contextUsagePct > 0) {
135
+ parts.push(`(${entry.contextUsagePct}%)`);
136
+ }
137
+ }
138
+
139
+ // Time
140
+ if (elapsedMs > 99) parts.push(formatTime(elapsedMs));
141
+ else parts.push("0.0s");
142
+
143
+ // Model
144
+ if (entry.model) parts.push(entry.model);
145
+
146
+ line += ` · ${parts.join(" · ")}`;
147
+ if (line.length > width) line = line.slice(0, width - 3) + "…";
148
+ lines.push(line);
149
+
150
+ // Activity sub-line (running agents only)
151
+ if (entry.task && entry.status === "running") {
152
+ const preview = entry.task.length > 60 ? `${entry.task.slice(0, 57)}…` : entry.task;
153
+ lines.push(` ⎿ ${preview}`);
154
+ }
155
+
156
+ return lines;
157
+ }
158
+
159
+ render(width: number): string[] {
160
+ const lines: string[] = [];
161
+
162
+ if (this.entries.length === 0) return [];
163
+
164
+ // Header
165
+ lines.push("\u25CF Agents");
166
+
167
+ for (const entry of this.entries) {
168
+ lines.push(...this.entryLine(entry, width));
169
+ }
170
+
171
+ this.frame++;
172
+ return lines;
173
+ }
174
+ }