@fosterg4/pi-subagent 1.0.2 → 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 (4) hide show
  1. package/index.ts +68 -11
  2. package/package.json +1 -1
  3. package/ui.ts +176 -0
  4. package/utils.ts +37 -0
package/index.ts CHANGED
@@ -35,12 +35,16 @@ import {
35
35
  formatAgentList,
36
36
  } from "./agents.ts";
37
37
  import { type ValidationResult, validateSchema } from "./validate.ts";
38
+ import { fmt, usageLine, sumUsage } from "./utils.ts";
39
+ import { AgentWidget, type WidgetEntry } from "./ui.ts";
38
40
 
39
41
  const MAX_PARALLEL_TASKS = 8;
40
42
  const MAX_CONCURRENCY = 4;
41
43
  const PER_TASK_OUTPUT_CAP = 50 * 1024;
42
44
 
45
+ /* eslint-disable @typescript-eslint/no-unused-vars */
43
46
 
47
+ interface UsageStats {
44
48
  input: number;
45
49
  output: number;
46
50
  cacheRead: number;
@@ -233,6 +237,7 @@ async function runSingleAgent(
233
237
  signal: AbortSignal | undefined,
234
238
  onUpdate: OnUpdateCallback | undefined,
235
239
  makeDetails: (results: SingleResult[]) => SubagentDetails,
240
+ onStats?: (stats: { turns: number; tokens: number }) => void,
236
241
  ): Promise<SingleResult> {
237
242
  const agent = agents.find((a) => a.name === agentName);
238
243
 
@@ -370,6 +375,8 @@ async function runSingleAgent(
370
375
  currentResult.usage.cost += usage.cost?.total || 0;
371
376
  currentResult.usage.contextTokens = usage.totalTokens || 0;
372
377
  }
378
+ const total = currentResult.usage.input + currentResult.usage.output + currentResult.usage.cacheRead;
379
+ onStats?.({ turns: currentResult.usage.turns, tokens: total });
373
380
  if (!currentResult.model && msg.model)
374
381
  currentResult.model = msg.model;
375
382
  if (msg.stopReason) currentResult.stopReason = msg.stopReason;
@@ -637,8 +644,18 @@ export default function (pi: ExtensionAPI) {
637
644
  if (params.chain && params.chain.length > 0) {
638
645
  const results: SingleResult[] = [];
639
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
+ };
640
655
 
641
656
  for (let i = 0; i < params.chain.length; i++) {
657
+ closeWidget();
658
+
642
659
  const step = params.chain[i];
643
660
  let taskWithContext = step.task;
644
661
 
@@ -648,13 +665,26 @@ export default function (pi: ExtensionAPI) {
648
665
  /\{previous\}/g,
649
666
  JSON.stringify(previousStructured, null, 2),
650
667
  );
651
- } else {
668
+ } else if (i > 0) {
652
669
  taskWithContext = taskWithContext.replace(
653
670
  /\{previous\}/g,
654
671
  getFinalOutput(results[i - 1]?.messages ?? ""),
655
672
  );
656
673
  }
657
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
+
658
688
  const chainUpdate: OnUpdateCallback | undefined = onUpdate
659
689
  ? (partial) => {
660
690
  const currentResult = partial.details?.results[0];
@@ -678,6 +708,7 @@ export default function (pi: ExtensionAPI) {
678
708
  signal,
679
709
  chainUpdate,
680
710
  makeDetails("chain"),
711
+ stepStats,
681
712
  );
682
713
  results.push(result);
683
714
 
@@ -694,6 +725,7 @@ export default function (pi: ExtensionAPI) {
694
725
  }
695
726
 
696
727
  if (isFailedResult(result)) {
728
+ closeWidget();
697
729
  const errorMsg = getResultOutput(result);
698
730
  return {
699
731
  content: [
@@ -708,6 +740,8 @@ export default function (pi: ExtensionAPI) {
708
740
  }
709
741
  }
710
742
 
743
+ closeWidget();
744
+
711
745
  const lastResult = results[results.length - 1];
712
746
  return {
713
747
  content: [
@@ -954,11 +988,13 @@ export default function (pi: ExtensionAPI) {
954
988
  return theme.fg("error", "\u2717");
955
989
  };
956
990
 
957
- // --- Single mode ---
991
+ // --- Single mode ---
958
992
  if (details.mode === "single" && details.results.length === 1) {
959
993
  const r = details.results[0];
960
994
  const status = getStatusText(r);
961
995
  const finalOutput = getFinalOutputText(r.messages);
996
+ const usage = usageLine(r.usage);
997
+ const usg = usage ? theme.fg("dim", usage) : "";
962
998
 
963
999
  if (expanded) {
964
1000
  const container = new Container();
@@ -969,6 +1005,10 @@ export default function (pi: ExtensionAPI) {
969
1005
  container.addChild(new Spacer(1));
970
1006
  container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
971
1007
  }
1008
+ if (usg) {
1009
+ container.addChild(new Spacer(1));
1010
+ container.addChild(new Text(usg, 0, 0));
1011
+ }
972
1012
  return container;
973
1013
  }
974
1014
 
@@ -981,6 +1021,7 @@ export default function (pi: ExtensionAPI) {
981
1021
  } else {
982
1022
  text += `\n${theme.fg("muted", "(no output)")}`;
983
1023
  }
1024
+ if (usg) text += `\n${usg}`;
984
1025
  return new Text(text, 0, 0);
985
1026
  }
986
1027
 
@@ -991,6 +1032,8 @@ export default function (pi: ExtensionAPI) {
991
1032
  const allOk = details.results.every((r) => r.exitCode === 0);
992
1033
  const icon = allOk ? theme.fg("success", "\u2713") : theme.fg("error", "\u2717");
993
1034
  const steps = details.results.map((r) => r.agent).join(" \u2192 ");
1035
+ const total = sumUsage(details.results);
1036
+ const totalUsg = usageLine(total);
994
1037
 
995
1038
  if (expanded) {
996
1039
  const container = new Container();
@@ -999,18 +1042,19 @@ export default function (pi: ExtensionAPI) {
999
1042
  );
1000
1043
  for (const r of details.results) {
1001
1044
  const out = getFinalOutputText(r.messages);
1045
+ const stepUsage = usageLine(r.usage);
1046
+ const stepUsg = stepUsage ? theme.fg("dim", stepUsage) : "";
1047
+ const label = `${getStatusText(r)} ${theme.fg("accent", r.agent)}${r.model ? theme.fg("muted", ` \u00B7 ${r.model}`) : ""}${stepUsg ? " " + stepUsg : ""}`;
1002
1048
  if (out) {
1003
1049
  container.addChild(new Spacer(1));
1004
- container.addChild(
1005
- new Text(
1006
- `${getStatusText(r)} ${theme.fg("accent", r.agent)}${r.model ? theme.fg("muted", ` \u00B7 ${r.model}`) : ""}`,
1007
- 0,
1008
- 0,
1009
- ),
1010
- );
1050
+ container.addChild(new Text(label, 0, 0));
1011
1051
  container.addChild(new Markdown(out.trim(), 0, 0, mdTheme));
1012
1052
  }
1013
1053
  }
1054
+ if (totalUsg && details.results.length > 1) {
1055
+ container.addChild(new Spacer(1));
1056
+ container.addChild(new Text(totalUsg, 0, 0));
1057
+ }
1014
1058
  return container;
1015
1059
  }
1016
1060
 
@@ -1023,6 +1067,7 @@ export default function (pi: ExtensionAPI) {
1023
1067
  } else {
1024
1068
  text += `\n${theme.fg("muted", "(no output)")}`;
1025
1069
  }
1070
+ if (totalUsg) text += `\n${totalUsg}`;
1026
1071
  return new Text(text, 0, 0);
1027
1072
  }
1028
1073
 
@@ -1039,6 +1084,8 @@ export default function (pi: ExtensionAPI) {
1039
1084
 
1040
1085
  if (expanded && running === 0) {
1041
1086
  const container = new Container();
1087
+ const total = sumUsage(details.results);
1088
+ const totalUsg = usageLine(total);
1042
1089
  container.addChild(
1043
1090
  new Text(
1044
1091
  `${icon} ${theme.fg("accent", `${done} tasks`)}`,
@@ -1048,11 +1095,13 @@ export default function (pi: ExtensionAPI) {
1048
1095
  );
1049
1096
  for (const r of details.results) {
1050
1097
  const out = getFinalOutputText(r.messages);
1098
+ const stepUsage = usageLine(r.usage);
1099
+ const stepUsg = stepUsage ? theme.fg("dim", stepUsage) : "";
1051
1100
  if (out) {
1052
1101
  container.addChild(new Spacer(1));
1053
1102
  container.addChild(
1054
1103
  new Text(
1055
- `${getStatusText(r)} ${theme.fg("accent", r.agent)}`,
1104
+ `${getStatusText(r)} ${theme.fg("accent", r.agent)}${stepUsg ? " " + stepUsg : ""}`,
1056
1105
  0,
1057
1106
  0,
1058
1107
  ),
@@ -1060,11 +1109,19 @@ export default function (pi: ExtensionAPI) {
1060
1109
  container.addChild(new Markdown(out.trim(), 0, 0, mdTheme));
1061
1110
  }
1062
1111
  }
1112
+ if (totalUsg && details.results.length > 1) {
1113
+ container.addChild(new Spacer(1));
1114
+ container.addChild(new Text(totalUsg, 0, 0));
1115
+ }
1063
1116
  return container;
1064
1117
  }
1065
1118
 
1119
+ const usg = usageLine(sumUsage(details.results));
1066
1120
  let text = `${icon} ${theme.fg("accent", `${done}/${details.results.length} tasks`)}`;
1067
- if (running === 0 && !expanded) text += theme.fg("muted", " (Ctrl+O to expand)");
1121
+ if (running === 0) {
1122
+ if (usg) text += `\n${usg}`;
1123
+ if (!expanded) text += theme.fg("muted", " (Ctrl+O to expand)");
1124
+ }
1068
1125
  return new Text(text, 0, 0);
1069
1126
  }
1070
1127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fosterg4/pi-subagent",
3
- "version": "1.0.2",
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
+ }
package/utils.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared formatting helpers — testable without pi runtime deps
3
+ */
4
+
5
+ import type { UsageStats } from "./index.ts";
6
+
7
+ export function fmt(n: number): string {
8
+ if (n < 1000) return n.toString();
9
+ if (n < 10000) return (n / 1000).toFixed(1) + "k";
10
+ if (n < 1000000) return Math.round(n / 1000) + "k";
11
+ return (n / 1000000).toFixed(1) + "M";
12
+ }
13
+
14
+ export function usageLine(u: UsageStats): string {
15
+ const parts: string[] = [];
16
+ if (u.input) parts.push("\u2191" + fmt(u.input));
17
+ if (u.output) parts.push("\u2193" + fmt(u.output));
18
+ if (u.cacheRead) {
19
+ parts.push("R" + fmt(u.cacheRead));
20
+ const total = u.input + u.cacheRead;
21
+ if (total > 0) parts.push("CH" + ((u.cacheRead / total) * 100).toFixed(1) + "%");
22
+ }
23
+ if (u.cost) parts.push("$" + u.cost.toFixed(4));
24
+ return parts.join(" ");
25
+ }
26
+
27
+ export function sumUsage(results: ReadonlyArray<{ usage: UsageStats }>): UsageStats {
28
+ const u: UsageStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
29
+ for (const r of results) {
30
+ u.input += r.usage.input;
31
+ u.output += r.usage.output;
32
+ u.cacheRead += r.usage.cacheRead;
33
+ u.cacheWrite += r.usage.cacheWrite;
34
+ u.cost += r.usage.cost;
35
+ }
36
+ return u;
37
+ }