@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 +13 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/index.ts +87 -86
- package/src/monitor.ts +127 -222
- package/src/spawn.ts +35 -10
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
|
+
"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,
|
|
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 = (
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 = (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 =
|
|
384
|
+
const lines: string[] = [];
|
|
385
|
+
for (const r of runs) {
|
|
367
386
|
const icon = statusIcon(r.status, theme);
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
3
|
+
* for the current turn.
|
|
4
4
|
*
|
|
5
|
-
* The store notifies subscribers on every mutation so the persistent widget
|
|
6
|
-
* the
|
|
7
|
-
* a
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
181
|
+
run.activity = text;
|
|
108
182
|
this.notify();
|
|
109
183
|
}
|
|
110
184
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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: "
|
|
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"
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
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", () =>
|
|
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 => {
|