@arhen/pi-core-subagent 1.1.5 → 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 +7 -7
  2. package/package.json +2 -2
  3. package/src/index.ts +78 -27
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # @arhen/pi-subagent
1
+ # @arhen/pi-core-subagent
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/%40arhen%2Fpi-subagent?color=cb3837&logo=npm)](https://www.npmjs.com/package/@arhen/pi-subagent)
3
+ [![npm version](https://img.shields.io/npm/v/%40arhen%2Fpi-core-subagent?color=cb3837&logo=npm)](https://www.npmjs.com/package/@arhen/pi-core-subagent)
4
4
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
5
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://github.com/earendil-works/pi)
6
6
 
@@ -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.
@@ -20,13 +20,13 @@ Built for one job: delegate work to isolated subagents **without bloating the pa
20
20
  ## Install
21
21
 
22
22
  ```sh
23
- pi install npm:@arhen/pi-subagent
23
+ pi install npm:@arhen/pi-core-subagent
24
24
  # or locally: pi install /path/to/pi-subagents
25
25
  ```
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.5",
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",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "repository": {
38
38
  "type": "git",
39
- "url": "git+https://github.com/arhen/pi-subagent.git"
39
+ "url": "git+https://github.com/arhen/pi-core-subagent.git"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public",
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 ?? []) {
@@ -396,7 +398,7 @@ class SubagentManager {
396
398
  private liveChildren = new Map<string, { abort: () => void; dispose: () => void; touchWatchdog: () => void }>();
397
399
  private mailboxes: Mailbox = createMailbox();
398
400
  private runControllers = new Map<string, AbortController>();
399
- private widgetTimer: ReturnType<typeof setTimeout> | undefined;
401
+ private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
400
402
  private widgetRuns: RunSnapshot[] = [];
401
403
 
402
404
  turnActivity = false;
@@ -431,14 +433,19 @@ class SubagentManager {
431
433
  return runId ? this.runs.get(runId) : undefined;
432
434
  }
433
435
  clearRuns(): void {
436
+ for (const child of this.liveChildren.values()) {
437
+ child.abort();
438
+ child.dispose();
439
+ }
440
+ this.liveChildren.clear();
434
441
  this.runs.clear();
435
442
  this.settlers.clear();
436
443
  this.pendingReplies.clear();
437
444
  this.runControllers.clear();
438
445
  this.mailboxes = createMailbox();
439
446
  this.widgetTui = null; // force re-registration on the next session
440
- if (this.widgetTimer) clearTimeout(this.widgetTimer);
441
- this.widgetTimer = undefined;
447
+ for (const t of this.widgetTimers.values()) clearTimeout(t);
448
+ this.widgetTimers.clear();
442
449
  this.widgetRuns = [];
443
450
  }
444
451
 
@@ -455,9 +462,17 @@ class SubagentManager {
455
462
  if (!Array.isArray(raw)) return;
456
463
  runs = (raw as RunSnapshot[]).map((run) => {
457
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
+ }
458
473
  return {
459
474
  ...run,
460
- status: interrupted ? ("aborted" as RunStatus) : run.status,
475
+ status,
461
476
  endedAt: interrupted ? Date.now() : run.endedAt,
462
477
  tasks: run.tasks.map((t) => (TERMINAL.includes(t.status) ? t : { ...t, status: "aborted" as TaskStatus, error: t.error || "Interrupted by session reload" })),
463
478
  };
@@ -481,7 +496,7 @@ class SubagentManager {
481
496
  const parentFile = getParentSessionFile(ctx);
482
497
  if (!parentFile) return;
483
498
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
484
- 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)));
485
500
  } catch {
486
501
  /* ignore */
487
502
  }
@@ -531,22 +546,23 @@ class SubagentManager {
531
546
  }
532
547
  private scheduleWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
533
548
  this.upsertWidgetRun(run);
534
- if (this.widgetTimer || this.widgetRuns.length === 0) return;
535
- this.widgetTimer = setTimeout(() => {
536
- this.widgetTimer = undefined;
537
- if (this.widgetRuns.length === 0) return;
538
- const targets = [...this.widgetRuns]; // read at fire: never render stale runs
549
+ if (!run || this.widgetTimers.has(run.id)) return;
550
+ this.widgetTimers.set(run.id, setTimeout(() => {
551
+ this.widgetTimers.delete(run.id);
539
552
  if (ctx?.hasUI) {
540
553
  this.ensureWidget(ctx);
541
554
  this.widgetTui?.requestRender();
542
555
  }
543
- onUpdate?.({ content: [{ type: "text", text: compactLines(run ?? targets[0]!).join("\n") }] });
544
- }, WIDGET_THROTTLE_MS);
556
+ onUpdate?.({ content: [{ type: "text", text: compactLines(run).join("\n") }] });
557
+ }, WIDGET_THROTTLE_MS));
545
558
  }
546
559
  private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
547
- if (this.widgetTimer) {
548
- clearTimeout(this.widgetTimer);
549
- this.widgetTimer = undefined;
560
+ if (run) {
561
+ const t = this.widgetTimers.get(run.id);
562
+ if (t) {
563
+ clearTimeout(t);
564
+ this.widgetTimers.delete(run.id);
565
+ }
550
566
  }
551
567
  if (!run || this.widgetRuns.length === 0) return;
552
568
  if (ctx?.hasUI) {
@@ -586,6 +602,13 @@ class SubagentManager {
586
602
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
587
603
  this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
588
604
  this.notifyParent(run, "asked", { taskId: task.id, question });
605
+ // A blocking run's parent can't reply mid-tool (followUp only fires after the
606
+ // tool returns) — only background runs can truly wait for the answer.
607
+ if (!run.background) {
608
+ this.updateTask(run, task, { status: "running" }, ctx);
609
+ this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
610
+ return "Parent cannot answer while this run is blocking. Continue autonomously with your best judgment.";
611
+ }
589
612
  const reply = await this.awaitParentReply(run.id, task.id);
590
613
  this.updateTask(run, task, { status: "running" }, ctx);
591
614
  this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
@@ -593,10 +616,24 @@ class SubagentManager {
593
616
  },
594
617
  onNotifyParent: (_taskId, message, level) => {
595
618
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
619
+ if (!run.awaited) {
620
+ try {
621
+ this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
622
+ } catch {
623
+ /* parent mid-stream */
624
+ }
625
+ }
596
626
  },
597
627
  onSendMessage: (_taskId, to, text) => {
598
628
  if (to === "leader") {
599
629
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level: "info", message: text });
630
+ if (!run.awaited) {
631
+ try {
632
+ this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
633
+ } catch {
634
+ /* parent mid-stream */
635
+ }
636
+ }
600
637
  return true;
601
638
  }
602
639
  // Run-scoped keys: sibling ids are run-local; cross-run task_1 can never collide.
@@ -706,7 +743,7 @@ class SubagentManager {
706
743
  });
707
744
 
708
745
  unsubscribe = child.subscribe((event: AgentSessionEvent) => {
709
- const active = event.type === "message_update" || event.type === "message_end" || event.type === "tool_execution_start" || event.type === "tool_execution_end" || event.type === "agent_settled";
746
+ const active = event.type === "message_update" || event.type === "message_end" || event.type === "tool_execution_start" || event.type === "tool_execution_update" || event.type === "tool_execution_end" || event.type === "bash_execution_update" || event.type === "agent_settled";
710
747
  if (active) {
711
748
  watchdog.touch();
712
749
  this.emit("subagent:session-event", { runId: run.id, taskId: task.id, seq: eventSeq++, event: { type: event.type } });
@@ -745,7 +782,10 @@ class SubagentManager {
745
782
  }
746
783
  });
747
784
 
748
- const abortChild = () => void child?.abort();
785
+ const abortChild = () => {
786
+ void child?.abort();
787
+ this.runControllers.get(run.id)?.abort(); // parent abort kills ALL siblings, not just this child
788
+ };
749
789
  const runController = this.runControllers.get(run.id);
750
790
  if (signal) signal.addEventListener("abort", abortChild, { once: true });
751
791
  if (runController) runController.signal.addEventListener("abort", abortChild, { once: true });
@@ -820,6 +860,13 @@ class SubagentManager {
820
860
  ? params.tasks!
821
861
  : params.chain!;
822
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
+ }
823
870
 
824
871
  const run: RunSnapshot = {
825
872
  id: newId("run"),
@@ -879,7 +926,7 @@ class SubagentManager {
879
926
  const input = { ...inputs[i]!, task: next };
880
927
  task.task = input.task;
881
928
  await this.runChild(run, task, input, ctx, signal, onUpdate);
882
- if (run.notifyPerTask && TERMINAL.includes(task.status)) {
929
+ if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
883
930
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
884
931
  }
885
932
  if (task.status !== "completed") break;
@@ -889,7 +936,7 @@ class SubagentManager {
889
936
  await mapWithConcurrency(run.tasks, run.mode === "single" ? 1 : run.concurrency, async (task) => {
890
937
  const index = run.tasks.indexOf(task);
891
938
  await this.runChild(run, task, inputs[index]!, ctx, signal, onUpdate);
892
- if (run.notifyPerTask && TERMINAL.includes(task.status)) {
939
+ if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
893
940
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
894
941
  }
895
942
  });
@@ -939,7 +986,10 @@ class SubagentManager {
939
986
  }
940
987
  this.settleRun(run.id, run);
941
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) });
942
991
  this.notifyParent(run, "failed");
992
+ this.persist(ctx);
943
993
  });
944
994
  return { run: cloneRun(run), background: true };
945
995
  }
@@ -947,6 +997,7 @@ class SubagentManager {
947
997
  cancelRun(runId: string): { aborted: number } {
948
998
  const run = this.runs.get(runId);
949
999
  if (!run) return { aborted: 0 };
1000
+ if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
950
1001
  let aborted = 0;
951
1002
  this.runControllers.get(runId)?.abort();
952
1003
  for (const [key, child] of this.liveChildren) {
@@ -957,7 +1008,7 @@ class SubagentManager {
957
1008
  for (const task of run.tasks) {
958
1009
  if (TERMINAL.includes(task.status)) continue;
959
1010
  task.status = "aborted";
960
- task.error = task.error || "Canceled by subagent_cancel";
1011
+ task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
961
1012
  task.endedAt = Date.now();
962
1013
  aborted += 1;
963
1014
  }
@@ -995,9 +1046,10 @@ class SubagentManager {
995
1046
  if (!timeoutMs) return settled;
996
1047
  return Promise.race([
997
1048
  settled,
998
- new Promise<RunSnapshot | undefined>((resolve) =>
999
- setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs),
1000
- ),
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
+ }),
1001
1053
  ]);
1002
1054
  }
1003
1055
  }
@@ -1082,8 +1134,7 @@ export default function (pi: ExtensionAPI) {
1082
1134
  ctx.ui.notify("No subagent runs in this session.", "info");
1083
1135
  return;
1084
1136
  }
1085
- ctx.ui.setWidget("subagents", runs.flatMap((run) => compactLines(run).concat("")), { placement: "aboveEditor" });
1086
- 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");
1087
1138
  },
1088
1139
  });
1089
1140