@ferris1225/pi-subagents 0.3.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.3.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, statusColor, statusIcon, statusLabel } 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];
@@ -232,7 +250,7 @@ export default function (pi: ExtensionAPI): void {
232
250
  makeDetails: makeDetails("parallel"),
233
251
  });
234
252
  } catch (err) {
235
- monitor.setStatus(runId, "failed");
253
+ finishRun(runId, "failed");
236
254
  throw err;
237
255
  }
238
256
  allResults[index] = result;
@@ -261,25 +279,7 @@ export default function (pi: ExtensionAPI): void {
261
279
  // ---- Single mode ----
262
280
  const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
263
281
  const runId = monitor.addRun(params.agent as string, resolvedModel);
264
- const onLive = (e: SubagentLiveEvent): void => {
265
- switch (e.kind) {
266
- case "status":
267
- monitor.setStatus(runId, e.status);
268
- break;
269
- case "usage":
270
- monitor.setUsage(runId, e.usage, e.model);
271
- break;
272
- case "tool_start":
273
- 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)})` });
274
- break;
275
- case "tool_end":
276
- monitor.appendTranscript(runId, { kind: e.isError ? "error" : "status", text: `${e.isError ? "✗" : "✓"} ${e.toolName}` });
277
- break;
278
- case "text_delta":
279
- monitor.appendTextDelta(runId, e.delta);
280
- break;
281
- }
282
- };
282
+ const onLive = makeLiveHandler(runId);
283
283
  let result: SingleResult;
284
284
  try {
285
285
  result = await runSingleAgent({
@@ -294,7 +294,7 @@ export default function (pi: ExtensionAPI): void {
294
294
  makeDetails: makeDetails("single"),
295
295
  });
296
296
  } catch (err) {
297
- monitor.setStatus(runId, "failed");
297
+ finishRun(runId, "failed");
298
298
  throw err;
299
299
  }
300
300
 
@@ -386,8 +386,8 @@ export default function (pi: ExtensionAPI): void {
386
386
  const icon = statusIcon(r.status, theme);
387
387
  const label = theme.fg(statusColor(r.status), statusLabel(r.status));
388
388
  lines.push(truncateToWidth(` ${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
389
- const activity = monitor.lastActivity(r);
390
- if (activity) lines.push(truncateToWidth(theme.fg("dim", ` ${activity}`), 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
391
  }
392
392
  return lines;
393
393
  },
package/src/monitor.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  *
5
5
  * The store notifies subscribers on every mutation so the persistent widget
6
6
  * above the editor can re-render. Each run carries timing information
7
- * (started/ended) and a transcript whose most recent entry is surfaced as the
8
- * run's current activity.
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.
9
11
  */
10
12
 
11
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
@@ -17,18 +19,14 @@ import type { UsageStats } from "./spawn.ts";
17
19
 
18
20
  export type RunStatus = "queued" | "running" | "done" | "failed";
19
21
 
20
- export interface TranscriptLine {
21
- kind: "tool" | "text" | "status" | "error";
22
- text: string;
23
- }
24
-
25
22
  export interface RunView {
26
23
  id: number;
27
24
  agent: string;
28
25
  model?: string;
29
26
  status: RunStatus;
30
27
  usage: UsageStats;
31
- transcript: TranscriptLine[];
28
+ /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
29
+ activity?: string;
32
30
  /** Epoch ms when the run started executing (set on first "running" status). */
33
31
  startedAt?: number;
34
32
  /** Epoch ms when the run finished (set on "done"/"failed"). */
@@ -71,6 +69,63 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
71
69
  return formatDuration(end - run.startedAt);
72
70
  }
73
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
+
74
129
  // ---------------------------------------------------------------------------
75
130
  // MonitorStore
76
131
  // ---------------------------------------------------------------------------
@@ -95,7 +150,6 @@ export class MonitorStore {
95
150
  model,
96
151
  status: "queued",
97
152
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
98
- transcript: [],
99
153
  });
100
154
  this.notify();
101
155
  return id;
@@ -120,29 +174,21 @@ export class MonitorStore {
120
174
  this.notify();
121
175
  }
122
176
 
123
- 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 {
124
179
  const run = this.find(id);
125
180
  if (!run) return;
126
- run.transcript.push(line);
181
+ run.activity = text;
127
182
  this.notify();
128
183
  }
129
184
 
130
- /** Append streamed assistant text, merging consecutive deltas into coherent
131
- * lines (split only on real newlines) instead of one line per token fragment. */
132
- appendTextDelta(id: number, delta: string): void {
133
- const run = this.find(id);
134
- if (!run || delta.length === 0) return;
135
- const segments = delta.split("\n");
136
- for (let i = 0; i < segments.length; i++) {
137
- const seg = segments[i];
138
- const last = run.transcript[run.transcript.length - 1];
139
- if (i === 0 && last && last.kind === "text") {
140
- last.text += seg;
141
- } else {
142
- run.transcript.push({ kind: "text", text: seg });
143
- }
144
- }
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);
145
190
  this.notify();
191
+ return run;
146
192
  }
147
193
 
148
194
  getRuns(): RunView[] {
@@ -166,15 +212,6 @@ export class MonitorStore {
166
212
  return parts.join(" · ");
167
213
  }
168
214
 
169
- /** Text of the most recent transcript entry (what the run is doing now). */
170
- lastActivity(run: RunView): string | undefined {
171
- for (let i = run.transcript.length - 1; i >= 0; i--) {
172
- const text = run.transcript[i].text.trim();
173
- if (text.length > 0) return text;
174
- }
175
- return undefined;
176
- }
177
-
178
215
  private find(id: number): RunView | undefined {
179
216
  return this.runs.find((r) => r.id === id);
180
217
  }
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 => {