@arhen/pi-core-subagent 1.3.49 → 1.3.50

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,8 +126,7 @@ 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
131
  "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.",
161
132
  promptSnippet: "Define and delegate work to specialized subagents.",
@@ -174,28 +145,22 @@ export default function (pi: ExtensionAPI) {
174
145
  "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.",
175
146
  ],
176
147
  parameters: SubagentParams,
177
- executionMode: "parallel", // sibling subagent calls run concurrently, not serialized
148
+ executionMode: "parallel",
178
149
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
179
150
  const typed = params as SubagentParamsShape;
180
151
  const details = manager.startInBackground(typed, ctx);
181
152
  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
153
  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).
154
+
188
155
  const intercom: ParkedMsg[] = [];
189
156
  while (!TERMINAL.includes(run.status)) {
190
157
  const awaited = await manager.awaitRun(details.run.id);
191
- if (!awaited) break; // run gone (session shutdown) — stop, no busy-spin
158
+ if (!awaited) break;
192
159
  if (awaited.run) run = awaited.run;
193
160
  intercom.push(...awaited.intercom);
194
161
  if (awaited.intercom.some((m) => m.kind === "ask")) break;
195
162
  }
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.
163
+
199
164
  const asks = intercom.filter((m) => m.kind === "ask");
200
165
  const heard = intercom.filter((m) => m.kind !== "ask");
201
166
  const text = [
@@ -227,7 +192,6 @@ export default function (pi: ExtensionAPI) {
227
192
  };
228
193
  },
229
194
  renderCall(args, theme) {
230
- // ponytail: args stream in partially, so mode is unknowable until JSON closes. Show "preparing…" instead of a wrong "single ?".
231
195
  const hasEdges = args.tasks?.some((t) => t.needs?.length);
232
196
  const mode = args.chain?.length
233
197
  ? `chain ${args.chain.length}`
@@ -237,7 +201,7 @@ export default function (pi: ExtensionAPI) {
237
201
  ? `single ${args.agent}`
238
202
  : "preparing…";
239
203
  const flags = args.autoAwait ? "await" : "bg";
240
- // Params used, dimmed: model, thinking, toolset, per-task write count.
204
+
241
205
  const tasks = args.tasks ?? args.chain ?? [];
242
206
  const writeCount = tasks.filter((t) => t.write).length;
243
207
  const parts: string[] = [];
@@ -250,8 +214,7 @@ export default function (pi: ExtensionAPI) {
250
214
  const params = parts.length > 0 ? `\n ${theme.fg("dim", parts.join(" · "))}` : "";
251
215
  const notation = waveNotation(tasks);
252
216
  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.
217
+
255
218
  const plan = tasks
256
219
  .filter((t) => t.agent || t.id)
257
220
  .map((t, i: number) => {
@@ -259,7 +222,7 @@ export default function (pi: ExtensionAPI) {
259
222
  const edge = t.needs?.length ? theme.fg("muted", ` ← ${t.needs.join(", ")}`) : "";
260
223
  const mark = t.write ? theme.fg("warning", " ✎") : "";
261
224
  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.
225
+
263
226
  const flat = String(t.task ?? "")
264
227
  .replace(/\s+/g, " ")
265
228
  .trim();
@@ -276,11 +239,9 @@ export default function (pi: ExtensionAPI) {
276
239
  renderResult(result, { expanded }, theme) {
277
240
  const run = result.details?.run;
278
241
  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.
242
+
280
243
  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
244
  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
245
  const usage = formatUsage(run.aggregateUsage);
285
246
  return new Text(usage ? `${header}\n${theme.fg("dim", usage)}` : header, 0, 0);
286
247
  }
@@ -312,8 +273,7 @@ export default function (pi: ExtensionAPI) {
312
273
  const { runId } = params as { runId: string };
313
274
  const run = manager.getRun(runId);
314
275
  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.
276
+
317
277
  const files = run.tasks.filter((t) => t.sessionFile).map((t) => `${t.id} (${t.agent}): ${t.sessionFile}`);
318
278
  const text = [
319
279
  compactLines(run).join("\n"),
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
  }