@arhen/pi-core-subagent 1.1.6 → 1.1.7

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 +32 -12
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.7",
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 ?? []) {
@@ -460,9 +462,17 @@ class SubagentManager {
460
462
  if (!Array.isArray(raw)) return;
461
463
  runs = (raw as RunSnapshot[]).map((run) => {
462
464
  const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
465
+ // A persisted "running" run whose tasks are all terminal (crash between
466
+ // task end and run end) must not stay "running" forever.
467
+ let status = interrupted ? ("aborted" as RunStatus) : run.status;
468
+ if (!TERMINAL.includes(status)) {
469
+ const anyFailed = run.tasks.some((t) => t.status === "failed");
470
+ const anyAborted = run.tasks.some((t) => t.status === "aborted");
471
+ status = anyFailed ? "failed" : anyAborted ? "aborted" : "completed";
472
+ }
463
473
  return {
464
474
  ...run,
465
- status: interrupted ? ("aborted" as RunStatus) : run.status,
475
+ status,
466
476
  endedAt: interrupted ? Date.now() : run.endedAt,
467
477
  tasks: run.tasks.map((t) => (TERMINAL.includes(t.status) ? t : { ...t, status: "aborted" as TaskStatus, error: t.error || "Interrupted by session reload" })),
468
478
  };
@@ -486,7 +496,7 @@ class SubagentManager {
486
496
  const parentFile = getParentSessionFile(ctx);
487
497
  if (!parentFile) return;
488
498
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
489
- import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().map(cloneRun), null, 2)));
499
+ import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)));
490
500
  } catch {
491
501
  /* ignore */
492
502
  }
@@ -850,6 +860,13 @@ class SubagentManager {
850
860
  ? params.tasks!
851
861
  : params.chain!;
852
862
  if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
863
+ const ids = new Set<string>();
864
+ for (const input of inputs) {
865
+ if (input.id !== undefined) {
866
+ if (ids.has(input.id)) throw new Error(`Duplicate task id: ${input.id}`);
867
+ ids.add(input.id);
868
+ }
869
+ }
853
870
 
854
871
  const run: RunSnapshot = {
855
872
  id: newId("run"),
@@ -909,7 +926,7 @@ class SubagentManager {
909
926
  const input = { ...inputs[i]!, task: next };
910
927
  task.task = input.task;
911
928
  await this.runChild(run, task, input, ctx, signal, onUpdate);
912
- if (run.notifyPerTask && TERMINAL.includes(task.status)) {
929
+ if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
913
930
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
914
931
  }
915
932
  if (task.status !== "completed") break;
@@ -919,7 +936,7 @@ class SubagentManager {
919
936
  await mapWithConcurrency(run.tasks, run.mode === "single" ? 1 : run.concurrency, async (task) => {
920
937
  const index = run.tasks.indexOf(task);
921
938
  await this.runChild(run, task, inputs[index]!, ctx, signal, onUpdate);
922
- if (run.notifyPerTask && TERMINAL.includes(task.status)) {
939
+ if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
923
940
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
924
941
  }
925
942
  });
@@ -969,7 +986,10 @@ class SubagentManager {
969
986
  }
970
987
  this.settleRun(run.id, run);
971
988
  this.runControllers.delete(run.id);
989
+ for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
990
+ this.emit("subagent:run-completed", { runId: run.id, status: "failed", run: cloneRun(run) });
972
991
  this.notifyParent(run, "failed");
992
+ this.persist(ctx);
973
993
  });
974
994
  return { run: cloneRun(run), background: true };
975
995
  }
@@ -988,7 +1008,7 @@ class SubagentManager {
988
1008
  for (const task of run.tasks) {
989
1009
  if (TERMINAL.includes(task.status)) continue;
990
1010
  task.status = "aborted";
991
- task.error = task.error || "Canceled by subagent_cancel";
1011
+ task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
992
1012
  task.endedAt = Date.now();
993
1013
  aborted += 1;
994
1014
  }
@@ -1026,9 +1046,10 @@ class SubagentManager {
1026
1046
  if (!timeoutMs) return settled;
1027
1047
  return Promise.race([
1028
1048
  settled,
1029
- new Promise<RunSnapshot | undefined>((resolve) =>
1030
- setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs),
1031
- ),
1049
+ new Promise<RunSnapshot | undefined>((resolve) => {
1050
+ const timer = setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs);
1051
+ settled.then(() => clearTimeout(timer));
1052
+ }),
1032
1053
  ]);
1033
1054
  }
1034
1055
  }
@@ -1113,8 +1134,7 @@ export default function (pi: ExtensionAPI) {
1113
1134
  ctx.ui.notify("No subagent runs in this session.", "info");
1114
1135
  return;
1115
1136
  }
1116
- ctx.ui.setWidget("subagents", runs.flatMap((run) => compactLines(run).concat("")), { placement: "aboveEditor" });
1117
- ctx.ui.notify(`Showing ${runs.length} subagent run(s).`, "info");
1137
+ ctx.ui.notify(runs.flatMap((run) => compactLines(run).concat("")).join("\n") || "No subagent runs in this session.", "info");
1118
1138
  },
1119
1139
  });
1120
1140