@ferris1225/pi-subagents 0.2.0 → 0.4.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-zh.md CHANGED
@@ -117,6 +117,19 @@ agentModels[name] → 当前 session 模型 → agent frontmatter 里的默
117
117
  { "tasks": [ { "agent": "explore", "task": "..." }, { "agent": "explore", "task": "..." } ] }
118
118
  ```
119
119
 
120
+ ## 实时状态与通知
121
+
122
+ 子代理运行期间,编辑器上方的挂件为每个运行显示一行状态(图标、agent、模型、
123
+ token 用量、耗时),其下缩进一行显示它正在做什么:`thinking`、`writing`、
124
+ `read src/index.ts`、`bash npm test`……(不会是一坨 JSON 参数)。
125
+
126
+ 运行结束(成功**或**失败)时,该行立即从挂件消失,主窗口收到一条通知,
127
+ 给出最终摘要(`✓ worker · openai/gpt-5 · ↑12.4k ↓3.1k · 47s`)。工具结果
128
+ 本身仍是对话里的持久记录。
129
+
130
+ 子代理一律请求**最强思考强度**(`--thinking max`);pi 会按目标模型实际支持
131
+ 的级别自适应降级(`max → xhigh → high → … → off`),弱模型也能平稳运行。
132
+
120
133
  ## 开发
121
134
 
122
135
  ```bash
package/README.md CHANGED
@@ -125,6 +125,22 @@ Tool shape:
125
125
  { "tasks": [ { "agent": "explore", "task": "..." }, { "agent": "explore", "task": "..." } ] }
126
126
  ```
127
127
 
128
+ ## Live status & notifications
129
+
130
+ While sub-agents run, a widget above the editor shows one line per run — status
131
+ icon, agent, model, token usage, elapsed time — plus a second, indented line
132
+ with what the agent is doing right now: `thinking`, `writing`,
133
+ `read src/index.ts`, `bash npm test`, … (never a raw JSON args blob).
134
+
135
+ When a run finishes (done **or** failed), its row disappears from the widget and
136
+ the main window gets a notification with the final summary
137
+ (`✓ worker · openai/gpt-5 · ↑12.4k ↓3.1k · 47s`). The tool result itself
138
+ remains the durable record in the conversation.
139
+
140
+ Sub-agents always request the **strongest thinking level** (`--thinking max`);
141
+ pi clamps it adaptively to what the resolved model supports
142
+ (`max → xhigh → high → … → off`), so weaker models degrade gracefully.
143
+
128
144
  ## Development
129
145
 
130
146
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / plan / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  type SubagentLiveEvent,
39
39
  type UsageStats,
40
40
  } from "./spawn.ts";
41
- import { monitor, openSubagentOverlay, statusIcon } from "./monitor.ts";
41
+ import { formatToolActivity, monitor, statusColor, statusIcon, statusLabel } from "./monitor.ts";
42
42
 
43
43
  const TaskItem = Type.Object({
44
44
  agent: Type.String({ description: "Name of the agent to invoke" }),
@@ -124,6 +124,42 @@ export default function (pi: ExtensionAPI): void {
124
124
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
125
125
  monitor.beginTurn();
126
126
  const config = await loadConfig(configPath);
127
+
128
+ // Finished runs leave the widget immediately; the main window gets a
129
+ // notification instead (the tool result remains the durable record).
130
+ const finishRun = (runId: number, status: "done" | "failed"): void => {
131
+ monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
132
+ const run = monitor.removeRun(runId);
133
+ if (!run) return; // already finished — stay idempotent
134
+ const icon = status === "done" ? "✓" : "✗";
135
+ ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
136
+ };
137
+
138
+ // Live sub-agent activity → concise one-line status ("thinking",
139
+ // "read src/index.ts", ...), never a raw args blob.
140
+ const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
141
+ switch (e.kind) {
142
+ case "status":
143
+ if (e.status === "done" || e.status === "failed") finishRun(runId, e.status);
144
+ else monitor.setStatus(runId, e.status);
145
+ break;
146
+ case "usage":
147
+ monitor.setUsage(runId, e.usage, e.model);
148
+ break;
149
+ case "tool_start":
150
+ monitor.setActivity(runId, formatToolActivity(e.toolName, e.args));
151
+ break;
152
+ case "tool_end":
153
+ if (e.isError) monitor.setActivity(runId, `✗ ${e.toolName} failed`);
154
+ break;
155
+ case "thinking":
156
+ monitor.setActivity(runId, "thinking");
157
+ break;
158
+ case "text":
159
+ monitor.setActivity(runId, "writing");
160
+ break;
161
+ }
162
+ };
127
163
  const discovery = discoverAgents(ctx.cwd, {
128
164
  scope: config.agentScope,
129
165
  enabledNames: config.enabledAgents,
@@ -190,25 +226,7 @@ export default function (pi: ExtensionAPI): void {
190
226
  const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
191
227
  const resolvedModel = agents.find((a) => a.name === t.agent)?.model;
192
228
  const runId = monitor.addRun(t.agent, resolvedModel);
193
- const onLive = (e: SubagentLiveEvent): void => {
194
- switch (e.kind) {
195
- case "status":
196
- monitor.setStatus(runId, e.status);
197
- break;
198
- case "usage":
199
- monitor.setUsage(runId, e.usage, e.model);
200
- break;
201
- case "tool_start":
202
- monitor.appendTranscript(runId, { kind: "tool", text: `▸ ${e.toolName}(${typeof e.args === "object" ? JSON.stringify(e.args).slice(0, 120) : String(e.args).slice(0, 120)})` });
203
- break;
204
- case "tool_end":
205
- monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
206
- break;
207
- case "text_delta":
208
- monitor.appendTextDelta(runId, e.delta);
209
- break;
210
- }
211
- };
229
+ const onLive = makeLiveHandler(runId);
212
230
  const perTaskUpdate: OnUpdateCallback | undefined = onUpdate
213
231
  ? (partial) => {
214
232
  const current = partial.details?.results[0];
@@ -218,17 +236,23 @@ export default function (pi: ExtensionAPI): void {
218
236
  }
219
237
  }
220
238
  : undefined;
221
- const result = await runSingleAgent({
222
- defaultCwd: ctx.cwd,
223
- agent: agents.find((a) => a.name === t.agent),
224
- agentName: t.agent,
225
- task: t.task,
226
- cwd: t.cwd,
227
- signal,
228
- onUpdate: perTaskUpdate,
229
- onLive,
230
- makeDetails: makeDetails("parallel"),
231
- });
239
+ let result: SingleResult;
240
+ try {
241
+ result = await runSingleAgent({
242
+ defaultCwd: ctx.cwd,
243
+ agent: agents.find((a) => a.name === t.agent),
244
+ agentName: t.agent,
245
+ task: t.task,
246
+ cwd: t.cwd,
247
+ signal,
248
+ onUpdate: perTaskUpdate,
249
+ onLive,
250
+ makeDetails: makeDetails("parallel"),
251
+ });
252
+ } catch (err) {
253
+ finishRun(runId, "failed");
254
+ throw err;
255
+ }
232
256
  allResults[index] = result;
233
257
  emitParallelUpdate();
234
258
  return result;
@@ -255,36 +279,24 @@ export default function (pi: ExtensionAPI): void {
255
279
  // ---- Single mode ----
256
280
  const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
257
281
  const runId = monitor.addRun(params.agent as string, resolvedModel);
258
- const onLive = (e: SubagentLiveEvent): void => {
259
- switch (e.kind) {
260
- case "status":
261
- monitor.setStatus(runId, e.status);
262
- break;
263
- case "usage":
264
- monitor.setUsage(runId, e.usage, e.model);
265
- break;
266
- case "tool_start":
267
- monitor.appendTranscript(runId, { kind: "tool", text: `▸ ${e.toolName}(${typeof e.args === "object" ? JSON.stringify(e.args).slice(0, 120) : String(e.args).slice(0, 120)})` });
268
- break;
269
- case "tool_end":
270
- monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
271
- break;
272
- case "text_delta":
273
- monitor.appendTextDelta(runId, e.delta);
274
- break;
275
- }
276
- };
277
- const result = await runSingleAgent({
278
- defaultCwd: ctx.cwd,
279
- agent: agents.find((a) => a.name === params.agent),
280
- agentName: params.agent as string,
281
- task: params.task as string,
282
- cwd: params.cwd,
283
- signal,
284
- onUpdate,
285
- onLive,
286
- makeDetails: makeDetails("single"),
287
- });
282
+ const onLive = makeLiveHandler(runId);
283
+ let result: SingleResult;
284
+ try {
285
+ result = await runSingleAgent({
286
+ defaultCwd: ctx.cwd,
287
+ agent: agents.find((a) => a.name === params.agent),
288
+ agentName: params.agent as string,
289
+ task: params.task as string,
290
+ cwd: params.cwd,
291
+ signal,
292
+ onUpdate,
293
+ onLive,
294
+ makeDetails: makeDetails("single"),
295
+ });
296
+ } catch (err) {
297
+ finishRun(runId, "failed");
298
+ throw err;
299
+ }
288
300
 
289
301
  if (isFailedResult(result)) {
290
302
  return {
@@ -359,20 +371,30 @@ export default function (pi: ExtensionAPI): void {
359
371
  "pi-subagents",
360
372
  (tui, theme) => {
361
373
  const unsub = monitor.subscribe(() => tui.requestRender());
374
+ // Tick once a second so elapsed time stays live while runs are active.
375
+ const timer = setInterval(() => {
376
+ if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
377
+ tui.requestRender();
378
+ }
379
+ }, 1000);
362
380
  return {
363
381
  render(width: number): string[] {
364
382
  const runs = monitor.getRuns();
365
383
  if (runs.length === 0) return [];
366
- const lines = runs.map((r) => {
384
+ const lines: string[] = [];
385
+ for (const r of runs) {
367
386
  const icon = statusIcon(r.status, theme);
368
- return truncateToWidth(` ${icon} ${monitor.summarize(r)}`, width, "");
369
- });
370
- lines.push(truncateToWidth(theme.fg("dim", " ctrl+shift+a / /subagents to inspect"), width, ""));
387
+ const label = theme.fg(statusColor(r.status), statusLabel(r.status));
388
+ lines.push(truncateToWidth(` ${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
389
+ // Activity sits one indent level below the agent name.
390
+ if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
391
+ }
371
392
  return lines;
372
393
  },
373
394
  invalidate() {},
374
395
  dispose() {
375
396
  unsub();
397
+ clearInterval(timer);
376
398
  },
377
399
  };
378
400
  },
@@ -380,27 +402,6 @@ export default function (pi: ExtensionAPI): void {
380
402
  );
381
403
  });
382
404
 
383
- // Drill-down overlay command.
384
- pi.registerCommand("subagents", {
385
- description: "Inspect running/recent sub-agents (model, tokens, live transcript)",
386
- handler: async (_args, ctx) => {
387
- if (ctx.mode !== "tui") {
388
- ctx.ui.notify("The sub-agent monitor requires Pi's interactive TUI.", "warning");
389
- return;
390
- }
391
- await openSubagentOverlay(ctx);
392
- },
393
- });
394
-
395
- // Keyboard shortcut for the overlay.
396
- pi.registerShortcut("ctrl+shift+a", {
397
- description: "Open sub-agent monitor",
398
- handler: async (ctx) => {
399
- if (ctx.mode !== "tui") return;
400
- await openSubagentOverlay(ctx);
401
- },
402
- });
403
-
404
405
  // Proactive dispatch: inject the delegation directive into the parent system prompt.
405
406
  pi.on("before_agent_start", async (event, ctx) => {
406
407
  const config = await loadConfig(configPath);
package/src/monitor.ts CHANGED
@@ -1,20 +1,16 @@
1
1
  /**
2
2
  * Sub-agent monitor: a module-level singleton store that tracks subagent runs
3
- * for the current turn, plus a drill-down overlay component for live inspection.
3
+ * for the current turn.
4
4
  *
5
- * The store notifies subscribers on every mutation so the persistent widget and
6
- * the overlay can re-render. The overlay shows a list of runs (↑/↓ + Enter) and
7
- * a detail view with the live transcript (auto-tailing, scrollable).
5
+ * The store notifies subscribers on every mutation so the persistent widget
6
+ * above the editor can re-render. Each run carries timing information
7
+ * (started/ended) plus a concise activity string describing what the run is
8
+ * doing right now ("thinking", "read src/index.ts", ...). Runs are removed
9
+ * as soon as they finish: the tool result is the durable record in the main
10
+ * conversation, so a stale "done" row must not linger in the widget.
8
11
  */
9
12
 
10
- import {
11
- matchesKey,
12
- truncateToWidth,
13
- type Component,
14
- type Focusable,
15
- type TUI,
16
- } from "@earendil-works/pi-tui";
17
- import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
13
+ import type { Theme } from "@earendil-works/pi-coding-agent";
18
14
  import type { UsageStats } from "./spawn.ts";
19
15
 
20
16
  // ---------------------------------------------------------------------------
@@ -23,18 +19,18 @@ import type { UsageStats } from "./spawn.ts";
23
19
 
24
20
  export type RunStatus = "queued" | "running" | "done" | "failed";
25
21
 
26
- export interface TranscriptLine {
27
- kind: "tool" | "text" | "status" | "error";
28
- text: string;
29
- }
30
-
31
22
  export interface RunView {
32
23
  id: number;
33
24
  agent: string;
34
25
  model?: string;
35
26
  status: RunStatus;
36
27
  usage: UsageStats;
37
- transcript: TranscriptLine[];
28
+ /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
29
+ activity?: string;
30
+ /** Epoch ms when the run started executing (set on first "running" status). */
31
+ startedAt?: number;
32
+ /** Epoch ms when the run finished (set on "done"/"failed"). */
33
+ endedAt?: number;
38
34
  }
39
35
 
40
36
  // ---------------------------------------------------------------------------
@@ -56,6 +52,80 @@ export function formatUsageCompact(usage: UsageStats): string {
56
52
  return parts.join(" ");
57
53
  }
58
54
 
55
+ export function formatDuration(ms: number): string {
56
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
57
+ if (totalSeconds < 60) return `${totalSeconds}s`;
58
+ const minutes = Math.floor(totalSeconds / 60);
59
+ const seconds = totalSeconds % 60;
60
+ if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
61
+ const hours = Math.floor(minutes / 60);
62
+ return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
63
+ }
64
+
65
+ /** Elapsed wall time of a run: live while running, final once finished. */
66
+ export function formatElapsed(run: RunView, now: number = Date.now()): string {
67
+ if (run.startedAt === undefined) return "";
68
+ const end = run.endedAt ?? now;
69
+ return formatDuration(end - run.startedAt);
70
+ }
71
+
72
+ /** Max length of the argument target inside a formatted activity line. */
73
+ export const ACTIVITY_TARGET_MAX = 60;
74
+
75
+ function shortTarget(value: unknown): string {
76
+ if (typeof value !== "string") return "";
77
+ const oneLine = value.replace(/\s+/g, " ").trim();
78
+ // Slice by code point so emoji / CJK-ext never leave a lone surrogate.
79
+ const chars = [...oneLine];
80
+ return chars.length > ACTIVITY_TARGET_MAX ? `${chars.slice(0, ACTIVITY_TARGET_MAX - 1).join("")}…` : oneLine;
81
+ }
82
+
83
+ /** Concise "what is it doing" text for a tool call: the tool name plus its single
84
+ * most telling argument (path, command, pattern, ...) — never a raw JSON blob. */
85
+ export function formatToolActivity(toolName: string, args: unknown): string {
86
+ const a = (typeof args === "object" && args !== null ? args : {}) as Record<string, unknown>;
87
+ const pick = (...keys: string[]): string => {
88
+ for (const key of keys) {
89
+ const s = shortTarget(a[key]);
90
+ if (s) return s;
91
+ }
92
+ return "";
93
+ };
94
+ let target: string;
95
+ switch (toolName) {
96
+ case "bash":
97
+ case "shell":
98
+ target = pick("command");
99
+ break;
100
+ case "read":
101
+ case "edit":
102
+ case "write":
103
+ case "ls":
104
+ target = pick("path", "file", "filePath");
105
+ break;
106
+ case "grep":
107
+ case "find":
108
+ case "glob":
109
+ target = pick("pattern", "query", "path");
110
+ break;
111
+ case "web_search":
112
+ case "search":
113
+ target = pick("query");
114
+ break;
115
+ case "fetch":
116
+ case "web_fetch":
117
+ case "fetch_content":
118
+ target = pick("url");
119
+ break;
120
+ case "subagent":
121
+ target = pick("agent", "task");
122
+ break;
123
+ default:
124
+ target = pick("path", "command", "query", "pattern", "url", "file", "task");
125
+ }
126
+ return target ? `${toolName} ${target}` : toolName;
127
+ }
128
+
59
129
  // ---------------------------------------------------------------------------
60
130
  // MonitorStore
61
131
  // ---------------------------------------------------------------------------
@@ -80,7 +150,6 @@ export class MonitorStore {
80
150
  model,
81
151
  status: "queued",
82
152
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
83
- transcript: [],
84
153
  });
85
154
  this.notify();
86
155
  return id;
@@ -90,9 +159,13 @@ export class MonitorStore {
90
159
  const run = this.find(id);
91
160
  if (!run) return;
92
161
  run.status = status;
162
+ if (status === "running" && run.startedAt === undefined) {
163
+ run.startedAt = Date.now();
164
+ } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
165
+ run.endedAt = Date.now();
166
+ }
93
167
  this.notify();
94
168
  }
95
-
96
169
  setUsage(id: number, usage: UsageStats, model?: string): void {
97
170
  const run = this.find(id);
98
171
  if (!run) return;
@@ -101,29 +174,21 @@ export class MonitorStore {
101
174
  this.notify();
102
175
  }
103
176
 
104
- appendTranscript(id: number, line: TranscriptLine): void {
177
+ /** Update the run's current one-line activity (what it is doing now). */
178
+ setActivity(id: number, text: string): void {
105
179
  const run = this.find(id);
106
180
  if (!run) return;
107
- run.transcript.push(line);
181
+ run.activity = text;
108
182
  this.notify();
109
183
  }
110
184
 
111
- /** Append streamed assistant text, merging consecutive deltas into coherent
112
- * lines (split only on real newlines) instead of one line per token fragment. */
113
- appendTextDelta(id: number, delta: string): void {
114
- const run = this.find(id);
115
- if (!run || delta.length === 0) return;
116
- const segments = delta.split("\n");
117
- for (let i = 0; i < segments.length; i++) {
118
- const seg = segments[i];
119
- const last = run.transcript[run.transcript.length - 1];
120
- if (i === 0 && last && last.kind === "text") {
121
- last.text += seg;
122
- } else {
123
- run.transcript.push({ kind: "text", text: seg });
124
- }
125
- }
185
+ /** Remove a run (finished runs leave the widget). Returns the removed run. */
186
+ removeRun(id: number): RunView | undefined {
187
+ const index = this.runs.findIndex((r) => r.id === id);
188
+ if (index === -1) return undefined;
189
+ const [run] = this.runs.splice(index, 1);
126
190
  this.notify();
191
+ return run;
127
192
  }
128
193
 
129
194
  getRuns(): RunView[] {
@@ -142,7 +207,8 @@ export class MonitorStore {
142
207
  const parts = [run.agent];
143
208
  if (run.model) parts.push(run.model);
144
209
  if (usage) parts.push(usage);
145
- parts.push(run.status);
210
+ const elapsed = formatElapsed(run);
211
+ if (elapsed) parts.push(elapsed);
146
212
  return parts.join(" · ");
147
213
  }
148
214
 
@@ -180,191 +246,30 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
180
246
  }
181
247
  }
182
248
 
183
- // ---------------------------------------------------------------------------
184
- // Overlay component
185
- // ---------------------------------------------------------------------------
186
-
187
- class SubagentOverlay implements Component, Focusable {
188
- private _focused = false;
189
- private mode: "list" | "detail" = "list";
190
- private cursor = 0;
191
- private scroll = 0;
192
- private cachedWidth = -1;
193
- private cachedLines: string[] = [];
194
- private closed = false;
195
-
196
- private readonly unsub: () => void;
197
-
198
- constructor(
199
- private readonly tui: TUI,
200
- private readonly theme: Theme,
201
- private readonly done: (result: void) => void,
202
- ) {
203
- this.unsub = monitor.subscribe(() => {
204
- this.invalidate();
205
- this.tui.requestRender();
206
- });
207
- }
208
-
209
- get focused(): boolean {
210
- return this._focused;
211
- }
212
-
213
- set focused(value: boolean) {
214
- this._focused = value;
215
- }
216
-
217
- invalidate(): void {
218
- this.cachedWidth = -1;
219
- this.cachedLines = [];
220
- }
221
-
222
- handleInput(data: string): void {
223
- if (this.mode === "list") {
224
- const runs = monitor.getRuns();
225
- if (matchesKey(data, "up")) {
226
- if (runs.length > 0) this.cursor = this.cursor === 0 ? runs.length - 1 : this.cursor - 1;
227
- } else if (matchesKey(data, "down")) {
228
- if (runs.length > 0) this.cursor = this.cursor === runs.length - 1 ? 0 : this.cursor + 1;
229
- } else if (matchesKey(data, "return")) {
230
- if (runs.length > 0) {
231
- this.mode = "detail";
232
- this.scrollToBottom();
233
- }
234
- } else if (matchesKey(data, "escape")) {
235
- this.close();
236
- return;
237
- }
238
- } else {
239
- if (matchesKey(data, "up")) {
240
- this.scroll = Math.max(0, this.scroll - 1);
241
- } else if (matchesKey(data, "down")) {
242
- this.scroll++;
243
- } else if (matchesKey(data, "escape")) {
244
- this.mode = "list";
245
- }
246
- }
247
- this.invalidate();
248
- this.tui.requestRender();
249
- }
250
-
251
- render(width: number): string[] {
252
- if (this.cachedWidth === width && this.cachedLines.length > 0) return this.cachedLines;
253
-
254
- const t = this.theme;
255
- const fit = (line: string): string => truncateToWidth(line, width, "");
256
- const border = fit(t.fg("border", "─".repeat(Math.max(1, width))));
257
-
258
- const lines: string[] = [];
259
-
260
- if (this.mode === "list") {
261
- lines.push(border);
262
- lines.push(fit(t.fg("accent", t.bold(" Sub-agents"))));
263
- lines.push(border);
264
-
265
- const runs = monitor.getRuns();
266
- if (runs.length === 0) {
267
- lines.push(fit(t.fg("dim", " (no sub-agent runs this turn)")));
268
- } else {
269
- this.cursor = Math.max(0, Math.min(this.cursor, runs.length - 1));
270
- for (let i = 0; i < runs.length; i++) {
271
- const run = runs[i];
272
- const isCursor = i === this.cursor;
273
- const mark = isCursor ? t.fg("accent", "❯ ") : " ";
274
- const icon = statusIcon(run.status, t);
275
- const usage = formatUsageCompact(run.usage);
276
- const parts = [run.agent];
277
- if (run.model) parts.push(run.model);
278
- if (usage) parts.push(usage);
279
- parts.push(run.status);
280
- const label = isCursor ? t.fg("accent", t.bold(parts.join(" · "))) : parts.join(" · ");
281
- lines.push(fit(`${mark}${icon} ${label}`));
282
- }
283
- }
284
-
285
- lines.push(border);
286
- lines.push(fit(t.fg("dim", " ↑↓ select · enter open · esc close")));
287
- lines.push(border);
288
- } else {
289
- const runs = monitor.getRuns();
290
- const run = runs[this.cursor];
291
-
292
- lines.push(border);
293
- if (run) {
294
- const headerParts = [run.agent];
295
- if (run.model) headerParts.push(run.model);
296
- lines.push(fit(t.fg("accent", t.bold(` ${headerParts.join(" · ")}`))));
297
- const usage = formatUsageCompact(run.usage);
298
- const statusLine = ` ${statusIcon(run.status, t)} ${run.status}${usage ? ` · ${usage}` : ""}`;
299
- lines.push(fit(statusLine));
300
- } else {
301
- lines.push(fit(t.fg("dim", " (no run selected)")));
302
- }
303
- lines.push(border);
304
-
305
- if (run) {
306
- // Available height for transcript: total minus header(4) + footer(2)
307
- const transcriptLines = run.transcript;
308
- const availHeight = Math.max(1, 40 - 6); // reasonable default; actual height varies
309
- this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, transcriptLines.length - availHeight)));
310
-
311
- // Auto-tail: if scroll is at the bottom, keep it there
312
- const maxScroll = Math.max(0, transcriptLines.length - availHeight);
313
- if (this.scroll >= maxScroll - 1) this.scroll = maxScroll;
314
-
315
- const visible = transcriptLines.slice(this.scroll, this.scroll + availHeight);
316
- for (const entry of visible) {
317
- const color =
318
- entry.kind === "tool"
319
- ? "accent"
320
- : entry.kind === "error"
321
- ? "error"
322
- : entry.kind === "status"
323
- ? "dim"
324
- : "text";
325
- lines.push(fit(t.fg(color, ` ${entry.text}`)));
326
- }
327
- if (transcriptLines.length === 0) {
328
- lines.push(fit(t.fg("dim", " (waiting for output…)")));
329
- }
330
- }
331
-
332
- lines.push(border);
333
- lines.push(fit(t.fg("dim", " ↑↓ scroll · esc back")));
334
- lines.push(border);
335
- }
336
-
337
- this.cachedWidth = width;
338
- this.cachedLines = lines;
339
- return lines;
340
- }
341
-
342
- dispose(): void {
343
- if (!this.closed) {
344
- this.closed = true;
345
- this.unsub();
346
- }
347
- }
348
-
349
- private scrollToBottom(): void {
350
- const runs = monitor.getRuns();
351
- const run = runs[this.cursor];
352
- if (run) this.scroll = Math.max(0, run.transcript.length);
353
- }
354
-
355
- private close(): void {
356
- this.closed = true;
357
- this.unsub();
358
- this.done();
249
+ /** User-facing status label shown in the widget. */
250
+ export function statusLabel(status: RunStatus): string {
251
+ switch (status) {
252
+ case "queued":
253
+ return "ready";
254
+ case "running":
255
+ return "running";
256
+ case "done":
257
+ return "done";
258
+ case "failed":
259
+ return "stopped";
359
260
  }
360
261
  }
361
262
 
362
- // ---------------------------------------------------------------------------
363
- // Public entry point
364
- // ---------------------------------------------------------------------------
365
-
366
- export async function openSubagentOverlay(ctx: ExtensionContext): Promise<void> {
367
- await ctx.ui.custom<void>((tui, theme, _kb, done) => new SubagentOverlay(tui, theme, done), {
368
- overlay: true,
369
- });
263
+ /** Theme color matching the status label. */
264
+ export function statusColor(status: RunStatus): "accent" | "success" | "error" | "dim" {
265
+ switch (status) {
266
+ case "running":
267
+ return "accent";
268
+ case "done":
269
+ return "success";
270
+ case "failed":
271
+ return "error";
272
+ default:
273
+ return "dim";
274
+ }
370
275
  }
package/src/spawn.ts CHANGED
@@ -20,6 +20,10 @@ import type { AgentConfig, AgentSource } from "./agents.ts";
20
20
 
21
21
  export const MAX_PARALLEL_TASKS = 8;
22
22
  export const MAX_CONCURRENCY = 4;
23
+ /** Thinking level requested for every sub-agent: the strongest pi offers. pi's
24
+ * session layer clamps it adaptively to what the resolved model supports
25
+ * (max → xhigh → high → … → off), so weaker models degrade gracefully. */
26
+ export const SUBAGENT_THINKING_LEVEL = "max";
23
27
  /** Max nesting depth for sub-agent -> sub-agent spawning (recursion guard). */
24
28
  export const MAX_SUBAGENT_DEPTH = 2;
25
29
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
@@ -59,7 +63,8 @@ export type SubagentLiveEvent =
59
63
  | { kind: "usage"; usage: UsageStats; model?: string }
60
64
  | { kind: "tool_start"; toolName: string; args: unknown }
61
65
  | { kind: "tool_end"; toolName: string; isError: boolean }
62
- | { kind: "text_delta"; delta: string };
66
+ | { kind: "thinking" }
67
+ | { kind: "text" };
63
68
 
64
69
  function emptyUsage(): UsageStats {
65
70
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
@@ -168,6 +173,8 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
168
173
 
169
174
  const args: string[] = ["--mode", "json", "-p", "--no-session"];
170
175
  if (agent.model) args.push("--model", agent.model);
176
+ // Strongest thinking by default; clamped adaptively per model by pi.
177
+ args.push("--thinking", SUBAGENT_THINKING_LEVEL);
171
178
  if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
172
179
 
173
180
  let tmpPromptDir: string | null = null;
@@ -237,12 +244,15 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
237
244
  }
238
245
  }
239
246
 
240
- // Live event: streamed assistant text
241
- if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
242
- if (onLive) {
243
- try {
244
- onLive({ kind: "text_delta", delta: event.assistantMessageEvent.delta ?? "" });
245
- } catch { /* never throw from event handling */ }
247
+ // Live event: streamed assistant reasoning / output text
248
+ if (event.type === "message_update") {
249
+ const t = event.assistantMessageEvent?.type;
250
+ if (t === "thinking_delta" || t === "text_delta") {
251
+ if (onLive) {
252
+ try {
253
+ onLive({ kind: t === "thinking_delta" ? "thinking" : "text" });
254
+ } catch { /* never throw from event handling */ }
255
+ }
246
256
  }
247
257
  }
248
258
 
@@ -309,17 +319,32 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
309
319
 
310
320
  proc.on("close", (code) => {
311
321
  if (buffer.trim()) processLine(buffer);
312
- // Live event: final status derived from exit code
322
+ // Live event: final status derived from exit code / abort state.
323
+ // code is null on signal termination (e.g. our own Esc abort), which
324
+ // must read as failure — never as a false "done".
313
325
  if (onLive) {
314
326
  try {
315
- const failed = (code ?? 0) !== 0 || currentResult.stopReason === "error" || currentResult.stopReason === "aborted";
327
+ const failed =
328
+ code !== 0 ||
329
+ wasAborted ||
330
+ (signal?.aborted ?? false) ||
331
+ currentResult.stopReason === "error" ||
332
+ currentResult.stopReason === "aborted";
316
333
  onLive({ kind: "status", status: failed ? "failed" : "done" });
317
334
  } catch { /* never throw from event handling */ }
318
335
  }
319
336
  resolve(code ?? 0);
320
337
  });
321
338
 
322
- proc.on("error", () => resolve(1));
339
+ proc.on("error", () => {
340
+ // Spawn itself failed; close may never fire, so finish the run here.
341
+ if (onLive) {
342
+ try {
343
+ onLive({ kind: "status", status: "failed" });
344
+ } catch { /* never throw from event handling */ }
345
+ }
346
+ resolve(1);
347
+ });
323
348
 
324
349
  if (signal) {
325
350
  const killProc = (): void => {