@arhen/pi-core-subagent 1.1.8 → 1.1.10

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.8",
3
+ "version": "1.1.10",
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
@@ -12,12 +12,13 @@
12
12
  */
13
13
 
14
14
  import type { AgentSessionEvent, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
15
- import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
15
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
16
16
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
17
17
  import { StringEnum, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
18
18
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
19
19
  import type { Component, TUI } from "@earendil-works/pi-tui";
20
20
  import { Type } from "typebox";
21
+ import { join } from "node:path";
21
22
  import { CHILD_TALK_TOOLS, createChildTools, createWatchdog, type ChildHandlers } from "./child.ts";
22
23
  import { createMailbox, type Mailbox } from "./mailbox.ts";
23
24
 
@@ -307,7 +308,8 @@ function makeSummary(run: RunSnapshot): string {
307
308
  const succeeded = run.tasks.filter((t) => t.status === "completed").length;
308
309
  const failed = run.tasks.filter((t) => t.status === "failed").length;
309
310
  const aborted = run.tasks.filter((t) => t.status === "aborted").length;
310
- const lines = [`Run ${run.id}: Subagents ${run.mode}${run.background ? " (background)" : ""} finished: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`];
311
+ const done = TERMINAL.includes(run.status) ? "finished" : "running";
312
+ const lines = [`Run ${run.id}: Subagents ${run.mode}${run.background ? " (background)" : ""} ${done}: ${succeeded}/${run.tasks.length} succeeded${failed ? `, ${failed} failed` : ""}${aborted ? `, ${aborted} aborted` : ""}.`];
311
313
  const usage = formatUsage(run.aggregateUsage);
312
314
  if (usage) lines.push(`Usage: ${usage}`);
313
315
  for (const task of run.tasks) {
@@ -340,20 +342,40 @@ function cloneRun(run: RunSnapshot): RunSnapshot {
340
342
  * Order: explicit "provider/model-id" or bare id (searched across available
341
343
  * models) → agent file model → parent's current model (ctx.model) → undefined
342
344
  * (createAgentSession falls back to settings). */
343
- function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
344
- if (explicit?.trim()) {
345
- const ref = explicit.trim();
346
- const slash = ref.indexOf("/");
347
- if (slash > 0 && slash < ref.length - 1) {
348
- const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
349
- if (!model) throw new Error(`Model not found: ${ref}`);
350
- return model;
345
+ export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
346
+ if (!explicit?.trim()) return ctx.model; // inherit the parent's active model
347
+ const ref = explicit.trim();
348
+ const available = ctx.modelRegistry.getAvailable();
349
+ // Model ids can contain slashes (e.g. 9router/cc/claude-opus-5), so a bare id
350
+ // match and every provider/id split point must be tried, not just the first.
351
+ const byId = available.find((m) => m.id === ref);
352
+ if (byId) return byId;
353
+ for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
354
+ const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
355
+ if (model) return model;
356
+ }
357
+ throw new Error(`Model not found: ${ref}`);
358
+ }
359
+
360
+ /** Extension-registered providers (e.g. 9router) live only in the parent's
361
+ * in-memory runtime. A child builds its runtime from disk and would lose them,
362
+ * so replay the parent's registrations before the child resolves auth. */
363
+ async function createChildModelRuntime(ctx: ExtensionContext) {
364
+ const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
365
+ if (ids.length === 0) return undefined; // no extension providers: disk runtime is enough
366
+ const agentDir = getAgentDir();
367
+ const runtime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
368
+ for (const id of ids) {
369
+ const native = ctx.modelRegistry.getRegisteredNativeProvider?.(id);
370
+ if (native) {
371
+ runtime.registerNativeProvider(native);
372
+ continue;
351
373
  }
352
- const byId = ctx.modelRegistry.getAvailable().find((m) => m.id === ref);
353
- if (!byId) throw new Error(`Model not found: ${ref}`);
354
- return byId;
374
+ const config = ctx.modelRegistry.getRegisteredProviderConfig?.(id);
375
+ if (config) runtime.registerProvider(id, config);
355
376
  }
356
- return ctx.model; // inherit the parent's active model
377
+ await runtime.refresh({ allowNetwork: false });
378
+ return runtime;
357
379
  }
358
380
 
359
381
  /** Validate a thinking level against the RESOLVED model's registry entry.
@@ -495,7 +517,9 @@ class SubagentManager {
495
517
  const parentFile = getParentSessionFile(ctx);
496
518
  if (!parentFile) return;
497
519
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
498
- import("fs").then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)));
520
+ import("fs")
521
+ .then(({ writeFileSync }) => writeFileSync(sidecar, JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2)))
522
+ .catch(() => {}); // never surface as an unhandled rejection
499
523
  } catch {
500
524
  /* ignore */
501
525
  }
@@ -533,8 +557,7 @@ class SubagentManager {
533
557
  }
534
558
 
535
559
  // Widget: register-once + requestRender (todo-overlay pattern).
536
- // The component self-animates the spinner via its own 100ms interval;
537
- // scheduleWidget just throttles status changes into requestRender calls.
560
+ // scheduleWidget throttles status changes into requestRender calls.
538
561
  private widgetTui: TUI | null = null;
539
562
  /** Upsert a run into the widget's visible set (all runs, not just the latest). */
540
563
  private upsertWidgetRun(run: RunSnapshot | undefined): void {
@@ -600,7 +623,6 @@ class SubagentManager {
600
623
  onAskParent: async (_taskId, question) => {
601
624
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
602
625
  this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
603
- this.notifyParent(run, "asked", { taskId: task.id, question });
604
626
  // A blocking run's parent can't reply mid-tool (followUp only fires after the
605
627
  // tool returns) — only background runs can truly wait for the answer.
606
628
  if (!run.background) {
@@ -608,10 +630,17 @@ class SubagentManager {
608
630
  this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
609
631
  return "Parent cannot answer while this run is blocking. Continue autonomously with your best judgment.";
610
632
  }
611
- const reply = await this.awaitParentReply(run.id, task.id);
612
- this.updateTask(run, task, { status: "running" }, ctx);
613
- this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
614
- return reply;
633
+ this.notifyParent(run, "asked", { taskId: task.id, question });
634
+ // M3: a waiting child is not stalled — keep the watchdog fed until the reply.
635
+ const keepAlive = setInterval(() => this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog(), 30_000);
636
+ try {
637
+ const reply = await this.awaitParentReply(run.id, task.id);
638
+ this.updateTask(run, task, { status: "running" }, ctx);
639
+ this.liveChildren.get(`${run.id}:${task.id}`)?.touchWatchdog();
640
+ return reply;
641
+ } finally {
642
+ clearInterval(keepAlive);
643
+ }
615
644
  },
616
645
  onNotifyParent: (_taskId, message, level) => {
617
646
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
@@ -723,6 +752,7 @@ class SubagentManager {
723
752
  const created = await createAgentSession({
724
753
  cwd: task.cwd,
725
754
  agentDir: getAgentDir(),
755
+ modelRuntime: await createChildModelRuntime(ctx),
726
756
  resourceLoader: loader,
727
757
  sessionManager: SessionManager.create(task.cwd, undefined, { parentSession: getParentSessionFile(ctx) }),
728
758
  model,
@@ -793,7 +823,7 @@ class SubagentManager {
793
823
  runController?.signal.removeEventListener("abort", abortChild);
794
824
  };
795
825
  // Cancel may have landed during session creation — honor it before prompting.
796
- if (run.status === "aborted" || TERMINAL.includes(task.status)) {
826
+ if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
797
827
  await child.abort();
798
828
  throw new Error("Canceled by subagent_cancel");
799
829
  }
@@ -928,7 +958,7 @@ class SubagentManager {
928
958
  if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
929
959
  this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
930
960
  }
931
- if (task.status !== "completed") break;
961
+ if (task.status !== "completed") break; // aborted/failed link stops the chain
932
962
  previous = task.finalText ?? "";
933
963
  }
934
964
  } else {
@@ -953,8 +983,11 @@ class SubagentManager {
953
983
  } else {
954
984
  this.clearWidget(ctx);
955
985
  }
956
- this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
957
- this.settleRun(run.id, run);
986
+ // L7: cancelRun already emitted + settled don't double-report.
987
+ if (this.settlers.has(run.id)) {
988
+ this.emit("subagent:run-completed", { runId: run.id, status: run.status, run: cloneRun(run), aggregateUsage: run.aggregateUsage });
989
+ this.settleRun(run.id, run);
990
+ }
958
991
  this.runControllers.delete(run.id);
959
992
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
960
993
  this.persist(ctx);
@@ -1138,7 +1171,7 @@ export default function (pi: ExtensionAPI) {
1138
1171
  });
1139
1172
 
1140
1173
  pi.on("agent_start", (_event, ctx) => {
1141
- if (!manager.turnActivity) manager.clearWidget(ctx);
1174
+ if (!manager.turnActivity && !manager.hasActiveRun()) manager.clearWidget(ctx);
1142
1175
  manager.turnActivity = false;
1143
1176
  });
1144
1177