@arhen/pi-core-subagent 1.1.6 → 1.1.8

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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/src/index.ts +56 -37
package/README.md CHANGED
@@ -10,7 +10,7 @@ Built for one job: delegate work to isolated subagents **without bloating the pa
10
10
 
11
11
  ## Design principles
12
12
 
13
- - **No agent files required, no discovery overhead.** The leader defines every subagent inline per call — name, system prompt, toolset. Existing files (`.agents/`, `.pi/agents/`, `~/.pi/agent/agents/`) are *looked up by name only when no inline prompt is given*; nothing is ever created or scanned per request.
13
+ - **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.
14
14
  - **Two toolsets only.** Read-only (`read, grep, find, ls` — default) or write (`read, grep, find, ls, bash, edit, write` — `write: true`). No per-agent tool config surface.
15
15
  - **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
16
16
  - **Zero parent-context injection.** No catalog, no context hook. 6 slim tools total.
@@ -26,7 +26,7 @@ pi install npm:@arhen/pi-core-subagent
26
26
 
27
27
  ## Usage — the leader invents the agents
28
28
 
29
- Define agents inline (never creates files). If a task has **no inline `prompt`**, the extension looks up an existing file by agent name — `.agents/<name>.md` or `.pi/agents/<name>.md` (nearest project dir), then `~/.pi/agent/agents/<name>.md` — and inherits its prompt + tools. Inline params always win over the file. Model resolution: explicit `provider/model-id` (or bare id) via the pi model registry → agent-file `model` → the parent's current model → settings default.
29
+ Define agents inline per call never creates or reads agent files. Model resolution: explicit `provider/model-id` (or bare id) via the pi model registry → agent-file `model` → the parent's current model → settings default.
30
30
 
31
31
  ```json
32
32
  {
@@ -84,7 +84,7 @@ Background + intercom:
84
84
 
85
85
  ### Per-task fields
86
86
 
87
- `agent` (name you invent, or an existing file's name — required), `task` (required), `prompt` (system prompt, optional — falls back to file lookup, then a minimal default), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist, overrides everything), `maxRuntimeMs`, `id`. Top-level only: `background`, `notifyPerTask`, `allowIntercom`, `concurrency`.
87
+ `agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`. Top-level only: `background`, `notifyPerTask`, `allowIntercom`, `concurrency`.
88
88
 
89
89
  ### Child talk tools (when `allowIntercom: true`)
90
90
 
@@ -99,7 +99,7 @@ Background + intercom:
99
99
 
100
100
  - Parent tools: 6 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
101
101
  - Background completion: 3-line notice. Full text only via `subagent_result`.
102
- - Children: isolated sessions; talk tools injected only when `allowIntercom`; each child's prompt states its own task id and its siblings' so mailbox addressing works.
102
+ - Children: isolated sessions; talk tools injected only when `allowIntercom`; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap`.
103
103
 
104
104
  ## Development
105
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.1.6",
3
+ "version": "1.1.8",
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
@@ -98,7 +98,7 @@ interface RunSnapshot {
98
98
  concurrency: number;
99
99
  /** True once the parent awaited this run — completion notices are redundant then. */
100
100
  awaited?: boolean;
101
- /** Wake the parent (queued follow-up turn) as each task completes. Default true. */
101
+ /** Wake the parent (queued follow-up turn) as each task completes. Default false. */
102
102
  notifyPerTask: boolean;
103
103
  tasks: TaskSnapshot[];
104
104
  aggregateUsage: UsageStats;
@@ -135,7 +135,9 @@ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
135
135
  }
136
136
  function truncateText(text: string, max = FINAL_OUTPUT_CAP): string {
137
137
  if (Buffer.byteLength(text, "utf8") <= max) return text;
138
- return `${text.slice(0, max)}\n\n[Output truncated. Full child session is available in the session file.]`;
138
+ let out = text.slice(0, max);
139
+ while (Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1); // multibyte-safe
140
+ return `${out}\n\n[Output truncated. Full child session is available in the session file.]`;
139
141
  }
140
142
  function getFirstText(message: AssistantMessage): string {
141
143
  for (const part of message?.content ?? []) {
@@ -251,8 +253,6 @@ function compactLines(run: RunSnapshot): string[] {
251
253
  * └─ ✓ reviewer · 6 tools · 44s
252
254
  * Static icons (no animation); latest activity + tool count + runtime per agent.
253
255
  */
254
- const WIDGET_MAX_RUNS = 3;
255
- const WIDGET_MAX_TASKS_PER_RUN = 4;
256
256
  const WIDGET_MAX_LINES = 10;
257
257
 
258
258
  class SubagentsWidget implements Component {
@@ -266,38 +266,39 @@ class SubagentsWidget implements Component {
266
266
  }
267
267
 
268
268
  render(width: number): string[] {
269
- const runs = this.getRuns();
269
+ // ONE flat tree: every run's tasks concatenated under a single heading.
270
+ // Whether the model spawned N runs or one tasks[] call, the pane reads the same.
271
+ const runs = this.getRuns().filter((r) => r.tasks.length > 0);
270
272
  if (runs.length === 0) return [];
271
- const lines: string[] = [];
272
- let renderedRuns = 0;
273
- for (const run of runs.slice(0, WIDGET_MAX_RUNS)) {
274
- if (run.tasks.length === 0) continue;
275
- if (lines.length > 0) lines.push("");
276
- const done = run.tasks.filter((t) => TERMINAL.includes(t.status)).length;
277
- const active = !TERMINAL.includes(run.status);
278
- const head = active ? "accent" : "dim";
279
- lines.push(truncateToWidth(`${this.theme.fg(head, active ? "●" : "○")} ${this.theme.fg(head, `Subagents (${done}/${run.tasks.length})`)}`, width, "…"));
273
+ const total = runs.reduce((n, r) => n + r.tasks.length, 0);
274
+ const done = runs.reduce((n, r) => n + r.tasks.filter((t) => TERMINAL.includes(t.status)).length, 0);
275
+ const live = total - done;
276
+ const head = live > 0 ? "accent" : "dim";
277
+ const lines = [truncateToWidth(`${this.theme.fg(head, live > 0 ? "●" : "○")} ${this.theme.fg(head, `Subagents (${done}/${total})`)}`, width, "")];
278
+ const budget = WIDGET_MAX_LINES - 1;
279
+ let shown = 0;
280
+ outer: for (const run of runs) {
280
281
  const allDone = TERMINAL.includes(run.status);
281
- const visible = run.tasks.slice(0, WIDGET_MAX_TASKS_PER_RUN);
282
- visible.forEach((task, i) => {
283
- const last = i === visible.length - 1 && run.tasks.length <= WIDGET_MAX_TASKS_PER_RUN;
284
- const conn = this.theme.fg("dim", last ? "└─" : "├─");
282
+ for (const task of run.tasks) {
283
+ if (shown >= budget) break outer;
284
+ shown += 1;
285
285
  const activity =
286
286
  !TERMINAL.includes(task.status) && task.lastActivity
287
287
  ? `${this.theme.fg("dim", `→ ${task.lastActivity}`)} · `
288
288
  : "";
289
- // Finished run: dim everything except the agent name.
289
+ // Tasks of a finished run: dim everything except the agent name.
290
290
  const line = allDone
291
291
  ? `${this.theme.fg("dim", `${statusIcon(task.status)} `)}${task.agent} ${this.theme.fg("dim", `· ${taskStatsWithUsage(task)} · ${taskTimer(task)}`)}`
292
292
  : `${statusIcon(task.status)} ${task.agent} · ${activity}${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
293
- lines.push(truncateToWidth(`${conn} ${line}`, width, "…"));
294
- });
295
- if (run.tasks.length > WIDGET_MAX_TASKS_PER_RUN) lines.push(`${this.theme.fg("dim", "└─")} ${this.theme.fg("dim", `+${run.tasks.length - WIDGET_MAX_TASKS_PER_RUN} more tasks`)}`);
296
- renderedRuns += 1;
297
- if (lines.length >= WIDGET_MAX_LINES) break; // keep the editor visible
293
+ lines.push(truncateToWidth(`${this.theme.fg("dim", "├─")} ${line}`, width, "…"));
294
+ }
295
+ }
296
+ const hidden = total - shown;
297
+ if (hidden > 0) {
298
+ lines.push(`${this.theme.fg("dim", "└─")} ${this.theme.fg("dim", `+${hidden} more`)}`);
299
+ } else if (lines.length > 1) {
300
+ lines[lines.length - 1] = lines[lines.length - 1]!.replace("├─", "└─");
298
301
  }
299
- const hiddenRuns = runs.length - renderedRuns;
300
- if (hiddenRuns > 0) lines.push(`${this.theme.fg("dim", "└─")} ${this.theme.fg("dim", `+${hiddenRuns} more run${hiddenRuns > 1 ? "s" : ""}`)}`);
301
302
  return lines;
302
303
  }
303
304
  }
@@ -460,9 +461,17 @@ class SubagentManager {
460
461
  if (!Array.isArray(raw)) return;
461
462
  runs = (raw as RunSnapshot[]).map((run) => {
462
463
  const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
464
+ // A persisted "running" run whose tasks are all terminal (crash between
465
+ // task end and run end) must not stay "running" forever.
466
+ let status = interrupted ? ("aborted" as RunStatus) : run.status;
467
+ if (!TERMINAL.includes(status)) {
468
+ const anyFailed = run.tasks.some((t) => t.status === "failed");
469
+ const anyAborted = run.tasks.some((t) => t.status === "aborted");
470
+ status = anyFailed ? "failed" : anyAborted ? "aborted" : "completed";
471
+ }
463
472
  return {
464
473
  ...run,
465
- status: interrupted ? ("aborted" as RunStatus) : run.status,
474
+ status,
466
475
  endedAt: interrupted ? Date.now() : run.endedAt,
467
476
  tasks: run.tasks.map((t) => (TERMINAL.includes(t.status) ? t : { ...t, status: "aborted" as TaskStatus, error: t.error || "Interrupted by session reload" })),
468
477
  };
@@ -486,7 +495,7 @@ class SubagentManager {
486
495
  const parentFile = getParentSessionFile(ctx);
487
496
  if (!parentFile) return;
488
497
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
489
- import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().map(cloneRun), null, 2)));
498
+ import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)));
490
499
  } catch {
491
500
  /* ignore */
492
501
  }
@@ -850,6 +859,13 @@ class SubagentManager {
850
859
  ? params.tasks!
851
860
  : params.chain!;
852
861
  if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
862
+ const ids = new Set<string>();
863
+ for (const input of inputs) {
864
+ if (input.id !== undefined) {
865
+ if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
866
+ ids.add(input.id);
867
+ }
868
+ }
853
869
 
854
870
  const run: RunSnapshot = {
855
871
  id: newId("run"),
@@ -909,7 +925,7 @@ class SubagentManager {
909
925
  const input = { ...inputs[i]!, task: next };
910
926
  task.task = input.task;
911
927
  await this.runChild(run, task, input, ctx, signal, onUpdate);
912
- if (run.notifyPerTask && TERMINAL.includes(task.status)) {
928
+ if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
913
929
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
914
930
  }
915
931
  if (task.status !== "completed") break;
@@ -919,7 +935,7 @@ class SubagentManager {
919
935
  await mapWithConcurrency(run.tasks, run.mode === "single" ? 1 : run.concurrency, async (task) => {
920
936
  const index = run.tasks.indexOf(task);
921
937
  await this.runChild(run, task, inputs[index]!, ctx, signal, onUpdate);
922
- if (run.notifyPerTask && TERMINAL.includes(task.status)) {
938
+ if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
923
939
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
924
940
  }
925
941
  });
@@ -969,7 +985,10 @@ class SubagentManager {
969
985
  }
970
986
  this.settleRun(run.id, run);
971
987
  this.runControllers.delete(run.id);
988
+ for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
989
+ this.emit("subagent:run-completed", { runId: run.id, status: "failed", run: cloneRun(run) });
972
990
  this.notifyParent(run, "failed");
991
+ this.persist(ctx);
973
992
  });
974
993
  return { run: cloneRun(run), background: true };
975
994
  }
@@ -988,7 +1007,7 @@ class SubagentManager {
988
1007
  for (const task of run.tasks) {
989
1008
  if (TERMINAL.includes(task.status)) continue;
990
1009
  task.status = "aborted";
991
- task.error = task.error || "Canceled by subagent_cancel";
1010
+ task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
992
1011
  task.endedAt = Date.now();
993
1012
  aborted += 1;
994
1013
  }
@@ -1026,9 +1045,10 @@ class SubagentManager {
1026
1045
  if (!timeoutMs) return settled;
1027
1046
  return Promise.race([
1028
1047
  settled,
1029
- new Promise<RunSnapshot | undefined>((resolve) =>
1030
- setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs),
1031
- ),
1048
+ new Promise<RunSnapshot | undefined>((resolve) => {
1049
+ const timer = setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs);
1050
+ settled.then(() => clearTimeout(timer));
1051
+ }),
1032
1052
  ]);
1033
1053
  }
1034
1054
  }
@@ -1113,8 +1133,7 @@ export default function (pi: ExtensionAPI) {
1113
1133
  ctx.ui.notify("No subagent runs in this session.", "info");
1114
1134
  return;
1115
1135
  }
1116
- ctx.ui.setWidget("subagents", runs.flatMap((run) => compactLines(run).concat("")), { placement: "aboveEditor" });
1117
- ctx.ui.notify(`Showing ${runs.length} subagent run(s).`, "info");
1136
+ ctx.ui.notify(runs.flatMap((run) => compactLines(run).concat("")).join("\n") || "No subagent runs in this session.", "info");
1118
1137
  },
1119
1138
  });
1120
1139
 
@@ -1144,7 +1163,7 @@ export default function (pi: ExtensionAPI) {
1144
1163
  promptSnippet: "Define and delegate work to specialized subagents.",
1145
1164
  promptGuidelines: [
1146
1165
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
1147
- "Decompose parallelizable work: if the request has 2+ independent sub-tasks (separate files, separate concerns, independent research/review), delegate each to its own subagent in ONE call with tasks[] (single shot), not multiple parallel subagent calls.",
1166
+ "Decompose parallelizable work: if the request has 2+ independent sub-tasks (separate files, separate concerns, independent research/review), spawn N agents with a SINGLE call: subagent({ tasks: [{agent, task}, ...] }). NEVER make multiple parallel subagent calls for parallel work — one call, one run, N tasks.",
1148
1167
  "If independent sub-tasks are sequential (each builds on the previous one's output), use chain mode with {previous}.",
1149
1168
  "Define each subagent yourself: an invented name, a focused system prompt (prompt:), and a toolset — read-only (default) or write (write:true).",
1150
1169
  "Prefer read-only subagents unless the task explicitly needs edits.",