@arhen/pi-core-subagent 1.3.49 → 1.3.51
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 +5 -6
- package/package.json +1 -1
- package/src/agentfile.ts +4 -41
- package/src/child.ts +2 -17
- package/src/format.ts +11 -58
- package/src/graph.ts +7 -36
- package/src/index.ts +27 -76
- package/src/mailbox.ts +0 -7
- package/src/manager.ts +93 -348
- package/src/peek.ts +13 -69
- package/src/schemas.ts +6 -16
- package/src/types.ts +0 -19
- package/src/worktree.ts +37 -164
package/src/index.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-core-subagent — in-process subagents.
|
|
3
|
-
*
|
|
4
|
-
* Fast in-process subagents (isolated AgentSessions, no process spawn).
|
|
5
|
-
* Modes: single / parallel / chain. Background runs, cancel, intercom
|
|
6
|
-
* (ask/notify/update the leader) and agent↔agent mailbox (send/poll).
|
|
7
|
-
*
|
|
8
|
-
* Context discipline: 7 slim parent tools, one-line catalog injected per
|
|
9
|
-
* request (cached), background completions notify with a 3-line summary
|
|
10
|
-
* instead of full outputs, and run updates are throttled (no per-event
|
|
11
|
-
* deep clones).
|
|
12
|
-
*
|
|
13
|
-
* Layout: schemas → schemas.ts, scheduler/graph → graph.ts, rendering →
|
|
14
|
-
* format.ts, run lifecycle → manager.ts, this file = entry + registrations.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
2
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
3
|
import { compactLines, formatUsage, makeSummary, statusIcon, taskLine, truncateText } from "./format.ts";
|
|
@@ -35,7 +19,6 @@ import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } fr
|
|
|
35
19
|
export default function (pi: ExtensionAPI) {
|
|
36
20
|
const manager = new SubagentManager(pi);
|
|
37
21
|
|
|
38
|
-
/** Read-only peek: browse agents, enter to tail one. Never mutates run state. */
|
|
39
22
|
const openPeek = async (ctx: ExtensionContext) => {
|
|
40
23
|
if (!ctx.hasUI) return;
|
|
41
24
|
const getTasks = (): PeekTask[] =>
|
|
@@ -70,7 +53,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
53
|
);
|
|
71
54
|
};
|
|
72
55
|
pi.registerCommand("subagents", {
|
|
73
|
-
description:
|
|
56
|
+
description:
|
|
57
|
+
"List subagent runs. `/subagents peek` opens the browsable pane; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
|
|
74
58
|
handler: async (args, ctx) => {
|
|
75
59
|
const arg = String(args ?? "")
|
|
76
60
|
.trim()
|
|
@@ -100,7 +84,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
100
84
|
ctx.ui.notify(runs.flatMap((run) => compactLines(run).concat("")).join("\n"), "info");
|
|
101
85
|
},
|
|
102
86
|
});
|
|
103
|
-
|
|
87
|
+
|
|
104
88
|
pi.registerShortcut("ctrl+shift+a", { description: "Peek at running subagents", handler: openPeek });
|
|
105
89
|
|
|
106
90
|
pi.on("agent_start", (_event, ctx) => {
|
|
@@ -110,9 +94,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
110
94
|
|
|
111
95
|
pi.on("session_start", async (_event, ctx) => {
|
|
112
96
|
await manager.restoreFromSidecar(ctx);
|
|
113
|
-
|
|
114
|
-
// Also reap branches the leader merged in a previous session (H1: cleanup can't
|
|
115
|
-
// fire at run end — the leader merges after).
|
|
97
|
+
|
|
116
98
|
const roots = new Set<string>();
|
|
117
99
|
const cwdRoot = repoRoot(ctx.cwd);
|
|
118
100
|
if (cwdRoot) roots.add(cwdRoot);
|
|
@@ -126,27 +108,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
126
108
|
}
|
|
127
109
|
for (const root of roots) {
|
|
128
110
|
try {
|
|
129
|
-
// A registered subagent worktree is a crash leftover UNLESS another pi
|
|
130
|
-
// session still owns it (pid marker) — commit its work, keep the branch,
|
|
131
|
-
// drop the dir. Then reap merged branches and dirs git no longer tracks.
|
|
132
|
-
// Pass real ownership: in a long-lived process (pi `/new`) the previous
|
|
133
|
-
// session's markers carry THIS pid, and trusting them made those dirs
|
|
134
|
-
// unreapable until the process exited.
|
|
135
111
|
reapDeadWorktrees(root, (p) => ownerAlive(p, manager.ownsWorktree));
|
|
136
112
|
cleanupMerged(root, { skipBranches: manager.liveBranches() });
|
|
137
113
|
sweepStale(root);
|
|
138
|
-
} catch {
|
|
139
|
-
/* recovery is best-effort — never block session start */
|
|
140
|
-
}
|
|
114
|
+
} catch {}
|
|
141
115
|
}
|
|
142
116
|
});
|
|
143
117
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
144
118
|
if (ctx?.hasUI) {
|
|
145
119
|
try {
|
|
146
120
|
ctx.ui.setWidget("subagents", [], { placement: "aboveEditor" });
|
|
147
|
-
} catch {
|
|
148
|
-
/* ignore */
|
|
149
|
-
}
|
|
121
|
+
} catch {}
|
|
150
122
|
}
|
|
151
123
|
manager.clearRuns();
|
|
152
124
|
});
|
|
@@ -154,48 +126,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
126
|
pi.registerTool<typeof SubagentParams, RunDetails>({
|
|
155
127
|
name: "subagent",
|
|
156
128
|
label: "Subagent",
|
|
157
|
-
|
|
158
|
-
// biases the model toward one shape; guidelines + JSON schema describe all of them.
|
|
129
|
+
|
|
159
130
|
description:
|
|
160
|
-
"Run isolated subagents (own context, own session)
|
|
131
|
+
"Run isolated subagents (own context, own session) in the background: returns a runId immediately, completion notifies you. One call = one agent (`agent`+`task`) or many (`tasks`, or `chain` with `{previous}`). `needs` edges gate tasks and prepend upstream outputs to their prompts. A user agent file (`.agents/agents`, `.claude/agents`, `.pi/agents`; project dirs, then home) whose `description` matches the goal is authoritative: body = system prompt, frontmatter `model`/`tools` apply, but explicit per-call `tools`/`write` override the file's tools. Write agents get an isolated git worktree; the result reports the branch. Children always carry talk tools (ask/notify the leader, message siblings).",
|
|
161
132
|
promptSnippet: "Define and delegate work to specialized subagents.",
|
|
162
133
|
promptGuidelines: [
|
|
163
134
|
"Use subagent when independent review, testing, research, or parallel analysis improves quality.",
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"
|
|
169
|
-
"
|
|
170
|
-
"
|
|
171
|
-
"
|
|
172
|
-
"Never block with nothing to do: if you have no work left after spawning, end your turn. Task completion notifies you and wakes a fresh turn with the results — await_subagent/autoAwait in that situation only burns time and tokens.",
|
|
173
|
-
"autoAwait:true only when the same turn must consume the result immediately (e.g. you spawn a reviewer and then must act on its verdict before replying). Otherwise spawn background and read results from the completion notice, or subagent_result when you come back.",
|
|
174
|
-
"await_subagent is for the rare case where you have parallel work of your own and need to sync at a specific point — not the default follow-up to a spawn.",
|
|
135
|
+
"Batch every sub-task in ONE call: subagent({ tasks: [...] }) — never multiple parallel subagent calls.",
|
|
136
|
+
"Declare ordering with `needs` edges on the tasks, never by splitting into separate calls; dependents receive upstream outputs automatically — do not restate them. Prefer flat `tasks` (plain parallel); add `needs` only when ordering genuinely matters.",
|
|
137
|
+
"End each task with a runnable check, e.g. 'Verify: bun test'. A subagent's claim of success is not evidence.",
|
|
138
|
+
"Write agents work in an isolated git worktree; their changes land on a branch — review the diff, then merge with `git merge --no-ff <branch>`. Never leave a worktree branch unmerged at the end of the task.",
|
|
139
|
+
"Define each agent inline: invented name, focused system prompt, read-only by default (write:true to edit). A matched agent file takes over (see description); matching is by description, not name — name the agent whatever fits the goal.",
|
|
140
|
+
"Right after spawning, call subagent_status(runId) ONCE before any other work — a child that died on spawn (or never started) is invisible until far later otherwise. If it shows a task failed/never started, fix or respawn immediately.",
|
|
141
|
+
"Never block with nothing to do: if you have no work left after spawning, end your turn — completion notifies you and wakes a fresh turn with the results. await_subagent/autoAwait while idle only burns time and tokens.",
|
|
142
|
+
"autoAwait:true only when this SAME turn must consume the result immediately. await_subagent is for syncing with your own parallel work — not the default follow-up to a spawn.",
|
|
175
143
|
],
|
|
176
144
|
parameters: SubagentParams,
|
|
177
|
-
executionMode: "parallel",
|
|
145
|
+
executionMode: "parallel",
|
|
178
146
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
179
147
|
const typed = params as SubagentParamsShape;
|
|
180
148
|
const details = manager.startInBackground(typed, ctx);
|
|
181
149
|
if (typed.autoAwait) {
|
|
182
|
-
// awaitRun wakes on every child→leader message (ask/notify/done). Re-park
|
|
183
|
-
// until terminal — but surface an ask_parent: the child is waiting on the
|
|
184
|
-
// leader, so break out, reply via reply_subagent, then await again.
|
|
185
150
|
let run = details.run;
|
|
186
|
-
|
|
187
|
-
// last is lost (they were consumed by the park, never sent as followUp).
|
|
151
|
+
|
|
188
152
|
const intercom: ParkedMsg[] = [];
|
|
189
153
|
while (!TERMINAL.includes(run.status)) {
|
|
190
154
|
const awaited = await manager.awaitRun(details.run.id);
|
|
191
|
-
if (!awaited) break;
|
|
155
|
+
if (!awaited) break;
|
|
192
156
|
if (awaited.run) run = awaited.run;
|
|
193
157
|
intercom.push(...awaited.intercom);
|
|
194
158
|
if (awaited.intercom.some((m) => m.kind === "ask")) break;
|
|
195
159
|
}
|
|
196
|
-
|
|
197
|
-
// followUp notice (the park swallowed it), so showing only the first
|
|
198
|
-
// leaves the rest blocked until their 10-minute timeout.
|
|
160
|
+
|
|
199
161
|
const asks = intercom.filter((m) => m.kind === "ask");
|
|
200
162
|
const heard = intercom.filter((m) => m.kind !== "ask");
|
|
201
163
|
const text = [
|
|
@@ -227,7 +189,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
227
189
|
};
|
|
228
190
|
},
|
|
229
191
|
renderCall(args, theme) {
|
|
230
|
-
// ponytail: args stream in partially, so mode is unknowable until JSON closes. Show "preparing…" instead of a wrong "single ?".
|
|
231
192
|
const hasEdges = args.tasks?.some((t) => t.needs?.length);
|
|
232
193
|
const mode = args.chain?.length
|
|
233
194
|
? `chain ${args.chain.length}`
|
|
@@ -237,7 +198,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
198
|
? `single ${args.agent}`
|
|
238
199
|
: "preparing…";
|
|
239
200
|
const flags = args.autoAwait ? "await" : "bg";
|
|
240
|
-
|
|
201
|
+
|
|
241
202
|
const tasks = args.tasks ?? args.chain ?? [];
|
|
242
203
|
const writeCount = tasks.filter((t) => t.write).length;
|
|
243
204
|
const parts: string[] = [];
|
|
@@ -250,8 +211,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
250
211
|
const params = parts.length > 0 ? `\n ${theme.fg("dim", parts.join(" · "))}` : "";
|
|
251
212
|
const notation = waveNotation(tasks);
|
|
252
213
|
const graphLine = notation ? `\n ${theme.fg("muted", notation)}` : "";
|
|
253
|
-
|
|
254
|
-
// so a graph is visible before the first child spawns.
|
|
214
|
+
|
|
255
215
|
const plan = tasks
|
|
256
216
|
.filter((t) => t.agent || t.id)
|
|
257
217
|
.map((t, i: number) => {
|
|
@@ -259,7 +219,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
259
219
|
const edge = t.needs?.length ? theme.fg("muted", ` ← ${t.needs.join(", ")}`) : "";
|
|
260
220
|
const mark = t.write ? theme.fg("warning", " ✎") : "";
|
|
261
221
|
const meta = [t.model ? t.model : "", t.thinking ? t.thinking : ""].filter(Boolean).join(" ");
|
|
262
|
-
|
|
222
|
+
|
|
263
223
|
const flat = String(t.task ?? "")
|
|
264
224
|
.replace(/\s+/g, " ")
|
|
265
225
|
.trim();
|
|
@@ -276,11 +236,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
276
236
|
renderResult(result, { expanded }, theme) {
|
|
277
237
|
const run = result.details?.run;
|
|
278
238
|
if (!run) return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
|
279
|
-
|
|
239
|
+
|
|
280
240
|
const header = `${statusIcon(run.status)} ${theme.fg("accent", `${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} done`)} ${theme.fg("muted", run.status)}`;
|
|
281
241
|
if (!expanded) {
|
|
282
|
-
// Every run is background: the spawn snapshot is always "0 tools" noise and the footer
|
|
283
|
-
// widget already shows live per-task state — keep the card to the header only.
|
|
284
242
|
const usage = formatUsage(run.aggregateUsage);
|
|
285
243
|
return new Text(usage ? `${header}\n${theme.fg("dim", usage)}` : header, 0, 0);
|
|
286
244
|
}
|
|
@@ -302,18 +260,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
260
|
name: "subagent_status",
|
|
303
261
|
label: "Subagent Status",
|
|
304
262
|
description:
|
|
305
|
-
"Live status of a subagent run (non-blocking)
|
|
263
|
+
"Live per-task status of a subagent run (non-blocking), incl. each child's session file path (JSONL) to `tail -f` from outside. Call once right after spawning to verify children actually started.",
|
|
306
264
|
promptSnippet: "Check progress of a subagent run; use right after spawn as a health check.",
|
|
307
|
-
promptGuidelines: [
|
|
308
|
-
"Health-check every background spawn with one subagent_status(runId) before continuing — catch dead-on-arrival children early instead of at completion time.",
|
|
309
|
-
],
|
|
310
265
|
parameters: RunIdParam,
|
|
311
266
|
async execute(_id, params) {
|
|
312
267
|
const { runId } = params as { runId: string };
|
|
313
268
|
const run = manager.getRun(runId);
|
|
314
269
|
if (!run) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
|
|
315
|
-
|
|
316
|
-
// multiplexer pane, a log viewer, anything. Cheaper than owning a pane integration.
|
|
270
|
+
|
|
317
271
|
const files = run.tasks.filter((t) => t.sessionFile).map((t) => `${t.id} (${t.agent}): ${t.sessionFile}`);
|
|
318
272
|
const text = [
|
|
319
273
|
compactLines(run).join("\n"),
|
|
@@ -353,10 +307,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
353
307
|
name: "await_subagent",
|
|
354
308
|
label: "Await Subagent",
|
|
355
309
|
description:
|
|
356
|
-
"Block until a run finishes (or timeoutMs elapses).
|
|
357
|
-
promptGuidelines: [
|
|
358
|
-
"Do not call await_subagent right after spawning with no other work pending — end the turn and let the completion notice wake you.",
|
|
359
|
-
],
|
|
310
|
+
"Block until a run finishes (or timeoutMs elapses). Only when you have your own work to sync — otherwise end your turn; completion notifies you. While parked, child→leader messages (asks, notifies, completions) wake the wait and arrive inside the result.",
|
|
360
311
|
parameters: AwaitParam,
|
|
361
312
|
async execute(_id, params) {
|
|
362
313
|
const { runId, timeoutMs } = params as { runId: string; timeoutMs?: number };
|
package/src/mailbox.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent↔agent mailbox. Pure logic, no pi imports — easily unit-tested.
|
|
3
|
-
* Agents talk by polling, not push: send() enqueues, poll() drains.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
export interface MailboxMessage {
|
|
7
2
|
from: string;
|
|
8
3
|
text: string;
|
|
@@ -11,9 +6,7 @@ export interface MailboxMessage {
|
|
|
11
6
|
|
|
12
7
|
export interface Mailbox {
|
|
13
8
|
open(taskId: string): void;
|
|
14
|
-
/** Returns false when sender or target is unknown (no silent drops). */
|
|
15
9
|
send(from: string, to: string, text: string): boolean;
|
|
16
|
-
/** Return and clear all pending messages for taskId. */
|
|
17
10
|
poll(taskId: string): MailboxMessage[];
|
|
18
11
|
close(taskId: string): void;
|
|
19
12
|
}
|