@arhen/pi-core-subagent 1.1.7 → 1.1.9

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/README.md CHANGED
@@ -15,7 +15,7 @@ Built for one job: delegate work to isolated subagents **without bloating the pa
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.
17
17
  - **Throttled updates** — widget/stream updates coalesce to ~6/s; no per-event deep clones.
18
- - **No silent hangs** — watchdog aborts children that produce no events for 90s; per-task timeout 10min.
18
+ - **No silent hangs** — watchdog aborts children that produce no events for 3 minutes; per-task timeout 10min.
19
19
 
20
20
  ## Install
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
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/child.ts CHANGED
@@ -98,8 +98,8 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
98
98
  if (messages.length === 0) return { content: [{ type: "text" as const, text: "No messages." }], details: {} };
99
99
  const body = messages
100
100
  .map((m) => `from ${m.from}: ${m.text}`)
101
- .join("\n")
102
- .slice(0, 4000);
101
+ .join("\n");
102
+ const capped = body.length > 4000 ? body.slice(0, 4000).replace(/[\uD800-\uDBFF]$/, "") : body; // multibyte-safe
103
103
  return { content: [{ type: "text" as const, text: body }], details: { messages } };
104
104
  },
105
105
  },
package/src/index.ts CHANGED
@@ -253,8 +253,6 @@ function compactLines(run: RunSnapshot): string[] {
253
253
  * └─ ✓ reviewer · 6 tools · 44s
254
254
  * Static icons (no animation); latest activity + tool count + runtime per agent.
255
255
  */
256
- const WIDGET_MAX_RUNS = 3;
257
- const WIDGET_MAX_TASKS_PER_RUN = 4;
258
256
  const WIDGET_MAX_LINES = 10;
259
257
 
260
258
  class SubagentsWidget implements Component {
@@ -268,38 +266,39 @@ class SubagentsWidget implements Component {
268
266
  }
269
267
 
270
268
  render(width: number): string[] {
271
- 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);
272
272
  if (runs.length === 0) return [];
273
- const lines: string[] = [];
274
- let renderedRuns = 0;
275
- for (const run of runs.slice(0, WIDGET_MAX_RUNS)) {
276
- if (run.tasks.length === 0) continue;
277
- if (lines.length > 0) lines.push("");
278
- const done = run.tasks.filter((t) => TERMINAL.includes(t.status)).length;
279
- const active = !TERMINAL.includes(run.status);
280
- const head = active ? "accent" : "dim";
281
- 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) {
282
281
  const allDone = TERMINAL.includes(run.status);
283
- const visible = run.tasks.slice(0, WIDGET_MAX_TASKS_PER_RUN);
284
- visible.forEach((task, i) => {
285
- const last = i === visible.length - 1 && run.tasks.length <= WIDGET_MAX_TASKS_PER_RUN;
286
- const conn = this.theme.fg("dim", last ? "└─" : "├─");
282
+ for (const task of run.tasks) {
283
+ if (shown >= budget) break outer;
284
+ shown += 1;
287
285
  const activity =
288
286
  !TERMINAL.includes(task.status) && task.lastActivity
289
287
  ? `${this.theme.fg("dim", `→ ${task.lastActivity}`)} · `
290
288
  : "";
291
- // Finished run: dim everything except the agent name.
289
+ // Tasks of a finished run: dim everything except the agent name.
292
290
  const line = allDone
293
291
  ? `${this.theme.fg("dim", `${statusIcon(task.status)} `)}${task.agent} ${this.theme.fg("dim", `· ${taskStatsWithUsage(task)} · ${taskTimer(task)}`)}`
294
292
  : `${statusIcon(task.status)} ${task.agent} · ${activity}${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
295
- lines.push(truncateToWidth(`${conn} ${line}`, width, "…"));
296
- });
297
- 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`)}`);
298
- renderedRuns += 1;
299
- 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("├─", "└─");
300
301
  }
301
- const hiddenRuns = runs.length - renderedRuns;
302
- if (hiddenRuns > 0) lines.push(`${this.theme.fg("dim", "└─")} ${this.theme.fg("dim", `+${hiddenRuns} more run${hiddenRuns > 1 ? "s" : ""}`)}`);
303
302
  return lines;
304
303
  }
305
304
  }
@@ -308,7 +307,8 @@ function makeSummary(run: RunSnapshot): string {
308
307
  const succeeded = run.tasks.filter((t) => t.status === "completed").length;
309
308
  const failed = run.tasks.filter((t) => t.status === "failed").length;
310
309
  const aborted = run.tasks.filter((t) => t.status === "aborted").length;
311
- const lines = [`Run ${run.id}: Subagents ${run.mode}${run.background ? " (background)" : ""} finished: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`];
310
+ const done = TERMINAL.includes(run.status) ? "finished" : "running";
311
+ const lines = [`Run ${run.id}: Subagents ${run.mode}${run.background ? " (background)" : ""} ${done}: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`];
312
312
  const usage = formatUsage(run.aggregateUsage);
313
313
  if (usage) lines.push(`Usage: ${usage}`);
314
314
  for (const task of run.tasks) {
@@ -496,7 +496,9 @@ class SubagentManager {
496
496
  const parentFile = getParentSessionFile(ctx);
497
497
  if (!parentFile) return;
498
498
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
499
- import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)));
499
+ import("fs")
500
+ .then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)))
501
+ .catch(() => {}); // never surface as an unhandled rejection
500
502
  } catch {
501
503
  /* ignore */
502
504
  }
@@ -534,8 +536,7 @@ class SubagentManager {
534
536
  }
535
537
 
536
538
  // Widget: register-once + requestRender (todo-overlay pattern).
537
- // The component self-animates the spinner via its own 100ms interval;
538
- // scheduleWidget just throttles status changes into requestRender calls.
539
+ // scheduleWidget throttles status changes into requestRender calls.
539
540
  private widgetTui: TUI | null = null;
540
541
  /** Upsert a run into the widget's visible set (all runs, not just the latest). */
541
542
  private upsertWidgetRun(run: RunSnapshot | undefined): void {
@@ -601,7 +602,6 @@ class SubagentManager {
601
602
  onAskParent: async (_taskId, question) => {
602
603
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
603
604
  this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
604
- this.notifyParent(run, "asked", { taskId: task.id, question });
605
605
  // A blocking run's parent can't reply mid-tool (followUp only fires after the
606
606
  // tool returns) — only background runs can truly wait for the answer.
607
607
  if (!run.background) {
@@ -609,10 +609,17 @@ class SubagentManager {
609
609
  this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
610
610
  return "Parent cannot answer while this run is blocking. Continue autonomously with your best judgment.";
611
611
  }
612
- const reply = await this.awaitParentReply(run.id, task.id);
613
- this.updateTask(run, task, { status: "running" }, ctx);
614
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
615
- return reply;
612
+ this.notifyParent(run, "asked", { taskId: task.id, question });
613
+ // M3: a waiting child is not stalled — keep the watchdog fed until the reply.
614
+ const keepAlive = setInterval(() => this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog(), 30_000);
615
+ try {
616
+ const reply = await this.awaitParentReply(run.id, task.id);
617
+ this.updateTask(run, task, { status: "running" }, ctx);
618
+ this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
619
+ return reply;
620
+ } finally {
621
+ clearInterval(keepAlive);
622
+ }
616
623
  },
617
624
  onNotifyParent: (_taskId, message, level) => {
618
625
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
@@ -794,7 +801,7 @@ class SubagentManager {
794
801
  runController?.signal.removeEventListener("abort", abortChild);
795
802
  };
796
803
  // Cancel may have landed during session creation — honor it before prompting.
797
- if (run.status === "aborted" || TERMINAL.includes(task.status)) {
804
+ if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
798
805
  await child.abort();
799
806
  throw new Error("Canceled by subagent_cancel");
800
807
  }
@@ -929,7 +936,7 @@ class SubagentManager {
929
936
  if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
930
937
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
931
938
  }
932
- if (task.status !== "completed") break;
939
+ if (task.status !== "completed") break; // aborted/failed link stops the chain
933
940
  previous = task.finalText ?? "";
934
941
  }
935
942
  } else {
@@ -954,8 +961,11 @@ class SubagentManager {
954
961
  } else {
955
962
  this.clearWidget(ctx);
956
963
  }
957
- this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
958
- this.settleRun(run.id, run);
964
+ // L7: cancelRun already emitted + settled don't double-report.
965
+ if (this.settlers.has(run.id)) {
966
+ this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
967
+ this.settleRun(run.id, run);
968
+ }
959
969
  this.runControllers.delete(run.id);
960
970
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
961
971
  this.persist(ctx);
@@ -1139,7 +1149,7 @@ export default function (pi: ExtensionAPI) {
1139
1149
  });
1140
1150
 
1141
1151
  pi.on("agent_start", (_event, ctx) => {
1142
- if (!manager.turnActivity) manager.clearWidget(ctx);
1152
+ if (!manager.turnActivity && !manager.hasActiveRun()) manager.clearWidget(ctx);
1143
1153
  manager.turnActivity = false;
1144
1154
  });
1145
1155
 
@@ -1164,7 +1174,7 @@ export default function (pi: ExtensionAPI) {
1164
1174
  promptSnippet: "Define and delegate work to specialized subagents.",
1165
1175
  promptGuidelines: [
1166
1176
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
1167
- "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.",
1177
+ "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.",
1168
1178
  "If independent sub-tasks are sequential (each builds on the previous one's output), use chain mode with {previous}.",
1169
1179
  "Define each subagent yourself: an invented name, a focused system prompt (prompt:), and a toolset — read-only (default) or write (write:true).",
1170
1180
  "Prefer read-only subagents unless the task explicitly needs edits.",