@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/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: "List subagent runs. `/subagents peek` opens the browsable pane.",
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
- // ctrl+shift+s belongs to pi-web-access (search curator); 'a' for agents is free.
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
- // Crash leftovers: remove stale worktree dirs (branches survive for merging).
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
- // ponytail: this string is billed on every request. No example block — an example
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). You invent each agent: name, optional system prompt, toolset (read-only default, write:true to edit). Use `agent`+`task` for one, `tasks` for many. `needs` declares dependency edges: a task waits for its needs and receives their outputs prepended to its prompt. If a user agent file in `.agents/agents`, `.claude/agents`, or `.pi/agents` (project dirs, then home) has a `description` matching the spawn goal (name + task), that file is authoritative: body = system prompt, frontmatter `model`/`tools` apply and inline prompt/model are ignored — except explicit per-call `tools`/`write`, which override the file's tools. No match → the inline definition stands. Write agents run in an isolated git worktree: on completion the result reports the branch + changed files — review, then merge with `git merge --no-ff <branch>` (merged branches are cleaned automatically). Every run is background: the call returns a runId immediately and completion notifies you — do NOT park waiting on it. If you have no other work, end your turn; the completion notice wakes you with the results. Set autoAwait:true only when the very next step in the SAME turn consumes the result. Children always carry talk tools: they can ask you questions, notify you, and message siblings.",
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
- "Put every sub-task in ONE call: subagent({ tasks: [...] }). Never make multiple parallel subagent calls — one call, one run, N tasks.",
165
- "Order comes from `needs`, not from separate calls: give tasks an `id`, list the ids each depends on. Tasks with no unmet needs run in parallel; dependents receive their upstream outputs automatically — do not restate them.",
166
- "Prefer flat `tasks` (plain parallel) unless a real dependency exists — only add `needs` edges when ordering genuinely matters.",
167
- "End each task with a runnable check, e.g. 'Verify: npx tsc --noEmit && bun test'. A subagent's claim of success is not evidence.",
168
- "For write agents (write:true) in a git repo, the child works in an isolated worktree and its changes are committed to a branch — the result reports branch + changed files. Review the diff, then merge with `git merge --no-ff <branch>`; merged branches are cleaned up automatically. Never leave a worktree branch unmerged at the end of the task.",
169
- "Define each agent yourself: invented name, focused system prompt, and read-only (default) or write:true. Prefer read-only. A user agent file (`.agents/agents`, `.claude/agents`, `.pi/agents` — project first, then home) whose `description` matches the spawn goal (name + task) takes over: its body is the system prompt, frontmatter `model`/`tools` apply and are validated against the model registry — explicit per-call `tools`/`write` still override the file's tools. Matching is by description, not name — name the agent whatever fits the goal.",
170
- "Right after a background spawn, call subagent_status(runId) ONCE before any other work — confirm each task is running (or already progressing), not stuck queued or failed at startup. A child that dies on spawn otherwise stays invisible until far later.",
171
- "If that first status shows a task failed or never started, fix or respawn immediately; do not move on assuming it runs.",
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", // sibling subagent calls run concurrently, not serialized
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
- // Each park gets a FRESH msgs array — accumulate, or every wake but the
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; // run gone (session shutdown) — stop, no busy-spin
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
- // EVERY ask must surface: siblings that asked in the same wake got no
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
- // Params used, dimmed: model, thinking, toolset, per-task write count.
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
- // The plan the model actually wrote: ids, edges, toolset. Streams in as args arrive,
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
- // Plain clip, not truncateText — that one appends a multi-line session-file notice.
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
- // ponytail: mode/count already shown on the call line above; result header only adds progress + status.
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): per-task state, plus each child's session file path (JSONL) so you can tail it from outside — e.g. in a terminal multiplexer pane. Call this once right after spawning to verify the children actually started.",
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
- // Session file paths are the one primitive an outside tool needs: `tail -f` it in a
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). Use ONLY when you have work of your own to sync with; if you have nothing else to do, end your turn instead — completion notifies you and wakes a new turn with the results. While parked, child→leader messages (asks, notifies, completions) wake the wait and arrive INSIDE the result.",
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
  }