@arhen/pi-core-subagent 1.1.14 → 1.1.16
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.md +15 -1
- package/package.json +1 -1
- package/src/index.ts +65 -27
- package/src/peek.ts +27 -4
package/README.md
CHANGED
|
@@ -8,6 +8,10 @@ Minimalist pi extension: **fast in-process subagents** with single / parallel /
|
|
|
8
8
|
|
|
9
9
|
Built for one job: delegate work to isolated subagents **without bloating the parent context**.
|
|
10
10
|
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
*The `subagent` tool call plus the live above-editor widget: per-agent activity, tool counts, turns, token counters and timers.*
|
|
14
|
+
|
|
11
15
|
## Design principles
|
|
12
16
|
|
|
13
17
|
- **No agent files, no discovery.** The leader defines every subagent inline per call — name, system prompt, toolset. Nothing is read from or written to disk.
|
|
@@ -15,7 +19,8 @@ Built for one job: delegate work to isolated subagents **without bloating the pa
|
|
|
15
19
|
- **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
|
|
16
20
|
- **Zero parent-context injection.** No catalog, no context hook. 6 slim tools total.
|
|
17
21
|
- **Throttled updates** — widget/stream updates coalesce to ~6/s; no per-event deep clones.
|
|
18
|
-
- **No silent hangs** — watchdog aborts children that produce no events for 3 minutes
|
|
22
|
+
- **No silent hangs** — watchdog aborts children that produce no events for 3 minutes.
|
|
23
|
+
- **No default runtime cap** — tasks run until done, stalled (watchdog), or aborted by the user. `maxRuntimeMs` is opt-in (default 0 = unlimited).
|
|
19
24
|
|
|
20
25
|
## Install
|
|
21
26
|
|
|
@@ -95,6 +100,15 @@ Background + intercom:
|
|
|
95
100
|
| `send_agent_message` | message to a sibling subagent's mailbox (`to` = its task id, or `"leader"`) |
|
|
96
101
|
| `poll_agent_messages` | drain this subagent's mailbox |
|
|
97
102
|
|
|
103
|
+
## Peek — `/peek` or `ctrl+shift+s`
|
|
104
|
+
|
|
105
|
+
Read-only pane over the session's subagents:
|
|
106
|
+
|
|
107
|
+
- `↑`/`↓` — move between agents
|
|
108
|
+
- `enter` — live tail of that child's session file (`esc` goes back)
|
|
109
|
+
- `x` then `y` — abort ONE subagent (only mutation; `n`/any other key cancels)
|
|
110
|
+
- `esc` — close
|
|
111
|
+
|
|
98
112
|
## Context budget
|
|
99
113
|
|
|
100
114
|
- Parent tools: 6 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with single/parallel/chain, background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,8 @@ import { createPeekPane, type PeekTask } from "./peek.ts";
|
|
|
26
26
|
const DEFAULT_CONCURRENCY = 3;
|
|
27
27
|
const MAX_CONCURRENCY = 8;
|
|
28
28
|
const MAX_TASKS = 16;
|
|
29
|
-
|
|
29
|
+
/** No default wall-clock cap: a subagent runs until its task is done, it stalls, or the user aborts. */
|
|
30
|
+
const DEFAULT_RUNTIME_MS = 0;
|
|
30
31
|
const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit message_update, not message_end
|
|
31
32
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
32
33
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
@@ -225,6 +226,21 @@ function taskStatsWithUsage(task: TaskSnapshot): string {
|
|
|
225
226
|
function taskLine(task: TaskSnapshot): string {
|
|
226
227
|
return `${statusIcon(task.status)} ${task.agent} · ${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
|
|
227
228
|
}
|
|
229
|
+
/** Numbers get the theme's number color, same as the footer's token counters. */
|
|
230
|
+
function colorNums(text: string, theme: Theme): string {
|
|
231
|
+
return text.replace(/\d+(?:\.\d+)?/g, (n) => theme.fg("syntaxNumber", n));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Themed one-liner. Finished tasks dim entirely (stats included); live tasks
|
|
235
|
+
* keep the agent name readable with themed numbers.
|
|
236
|
+
*/
|
|
237
|
+
function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
|
|
238
|
+
const tail = `${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
|
|
239
|
+
if (TERMINAL.includes(task.status)) {
|
|
240
|
+
return theme.fg("dim", `${statusIcon(task.status)} ${task.agent} · ${tail}`);
|
|
241
|
+
}
|
|
242
|
+
return `${statusIcon(task.status)} ${task.agent} · ${activity}${colorNums(theme.fg("muted", tail), theme)}`;
|
|
243
|
+
}
|
|
228
244
|
function argsSuffix(args: unknown): string {
|
|
229
245
|
try {
|
|
230
246
|
const s = JSON.stringify(args);
|
|
@@ -280,19 +296,12 @@ class SubagentsWidget implements Component {
|
|
|
280
296
|
const budget = WIDGET_MAX_LINES - 1;
|
|
281
297
|
let shown = 0;
|
|
282
298
|
outer: for (const run of runs) {
|
|
283
|
-
const allDone = TERMINAL.includes(run.status);
|
|
284
299
|
for (const task of run.tasks) {
|
|
285
300
|
if (shown >= budget) break outer;
|
|
286
301
|
shown += 1;
|
|
287
|
-
const activity =
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
: "";
|
|
291
|
-
// Tasks of a finished run: dim everything except the agent name.
|
|
292
|
-
const line = allDone
|
|
293
|
-
? `${this.theme.fg("dim", `${statusIcon(task.status)} `)}${task.agent} ${this.theme.fg("dim", `· ${taskStatsWithUsage(task)} · ${taskTimer(task)}`)}`
|
|
294
|
-
: `${statusIcon(task.status)} ${task.agent} · ${activity}${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
|
|
295
|
-
lines.push(truncateToWidth(`${this.theme.fg("dim", "├─")} ${line}`, width, "…"));
|
|
302
|
+
const activity = task.lastActivity ? `${this.theme.fg("dim", `→ ${task.lastActivity}`)} · ` : "";
|
|
303
|
+
// Per-TASK status drives dimming: a finished agent stays dim even while siblings run.
|
|
304
|
+
lines.push(truncateToWidth(`${this.theme.fg("dim", "├─")} ${themedTaskLine(task, this.theme, activity)}`, width, "…"));
|
|
296
305
|
}
|
|
297
306
|
}
|
|
298
307
|
const hidden = total - shown;
|
|
@@ -576,7 +585,6 @@ class SubagentManager {
|
|
|
576
585
|
this.ensureWidget(ctx);
|
|
577
586
|
this.widgetTui?.requestRender();
|
|
578
587
|
}
|
|
579
|
-
onUpdate?.({ content: [{ type: "text", text: compactLines(run).join("\n") }] });
|
|
580
588
|
}, WIDGET_THROTTLE_MS));
|
|
581
589
|
}
|
|
582
590
|
private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
@@ -592,7 +600,8 @@ class SubagentManager {
|
|
|
592
600
|
this.ensureWidget(ctx);
|
|
593
601
|
this.widgetTui?.requestRender();
|
|
594
602
|
}
|
|
595
|
-
|
|
603
|
+
// Transcript gets one status line only — the live per-task view is the widget's job.
|
|
604
|
+
onUpdate?.({ content: [{ type: "text", text: `${run.tasks.filter((t) => TERMINAL.includes(t.status)).length}/${run.tasks.length} done · ${run.status}` }] });
|
|
596
605
|
}
|
|
597
606
|
private ensureWidget(ctx: ExtensionContext): void {
|
|
598
607
|
if (this.widgetTui !== null || !ctx.hasUI) return;
|
|
@@ -832,10 +841,15 @@ class SubagentManager {
|
|
|
832
841
|
|
|
833
842
|
const maxRuntimeMs = input.maxRuntimeMs ?? DEFAULT_RUNTIME_MS;
|
|
834
843
|
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
844
|
+
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
|
|
845
|
+
if (maxRuntimeMs > 0) {
|
|
846
|
+
races.push(
|
|
847
|
+
new Promise<never>((_, reject) => {
|
|
848
|
+
timeout = setTimeout(() => reject(new Error(`Subagent timed out after ${maxRuntimeMs}ms`)), maxRuntimeMs);
|
|
849
|
+
}),
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
await Promise.race(races);
|
|
839
853
|
if (timeout) clearTimeout(timeout);
|
|
840
854
|
|
|
841
855
|
pendingFailure ??= lastAssistantFailure(child.messages as AssistantMessage[]);
|
|
@@ -977,13 +991,10 @@ class SubagentManager {
|
|
|
977
991
|
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
978
992
|
run.endedAt = Date.now();
|
|
979
993
|
this.flushWidget(run, ctx, onUpdate);
|
|
980
|
-
//
|
|
994
|
+
// Finished runs (including aborted ones) stay on screen so the outcome is readable.
|
|
995
|
+
// The agent_start handler clears them on the next turn that spawns nothing.
|
|
981
996
|
const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
|
|
982
|
-
if (live)
|
|
983
|
-
this.scheduleWidget(live, ctx, onUpdate);
|
|
984
|
-
} else {
|
|
985
|
-
this.clearWidget(ctx);
|
|
986
|
-
}
|
|
997
|
+
if (live) this.scheduleWidget(live, ctx, onUpdate);
|
|
987
998
|
// L7: cancelRun already emitted + settled — don't double-report.
|
|
988
999
|
if (this.settlers.has(run.id)) {
|
|
989
1000
|
this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
|
|
@@ -1027,6 +1038,22 @@ class SubagentManager {
|
|
|
1027
1038
|
return { run: cloneRun(run), background: true };
|
|
1028
1039
|
}
|
|
1029
1040
|
|
|
1041
|
+
/** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
|
|
1042
|
+
cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
|
|
1043
|
+
const run = this.runs.get(runId);
|
|
1044
|
+
const task = run?.tasks.find((t) => t.id === taskId);
|
|
1045
|
+
if (!run || !task || TERMINAL.includes(task.status)) return false;
|
|
1046
|
+
// Mark first: runChild's catch reads task.status to classify the outcome as aborted.
|
|
1047
|
+
task.status = "aborted";
|
|
1048
|
+
task.error = task.error || "Canceled from peek";
|
|
1049
|
+
task.endedAt = Date.now();
|
|
1050
|
+
this.liveChildren.get(`${runId}:${taskId}`)?.abort();
|
|
1051
|
+
this.mailboxes.close(`${runId}:${taskId}`);
|
|
1052
|
+
if (ctx) this.flushWidget(run, ctx);
|
|
1053
|
+
this.emit("subagent:task-aborted", { runId, taskId });
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1030
1057
|
cancelRun(runId: string): { aborted: number } {
|
|
1031
1058
|
const run = this.runs.get(runId);
|
|
1032
1059
|
if (!run) return { aborted: 0 };
|
|
@@ -1133,7 +1160,7 @@ const SubagentParams = Type.Object({
|
|
|
1133
1160
|
thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override (single mode)" })),
|
|
1134
1161
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode). Default: current project." })),
|
|
1135
1162
|
concurrency: Type.Optional(Type.Number({ description: `Parallel concurrency (default ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY})` })),
|
|
1136
|
-
maxRuntimeMs: Type.Optional(Type.Number({ description:
|
|
1163
|
+
maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout, ms. Omit for no cap (default): tasks run until done, stalled, or user-aborted." })),
|
|
1137
1164
|
background: Type.Optional(Type.Boolean({ description: "Fire-and-forget: return immediately with a runId; you'll be notified on completion" })),
|
|
1138
1165
|
notifyPerTask: Type.Optional(Type.Boolean({ description: "Wake you (queued follow-up turn) as each task completes, even mid-run. Default false." })),
|
|
1139
1166
|
allowIntercom: Type.Optional(Type.Boolean({ description: "Let children ask you questions, notify you, and message sibling subagents" })),
|
|
@@ -1178,13 +1205,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
1178
1205
|
manager
|
|
1179
1206
|
.listRuns()
|
|
1180
1207
|
.flatMap((run) => run.tasks)
|
|
1181
|
-
.map((task) => ({
|
|
1208
|
+
.map((task) => ({
|
|
1209
|
+
runId: task.runId,
|
|
1210
|
+
taskId: task.id,
|
|
1211
|
+
agent: task.agent,
|
|
1212
|
+
status: task.status,
|
|
1213
|
+
running: !TERMINAL.includes(task.status),
|
|
1214
|
+
sessionFile: task.sessionFile,
|
|
1215
|
+
line: taskLine(task),
|
|
1216
|
+
}));
|
|
1182
1217
|
if (getTasks().length === 0) {
|
|
1183
1218
|
ctx.ui.notify("No subagents in this session.", "info");
|
|
1184
1219
|
return;
|
|
1185
1220
|
}
|
|
1186
1221
|
await ctx.ui.custom<void>(
|
|
1187
|
-
(tui, theme, _keybindings, done) =>
|
|
1222
|
+
(tui, theme, _keybindings, done) =>
|
|
1223
|
+
createPeekPane(getTasks, theme, () => tui.requestRender(), () => done(undefined), (t) => {
|
|
1224
|
+
if (manager.cancelTask(t.runId, t.taskId, ctx)) ctx.ui.notify(`Aborted subagent ${t.agent}.`, "warning");
|
|
1225
|
+
}),
|
|
1188
1226
|
{ overlay: true, overlayOptions: { anchor: "center", width: "80%", maxHeight: "70%" } },
|
|
1189
1227
|
);
|
|
1190
1228
|
};
|
|
@@ -1267,7 +1305,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1267
1305
|
// ponytail: mode/count already shown on the call line above; result header only adds progress + status.
|
|
1268
1306
|
const header = `${statusIcon(run.status)} ${theme.fg("accent", `${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} done`)}${run.background ? ` ${theme.fg("muted", "(background)")}` : ""} ${theme.fg("muted", run.status)}`;
|
|
1269
1307
|
if (!expanded) {
|
|
1270
|
-
const lines = [header, ...run.tasks.map((task) => ` ${
|
|
1308
|
+
const lines = [header, ...run.tasks.map((task) => ` ${themedTaskLine(task, theme)}`)];
|
|
1271
1309
|
const usage = formatUsage(run.aggregateUsage);
|
|
1272
1310
|
if (usage) lines.push(theme.fg("dim", usage));
|
|
1273
1311
|
return new Text(lines.join("\n"), 0, 0);
|
package/src/peek.ts
CHANGED
|
@@ -15,8 +15,11 @@ const TAIL_BYTES = 64 * 1024;
|
|
|
15
15
|
const POLL_MS = 700;
|
|
16
16
|
|
|
17
17
|
export interface PeekTask {
|
|
18
|
+
runId: string;
|
|
19
|
+
taskId: string;
|
|
18
20
|
agent: string;
|
|
19
21
|
status: string;
|
|
22
|
+
running: boolean;
|
|
20
23
|
sessionFile?: string;
|
|
21
24
|
line: string; // pre-rendered stats line from the caller
|
|
22
25
|
}
|
|
@@ -82,9 +85,16 @@ export interface PeekPane {
|
|
|
82
85
|
* Build the peek component. `getTasks` is polled live, so the pane keeps
|
|
83
86
|
* updating while agents run.
|
|
84
87
|
*/
|
|
85
|
-
export function createPeekPane(
|
|
88
|
+
export function createPeekPane(
|
|
89
|
+
getTasks: () => PeekTask[],
|
|
90
|
+
theme: Theme,
|
|
91
|
+
requestRender: () => void,
|
|
92
|
+
close: () => void,
|
|
93
|
+
abort: (task: PeekTask) => void,
|
|
94
|
+
): PeekPane {
|
|
86
95
|
let selected = 0;
|
|
87
96
|
let tailing = false;
|
|
97
|
+
let confirming = false;
|
|
88
98
|
const timer = setInterval(requestRender, POLL_MS);
|
|
89
99
|
|
|
90
100
|
const clamp = (n: number, len: number) => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, n)));
|
|
@@ -95,7 +105,7 @@ export function createPeekPane(getTasks: () => PeekTask[], theme: Theme, request
|
|
|
95
105
|
selected = clamp(selected, tasks.length);
|
|
96
106
|
if (tasks.length === 0) return [theme.fg("dim", "No subagents in this session.")];
|
|
97
107
|
const task = tasks[selected]!;
|
|
98
|
-
const hint = tailing ? "esc back" : "↑↓ move · enter tail · esc close";
|
|
108
|
+
const hint = confirming ? theme.fg("error", `abort ${task.agent}? y / n`) : tailing ? "esc back · x abort" : "↑↓ move · enter tail · x abort · esc close";
|
|
99
109
|
const head = `${theme.fg("accent", theme.bold(tailing ? task.agent : "Subagents"))} ${theme.fg("dim", `(${selected + 1}/${tasks.length}) · ${hint}`)}`;
|
|
100
110
|
if (!tailing) {
|
|
101
111
|
return [head, ...tasks.map((t, i) => truncateToWidth(`${i === selected ? theme.fg("accent", "❯ ") : " "}${t.line}`, width, "…"))];
|
|
@@ -105,8 +115,21 @@ export function createPeekPane(getTasks: () => PeekTask[], theme: Theme, request
|
|
|
105
115
|
return [head, ...tailLines(task.sessionFile, 18).map((l) => truncateToWidth(` ${l}`, width, "…"))];
|
|
106
116
|
},
|
|
107
117
|
handleInput(data: string): void {
|
|
108
|
-
const
|
|
109
|
-
|
|
118
|
+
const tasks = getTasks();
|
|
119
|
+
const len = tasks.length;
|
|
120
|
+
if (confirming) {
|
|
121
|
+
// Abort is irreversible, so it always costs a second keystroke.
|
|
122
|
+
confirming = false;
|
|
123
|
+
if (data === "y" || data === "Y") {
|
|
124
|
+
const task = tasks[selected];
|
|
125
|
+
if (task) abort(task);
|
|
126
|
+
}
|
|
127
|
+
requestRender();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (data === "x" || data === "X") {
|
|
131
|
+
if (tasks[selected]?.running) confirming = true;
|
|
132
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
110
133
|
if (tailing) tailing = false;
|
|
111
134
|
else close();
|
|
112
135
|
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
|