@arhen/pi-core-subagent 1.1.15 → 1.1.17
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 +55 -33
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.17",
|
|
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,14 +226,40 @@ 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
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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}`);
|
|
235
241
|
}
|
|
242
|
+
return `${statusIcon(task.status)} ${task.agent} · ${activity}${colorNums(theme.fg("muted", tail), theme)}`;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Human-readable activity line: "Read src/index.ts", "Grep wrapSingleLine".
|
|
246
|
+
* ponytail: picks the first interesting string arg instead of a per-tool table —
|
|
247
|
+
* unknown/custom tools then read fine too. Add a case only if one reads badly.
|
|
248
|
+
*/
|
|
249
|
+
// Order matters: the most specific arg wins (grep's pattern beats its path).
|
|
250
|
+
const ARG_KEYS = ["pattern", "query", "command", "path", "file_path", "filePath", "url", "name", "subject", "task"];
|
|
251
|
+
export function describeCall(toolName: string, args: unknown, cwd?: string): string {
|
|
252
|
+
const verb = toolName.charAt(0).toUpperCase() + toolName.slice(1);
|
|
253
|
+
const obj = args && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
|
|
254
|
+
if (!obj) return verb;
|
|
255
|
+
let value = ARG_KEYS.map((k) => obj[k]).find((v) => typeof v === "string" && v.trim() !== "") as string | undefined;
|
|
256
|
+
if (value === undefined) {
|
|
257
|
+
value = Object.values(obj).find((v) => typeof v === "string" && v.trim() !== "") as string | undefined;
|
|
258
|
+
}
|
|
259
|
+
if (value === undefined) return verb;
|
|
260
|
+
let text = value.replace(/\s+/g, " ").trim();
|
|
261
|
+
if (cwd && text.startsWith(`${cwd}/`)) text = text.slice(cwd.length + 1); // absolute paths inside the task cwd read as noise
|
|
262
|
+
return `${verb} ${text.length > 60 ? `${text.slice(0, 60)}…` : text}`;
|
|
236
263
|
}
|
|
237
264
|
function activitySnippet(text: string): string {
|
|
238
265
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
@@ -280,19 +307,12 @@ class SubagentsWidget implements Component {
|
|
|
280
307
|
const budget = WIDGET_MAX_LINES - 1;
|
|
281
308
|
let shown = 0;
|
|
282
309
|
outer: for (const run of runs) {
|
|
283
|
-
const allDone = TERMINAL.includes(run.status);
|
|
284
310
|
for (const task of run.tasks) {
|
|
285
311
|
if (shown >= budget) break outer;
|
|
286
312
|
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, "…"));
|
|
313
|
+
const activity = task.lastActivity ? `${this.theme.fg("dim", `→ ${task.lastActivity}`)} · ` : "";
|
|
314
|
+
// Per-TASK status drives dimming: a finished agent stays dim even while siblings run.
|
|
315
|
+
lines.push(truncateToWidth(`${this.theme.fg("dim", "├─")} ${themedTaskLine(task, this.theme, activity)}`, width, "…"));
|
|
296
316
|
}
|
|
297
317
|
}
|
|
298
318
|
const hidden = total - shown;
|
|
@@ -576,7 +596,6 @@ class SubagentManager {
|
|
|
576
596
|
this.ensureWidget(ctx);
|
|
577
597
|
this.widgetTui?.requestRender();
|
|
578
598
|
}
|
|
579
|
-
onUpdate?.({ content: [{ type: "text", text: compactLines(run).join("\n") }] });
|
|
580
599
|
}, WIDGET_THROTTLE_MS));
|
|
581
600
|
}
|
|
582
601
|
private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
|
|
@@ -592,7 +611,8 @@ class SubagentManager {
|
|
|
592
611
|
this.ensureWidget(ctx);
|
|
593
612
|
this.widgetTui?.requestRender();
|
|
594
613
|
}
|
|
595
|
-
|
|
614
|
+
// Transcript gets one status line only — the live per-task view is the widget's job.
|
|
615
|
+
onUpdate?.({ content: [{ type: "text", text: `${run.tasks.filter((t) => TERMINAL.includes(t.status)).length}/${run.tasks.length} done · ${run.status}` }] });
|
|
596
616
|
}
|
|
597
617
|
private ensureWidget(ctx: ExtensionContext): void {
|
|
598
618
|
if (this.widgetTui !== null || !ctx.hasUI) return;
|
|
@@ -779,7 +799,7 @@ class SubagentManager {
|
|
|
779
799
|
this.emit("subagent:session-event", { runId: run.id, taskId: task.id, seq: eventSeq++, event: { type: event.type } });
|
|
780
800
|
}
|
|
781
801
|
if (event.type === "tool_execution_start") {
|
|
782
|
-
this.updateTask(run, task, { toolCalls: task.toolCalls + 1, lastActivity:
|
|
802
|
+
this.updateTask(run, task, { toolCalls: task.toolCalls + 1, lastActivity: describeCall(event.toolName, event.args, task.cwd) }, ctx, onUpdate);
|
|
783
803
|
} else if (event.type === "tool_execution_end") {
|
|
784
804
|
this.scheduleWidget(run, ctx, onUpdate);
|
|
785
805
|
} else if (event.type === "message_end") {
|
|
@@ -832,10 +852,15 @@ class SubagentManager {
|
|
|
832
852
|
|
|
833
853
|
const maxRuntimeMs = input.maxRuntimeMs ?? DEFAULT_RUNTIME_MS;
|
|
834
854
|
const promptPromise = child.prompt(task.task, { source: "extension" });
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
855
|
+
const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise, watchdog.promise];
|
|
856
|
+
if (maxRuntimeMs > 0) {
|
|
857
|
+
races.push(
|
|
858
|
+
new Promise<never>((_, reject) => {
|
|
859
|
+
timeout = setTimeout(() => reject(new Error(`Subagent timed out after ${maxRuntimeMs}ms`)), maxRuntimeMs);
|
|
860
|
+
}),
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
await Promise.race(races);
|
|
839
864
|
if (timeout) clearTimeout(timeout);
|
|
840
865
|
|
|
841
866
|
pendingFailure ??= lastAssistantFailure(child.messages as AssistantMessage[]);
|
|
@@ -977,13 +1002,10 @@ class SubagentManager {
|
|
|
977
1002
|
run.status = aborted ? "aborted" : failed ? "failed" : "completed";
|
|
978
1003
|
run.endedAt = Date.now();
|
|
979
1004
|
this.flushWidget(run, ctx, onUpdate);
|
|
980
|
-
//
|
|
1005
|
+
// Finished runs (including aborted ones) stay on screen so the outcome is readable.
|
|
1006
|
+
// The agent_start handler clears them on the next turn that spawns nothing.
|
|
981
1007
|
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
|
-
}
|
|
1008
|
+
if (live) this.scheduleWidget(live, ctx, onUpdate);
|
|
987
1009
|
// L7: cancelRun already emitted + settled — don't double-report.
|
|
988
1010
|
if (this.settlers.has(run.id)) {
|
|
989
1011
|
this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
|
|
@@ -1149,7 +1171,7 @@ const SubagentParams = Type.Object({
|
|
|
1149
1171
|
thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override (single mode)" })),
|
|
1150
1172
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode). Default: current project." })),
|
|
1151
1173
|
concurrency: Type.Optional(Type.Number({ description: `Parallel concurrency (default ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY})` })),
|
|
1152
|
-
maxRuntimeMs: Type.Optional(Type.Number({ description:
|
|
1174
|
+
maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout, ms. Omit for no cap (default): tasks run until done, stalled, or user-aborted." })),
|
|
1153
1175
|
background: Type.Optional(Type.Boolean({ description: "Fire-and-forget: return immediately with a runId; you'll be notified on completion" })),
|
|
1154
1176
|
notifyPerTask: Type.Optional(Type.Boolean({ description: "Wake you (queued follow-up turn) as each task completes, even mid-run. Default false." })),
|
|
1155
1177
|
allowIntercom: Type.Optional(Type.Boolean({ description: "Let children ask you questions, notify you, and message sibling subagents" })),
|
|
@@ -1294,7 +1316,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1294
1316
|
// ponytail: mode/count already shown on the call line above; result header only adds progress + status.
|
|
1295
1317
|
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)}`;
|
|
1296
1318
|
if (!expanded) {
|
|
1297
|
-
const lines = [header, ...run.tasks.map((task) => ` ${
|
|
1319
|
+
const lines = [header, ...run.tasks.map((task) => ` ${themedTaskLine(task, theme)}`)];
|
|
1298
1320
|
const usage = formatUsage(run.aggregateUsage);
|
|
1299
1321
|
if (usage) lines.push(theme.fg("dim", usage));
|
|
1300
1322
|
return new Text(lines.join("\n"), 0, 0);
|