@henryqw/pi-subagent 2.3.4 → 2.4.0

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/CONTEXT.md CHANGED
@@ -18,6 +18,7 @@ Provide validated user Roles, shared task-model Pi launch policy, generic manage
18
18
  ## Invariants
19
19
 
20
20
  - One Delegated Task creates one ephemeral child process and no saved session. Its soft deadline is 10 minutes; active model/tool execution or activity within the last minute grants one 5-minute grace period before a hard stop.
21
+ - Up to four active ephemeral `delegate_task` children run per Main; excess calls wait FIFO. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
21
22
  - Ambient child extensions and Skills stay disabled; Role explicitly selects extension sources and named Skills. Pi loads Skills supplied by those extension packages or their resource discovery. Omitted Role tools use Pi's effective `defaultTools`; an explicit list sets base tools while loaded extension tools activate automatically.
22
23
  - Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation.
23
24
  - Main selects Role and may override Model Class per task; omitted class uses shared `pi-subagent/delegateTask` assignment, initially `balanced`. Library callers select Role plus their own shared task ID.
package/README.md CHANGED
@@ -21,11 +21,13 @@ pi install npm:@henryqw/pi-subagent
21
21
 
22
22
  | Surface | Type | Purpose |
23
23
  | --- | --- | --- |
24
- | `delegate_task` | tool | Start one isolated child for `role`, `task`, and optional `modelClass`. |
24
+ | `delegate_task` | tool | Start one isolated child for `role`, `task`, and optional `model` or `modelClass`. |
25
+
26
+ An explicit `model` (`provider/modelId`) overrides `modelClass` and resolves against the currently available text models; an unknown reference rejects with the list of available models. Thinking level defaults to `medium` when supported, otherwise the highest supported level.
25
27
 
26
28
  `modelClass` is `fast`, `balanced`, or `frontier`. Omitted class uses the shared `pi-subagent/delegateTask` assignment, which defaults to `balanced`. Primary route is resolved against current scoped text models; fallback is tried only before launch. If no route is usable, delegation rejects with `Run /task-models`. A started child is never retried.
27
29
 
28
- Main splits broad work into independent bounded tasks and keeps integration and cross-cutting decisions. Each `task` states its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation. Each call uses the least capable `modelClass` that can reliably complete its task. Independent sibling calls can run concurrently; concurrent edit tasks must own non-overlapping files.
30
+ Main splits broad work into independent bounded tasks and keeps integration and cross-cutting decisions. Each `task` states its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation. Each call uses the least capable `modelClass` that can reliably complete its task. Independent sibling calls can run concurrently; concurrent edit tasks must own non-overlapping files. Up to four active ephemeral `delegate_task` children run per Main; excess calls wait FIFO. Queued calls do not start a child or consume child timeout. Managed Herdr workers are unaffected.
29
31
 
30
32
  Each call starts one isolated child (`pi --mode json -p --no-session`). Ambient extensions and Skills are off. Role/caller extensions load; those packages' tools and Skills auto-load, plus any extra `skills` names. Child uses the delegated working directory and Main's project approval. Abort kills the child process group. An inactive child times out after 10 minutes; current model/tool execution or activity in the last minute grants one 5-minute grace period, then the child stops. Streaming output is capped at 50 KiB. Unused JSON event types are discarded before payload buffering; consumed or unclassifiable events above 1 MiB fail delegation.
31
33
 
@@ -4,7 +4,15 @@ import { basename } from "node:path";
4
4
  import { StringEnum } from "@earendil-works/pi-ai";
5
5
  import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
7
- import { modelReference, PROFILE_NAMES, type ProfileName } from "@henryqw/pi-task-models";
7
+ import {
8
+ availableTaskModels,
9
+ modelReference,
10
+ PROFILE_NAMES,
11
+ type ProfileName,
12
+ resolveAvailableModel,
13
+ type ResolvedTaskRoute,
14
+ taskThinkingLevels,
15
+ } from "@henryqw/pi-task-models";
8
16
  import { Type } from "typebox";
9
17
  import { createRoleLaunch, isProfileName, loadRoles, resolveRoleLaunch, resolveTaskRoute } from "@henryqw/pi-subagent";
10
18
 
@@ -12,6 +20,7 @@ const MODEL_CLASSES = PROFILE_NAMES;
12
20
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
13
21
  const MAX_OUTPUT_BYTES = 50 * 1024;
14
22
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
23
+ const MAX_ACTIVE_CHILDREN = 4;
15
24
  const CONSUMED_JSON_EVENTS = new Set(["message_start", "message_update", "message_end"]);
16
25
  const JSON_EVENT_TYPE = /^\s*\{\s*"type"\s*:\s*"([^"\\]+)"/;
17
26
  const WIDGET_KEY = "subagent-status";
@@ -414,11 +423,24 @@ const Parameters = Type.Object({
414
423
  task: Type.String({
415
424
  description: "Bounded task packet: objective; exact scope and exclusions; relevant context and constraints; expected deliverable; validation. Never the whole parent request.",
416
425
  }),
426
+ model: Type.Optional(Type.String({
427
+ description: "Designated model as provider/modelId; overrides modelClass. Unknown references reject with the list of available models.",
428
+ })),
417
429
  modelClass: Type.Optional(StringEnum(MODEL_CLASSES, {
418
430
  description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning. Defaults to the shared pi-subagent/delegateTask assignment.",
419
431
  })),
420
432
  });
421
433
 
434
+ function resolveDesignatedRoute(ctx: ExtensionContext, reference: string): ResolvedTaskRoute {
435
+ const models = availableTaskModels(ctx);
436
+ const model = resolveAvailableModel(models, reference, ctx.model?.provider);
437
+ if (!model) {
438
+ throw new Error(`Unknown delegate_task model: ${reference}. Available models: ${models.map((candidate) => modelReference(candidate)).join(", ") || "none"}.`);
439
+ }
440
+ const levels = taskThinkingLevels(ctx, model);
441
+ return { model, thinkingLevel: levels.includes("medium") ? "medium" : levels.at(-1)! };
442
+ }
443
+
422
444
  const roleSummary = (): string => {
423
445
  try {
424
446
  const roles = loadRoles();
@@ -433,6 +455,35 @@ export default function subagentExtension(
433
455
  timeoutPolicy: TimeoutPolicy = DEFAULT_TIMEOUT_POLICY,
434
456
  ): void {
435
457
  const widgetItems = new Map<string, WidgetItem>();
458
+ let activeChildren = 0;
459
+ const queuedChildren: Array<() => void> = [];
460
+ const acquireChildPermit = (signal: AbortSignal | undefined): Promise<void> => {
461
+ if (signal?.aborted) return Promise.reject(new Error("Subagent was aborted."));
462
+ if (activeChildren < MAX_ACTIVE_CHILDREN) {
463
+ activeChildren++;
464
+ return Promise.resolve();
465
+ }
466
+ return new Promise<void>((resolve, reject) => {
467
+ function abort() {
468
+ const index = queuedChildren.indexOf(grant);
469
+ if (index < 0) return;
470
+ queuedChildren.splice(index, 1);
471
+ signal?.removeEventListener("abort", abort);
472
+ reject(new Error("Subagent was aborted."));
473
+ }
474
+ const grant = () => {
475
+ signal?.removeEventListener("abort", abort);
476
+ resolve();
477
+ };
478
+ queuedChildren.push(grant);
479
+ signal?.addEventListener("abort", abort, { once: true });
480
+ });
481
+ };
482
+ const releaseChildPermit = () => {
483
+ const grant = queuedChildren.shift();
484
+ if (grant) grant();
485
+ else activeChildren--;
486
+ };
436
487
  let widgetInstalled = false;
437
488
  let widgetTimer: ReturnType<typeof setInterval> | undefined;
438
489
  let spinnerIndex = 0;
@@ -523,7 +574,7 @@ export default function subagentExtension(
523
574
  pi.registerTool({
524
575
  name: "delegate_task",
525
576
  label: "Subagent",
526
- description: `Delegate one bounded, independently executable task to one isolated Pi Subagent. Roles: ${roleSummary()}. Choose fast for narrow work, balanced for normal work, or frontier for ambiguous and high-risk work; omit modelClass to use shared task-model settings.`,
577
+ description: `Delegate one bounded, independently executable task to one isolated Pi Subagent. Roles: ${roleSummary()}. Choose fast for narrow work, balanced for normal work, or frontier for ambiguous and high-risk work; omit modelClass to use shared task-model settings. When the user designates a specific model, pass it as provider/modelId in model.`,
527
578
  promptSnippet: "Delegate one bounded, independently executable task to an isolated role",
528
579
  promptGuidelines: [
529
580
  "Before calling delegate_task, split broad work into the smallest independent bounded tasks; keep integration and cross-cutting decisions in Main.",
@@ -543,9 +594,11 @@ export default function subagentExtension(
543
594
  if (params.modelClass !== undefined && !isModelClass(params.modelClass)) {
544
595
  throw new Error("delegate_task modelClass must be fast, balanced, or frontier.");
545
596
  }
546
- const launch = params.modelClass === undefined
547
- ? resolveRoleLaunch(pi, ctx, { role, taskId: SUBAGENT_TASK })
548
- : createRoleLaunch(pi, ctx, { role, route: resolveTaskRoute(ctx, params.modelClass) });
597
+ const launch = params.model !== undefined
598
+ ? createRoleLaunch(pi, ctx, { role, route: resolveDesignatedRoute(ctx, cleanText(params.model, "model", "delegate_task")) })
599
+ : params.modelClass === undefined
600
+ ? resolveRoleLaunch(pi, ctx, { role, taskId: SUBAGENT_TASK })
601
+ : createRoleLaunch(pi, ctx, { role, route: resolveTaskRoute(ctx, params.modelClass) });
549
602
  const modelReferenceValue = modelReference(launch.model);
550
603
  const thinkingLevel = launch.thinkingLevel;
551
604
  if (launch.missingSkills.length) {
@@ -555,9 +608,10 @@ export default function subagentExtension(
555
608
  );
556
609
  }
557
610
 
611
+ const args = ["--mode", "json", "-p", ...launch.args, `Task: ${task}`];
612
+ await acquireChildPermit(signal);
558
613
  let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
559
614
  try {
560
- const args = ["--mode", "json", "-p", ...launch.args, `Task: ${task}`];
561
615
  startWidgetItem(toolCallId, role.name, launch.model.id, thinkingLevel, task, ctx);
562
616
  const details = { role: role.name, model: modelReferenceValue, thinkingLevel };
563
617
  const result = await runPi(
@@ -578,6 +632,7 @@ export default function subagentExtension(
578
632
  if (signal?.aborted && !(error instanceof SubagentTimeoutError)) widgetStatus = "aborted";
579
633
  throw error;
580
634
  } finally {
635
+ releaseChildPermit();
581
636
  finishWidgetItem(toolCallId, widgetStatus);
582
637
  }
583
638
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "2.3.4",
3
+ "version": "2.4.0",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",