@henryqw/pi-subagent 14.0.1 → 15.0.1

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
@@ -23,7 +23,7 @@ Provide validated built-in and user Roles, shared task-model Pi launch policy, g
23
23
 
24
24
  ## Invariants
25
25
 
26
- - One Delegated Task creates one ephemeral child process and no saved session. Execution ends at the first hard budget: attempted turn 51 by default (`maxTurns` is a safe integer >= 10; default 50) or `deadline = min(last recognized Pi JSON event + idle timeout, child start + maximum runtime)` (recognized Pi events renew; raw bytes do not; max always terminates). A terminal turn 50 succeeds; attempted continuation rejects with `turn_limit`, accumulated usage, and bounded output. Role launches, including `delegate_task` and each `delegate_flow` Implementer or Reviewer launch, reserve the final allowed turn for a response-only handoff: after a continuing penultimate `turn_end` and its tools, they disable every tool and steer one structured final report. Its fixed decision packet is the default, but exact output required by the assigned task or Role takes precedence and is returned alone. A terminal penultimate response gets no handoff. This does not extend the executor limit. Raw `createEphemeralSubagentExecutor` launches do not guarantee a handoff, and timeout, provider, or child-process failures can prevent one. Before the final boundary, the Role tool extension steers the fixed convergence warning once at 80% of completed turns and once at 80% of maximum runtime, combining thresholds first due together and never starting a warning timer or extra turn. After direct Pi exits, inherited stdout/stderr drain until EOF unless an escaped descendant holds them past short inactivity or a one-second hard deadline. Configurable in `~/.pi/agent/config/pi-subagent/config.json` (`maxTurns` default 50 and minimum 10; `timeout.idleMinutes`/`maxMinutes` defaults 10/30).
26
+ - One Delegated Task creates one ephemeral child process and no saved session. Execution ends at the first hard budget: attempted turn 51 by default (`maxTurns` is a safe integer >= 1; default 50) or `deadline = min(last recognized Pi JSON event + idle timeout, child start + maximum runtime)` (recognized Pi events renew; raw bytes do not; max always terminates). A terminal turn 50 succeeds; attempted continuation rejects with `turn_limit`, accumulated usage, and bounded output. When Role launches, including `delegate_task` and each `delegate_flow` Implementer or Reviewer launch, reach a continuing penultimate `turn_end`, they reserve the final allowed turn for a response-only handoff: after its tools, they disable every tool and steer one structured final report. With `maxTurns` set to 1, they disable tools during `session_start`, and the sole provider turn is the response-only handoff. Its fixed decision packet is the default, but exact output required by the assigned task or Role takes precedence and is returned alone. A terminal penultimate response gets no handoff. This does not extend the executor limit. Raw `createEphemeralSubagentExecutor` launches do not guarantee a handoff, and timeout, provider, or child-process failures can prevent one. Before the final boundary, the Role tool extension steers the fixed convergence warning once at 80% of completed turns and once at 80% of maximum runtime, combining thresholds first due together and never starting a warning timer or extra turn. After direct Pi exits, inherited stdout/stderr drain until EOF unless an escaped descendant holds them past short inactivity or a one-second hard deadline. Configurable in `~/.pi/agent/config/pi-subagent/config.json` (`maxTurns` is a safe integer >= 1 and defaults to 50; `timeout.idleMinutes`/`maxMinutes` default to 10/30).
27
27
  - Up to five active ephemeral `delegate_task` children run per Main by default, configurable via `maxSubagents` in `~/.pi/agent/config/pi-subagent/config.json` or the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable; excess calls wait FIFO. Queued calls do not start a child or consume child timeout.
28
28
  - Ambient child extensions and Skills stay disabled. Every Role requires `tools`, `extensions`, and `skills` YAML arrays, and every launch installs the Role tool policy. `tools: []` activates no base built-ins but does activate all tools from explicitly selected trusted extension bundles and explicit caller tool additions; `skills: []` selects no separately named Role Skills but trusted selected extension Skills still load; `extensions: []` selects no Role extension bundle. A Role/caller explicitly selected extension is a trusted atomic capability bundle: all tools it registers and all Skills supplied through its Pi package metadata or dynamic `resources_discover` load alongside separately named Role Skills. This intentionally includes the extension's executable lifecycle/prompt behavior; pi-subagent does not infer or externally narrow undocumented dependencies, and loading an extension is not sandboxing. Scope children by selecting fewer trusted extensions; finer granularity requires separate entry points/configuration or an upstream split. Explicit Role/caller tool names still verify against the final filtered registry, while parent-only recursive orchestration tools remain excluded.
29
29
  - Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation. Explicit Role/caller tool names verify against the final filtered child registry after explicit provider `session_start` handlers, and unavailable names fail before the first turn.
package/README.md CHANGED
@@ -64,7 +64,9 @@ An explicit `model` (`provider/modelId`) replaces only the route model and must
64
64
 
65
65
  Parallel tasks start together, settle together, and report in input order. Chains are sequential and fail at the first failure. `{previous}` passes only the immediately preceding successful assistant output.
66
66
 
67
- Foreground failures throw after keeping bounded sibling and recovery evidence. One call has one aggregate 50 KiB cap for Main-visible text. Live updates show task names and statuses without opaque IDs. Final results show summaries first and full evidence below.
67
+ Foreground failures throw after keeping bounded sibling and recovery evidence. One call has one aggregate 50 KiB cap for Main-visible text. Final results show summaries first and full evidence below.
68
+
69
+ The status widget shows each task group name above at most three indented child rows. Each row shows a one-letter Role badge, status, activity, usage, and duration. Flow stages for one unit share that unit's heading.
68
70
 
69
71
  Background work belongs to its launching session. Shutdown or reload aborts it and may leave only recoverable-work evidence or no follow-up message.
70
72
 
@@ -107,19 +109,21 @@ pi-subagent owns `~/.pi/agent/config/pi-subagent/config.json`. It is optional. A
107
109
  | Field | Valid value | Default |
108
110
  | --- | --- | --- |
109
111
  | `maxSubagents` | Safe integer ≥ 1 | `5` |
110
- | `maxTurns` | Safe integer ≥ 10 | `50` |
112
+ | `maxTurns` | Safe integer ≥ 1 | `50` |
111
113
  | `timeout.idleMinutes` | Positive minutes; minutes × 60,000 ≤ 2,147,483,647 ms | `10` |
112
114
  | `timeout.maxMinutes` | Positive minutes greater than `idleMinutes`; minutes × 60,000 ≤ 2,147,483,647 ms | `30` |
113
115
 
114
- `maxTurns` defaults to 50. Its minimum is 10.
116
+ `maxTurns` defaults to 50.
115
117
 
116
118
  Excess children wait FIFO without using a child timeout. A terminal response on turn 50 succeeds; an attempted continuation rejects with `turn_limit`.
117
119
 
118
120
  ### Final response handoff
119
121
 
120
- Role launches reserve the final allowed turn for a response-only handoff. This includes `delegate_task` and every Implementer or Reviewer launch within `delegate_flow`.
122
+ When a Role launch reaches a continuing penultimate turn, Pi reserves the final allowed turn for a response-only handoff. This includes `delegate_task` and every Implementer or Reviewer launch within `delegate_flow`.
123
+
124
+ With `maxTurns` set to 1, Pi disables tools at startup. The sole provider turn is the response-only handoff.
121
125
 
122
- After a continuing penultimate turn, Pi waits for its tools. It then disables all tools and requests a final report. The final allowed provider request has no tools. A terminal penultimate response gets no handoff.
126
+ Pi waits for the penultimate turn's tools. It then disables all tools and requests a final report. The final allowed provider request has no tools. A terminal penultimate response gets no handoff.
123
127
 
124
128
  The fixed decision packet asks for Status (completed, blocked, or incomplete), one-sentence Outcome, up to three concrete Evidence facts, Blocker, one material Risk, and one Suggested next action. It is the default. Exact output required by the assigned task or Role takes precedence. The child returns only that output, such as a Flow Reviewer's exact `PASS` or caller-required structured output. It reserves a turn within the existing hard limit; it never adds a model turn. Commits, validation, and retained-worktree facts from executor/Flow structured evidence remain authoritative; the model handoff supplies semantic context and a suggested next action.
125
129
 
@@ -1,6 +1,5 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai";
2
2
  import type { PiLaunch } from "./index.ts";
3
- export declare const MIN_MAX_TURNS: 10;
4
3
  export declare const DEFAULT_MAX_TURNS = 50;
5
4
  export declare const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
6
5
  export interface EphemeralSubagentTimeout {
package/dist/ephemeral.js CHANGED
@@ -5,7 +5,6 @@ import { StringDecoder } from "node:string_decoder";
5
5
  import { hasDisplayControlCharacters } from "./display-text.js";
6
6
  const MAX_OUTPUT_BYTES = 50 * 1024;
7
7
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
8
- export const MIN_MAX_TURNS = 10;
9
8
  export const DEFAULT_MAX_TURNS = 50;
10
9
  export const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
11
10
  const MAX_ACTIVITY_TEXT_BYTES = 4 * 1024;
@@ -68,8 +67,8 @@ function validateOptions(options) {
68
67
  throw new RangeError("maxConcurrency must be a positive safe integer.");
69
68
  }
70
69
  const maxTurns = options.maxTurns === undefined ? DEFAULT_MAX_TURNS : options.maxTurns;
71
- if (!Number.isSafeInteger(maxTurns) || maxTurns < MIN_MAX_TURNS) {
72
- throw new RangeError(`maxTurns must be a safe integer >= ${MIN_MAX_TURNS}.`);
70
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
71
+ throw new RangeError("maxTurns must be a safe integer >= 1.");
73
72
  }
74
73
  if (!options.timeout || typeof options.timeout !== "object")
75
74
  throw new TypeError("timeout is required.");
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type AvailableModel, type ModelTask, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
3
3
  export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.ts";
4
- export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, MIN_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
4
+ export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
5
5
  export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, type WorktreeDirtyInspection, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
6
6
  export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, type PreparedReviewEvidence, type PrepareExactReviewEvidenceInput, } from "./review-evidence.ts";
7
7
  export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { extensionConfigDir } from "@henryqw/pi-config-store";
6
6
  import { hasDisplayControlCharacters } from "./display-text.js";
7
7
  import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, PROFILE_NAMES, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
8
8
  export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.js";
9
- export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, MIN_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
9
+ export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
10
10
  export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, } from "./worktree.js";
11
11
  export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
12
12
  const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
@@ -146,6 +146,8 @@ Role Skill names resolve through Main's effective Pi Skill registry at launch. M
146
146
 
147
147
  Role launches made by `createRoleLaunch` reserve the final allowed turn for a response-only handoff. This includes `delegate_task` and every Implementer or Reviewer launch from `delegate_flow`. A raw `createEphemeralSubagentExecutor` launch does not guarantee a handoff.
148
148
 
149
+ With `maxTurns` set to 1, Pi disables tools during `session_start`. The sole provider turn is the response-only handoff.
150
+
149
151
  After a continuing penultimate turn, Pi completes its tools. It then disables every active tool and queues one structured final handoff. The final allowed provider request has no tools. A terminal penultimate response gets no handoff.
150
152
 
151
153
  The handoff requests a fixed Markdown decision packet with Status (`completed`, `blocked`, or `incomplete`), one-sentence Outcome, up to three concrete Evidence facts, Blocker, one material Risk, and one Suggested next action. It is the default. Exact output required by the assigned task or Role takes precedence, and the child replies only with it. This preserves a Flow Reviewer's exact `PASS` and caller-required structured output. It reserves a turn inside the executor's existing hard limit. It never adds a model turn. A timeout, provider failure, or child-process failure can end a Role launch before handoff. Commits, validation, and retained-worktree facts from executor/Flow structured evidence remain authoritative; the model handoff supplies semantic context and a suggested next action.
@@ -183,7 +185,7 @@ const executorOptions = {
183
185
  };
184
186
  ```
185
187
 
186
- Concurrency is FIFO. `run` accepts optional `signal`, `onUpdate(text)`, `onTokens(number)`, and `onActivity(event)` callbacks plus required `prepare()`. A queued run receives its permit before `prepare` executes, so resource and route resolution can use the latest Pi state. Queued time does not consume child timeout. `maxConcurrency` must be a safe integer >= 1. `maxTurns` must be a safe integer >= 10. `idleMs` and `maxMs` must be positive. `maxMs` must exceed `idleMs`. Omitted `maxTurns` defaults to 50.
188
+ Concurrency is FIFO. `run` accepts optional `signal`, `onUpdate(text)`, `onTokens(number)`, and `onActivity(event)` callbacks plus required `prepare()`. A queued run receives its permit before `prepare` executes, so resource and route resolution can use the latest Pi state. Queued time does not consume child timeout. `maxConcurrency` and `maxTurns` must be safe integers >= 1. `idleMs` and `maxMs` must be positive. `maxMs` must exceed `idleMs`. Omitted `maxTurns` defaults to 50.
187
189
 
188
190
  The executor is **active-Pi-only**. It reuses the currently running Pi invocation and does not locate or support a standalone Node.js Pi installation. Once direct Pi exits, stdout/stderr drain normally until EOF; an escaped descendant retaining either stream is cut off after short output inactivity or a one-second hard deadline so it cannot retain the FIFO permit.
189
191
 
@@ -1,6 +1,5 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { createConfigStore, extensionConfigPath } from "@henryqw/pi-config-store";
3
- import { MIN_MAX_TURNS } from "@henryqw/pi-subagent";
4
3
 
5
4
  export interface SubagentTimeoutConfig {
6
5
  /** Minutes a child may stay idle before it is asked to stop. */
@@ -62,10 +61,10 @@ function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfi
62
61
 
63
62
  const maxTurns = record.maxTurns;
64
63
  if (maxTurns !== undefined) {
65
- if (typeof maxTurns === "number" && Number.isSafeInteger(maxTurns) && maxTurns >= MIN_MAX_TURNS) {
64
+ if (typeof maxTurns === "number" && Number.isSafeInteger(maxTurns) && maxTurns >= 1) {
66
65
  config.maxTurns = maxTurns;
67
66
  } else {
68
- problems.push(`maxTurns must be a safe integer >= ${MIN_MAX_TURNS}, got ${JSON.stringify(maxTurns)}`);
67
+ problems.push(`maxTurns must be a safe integer >= 1, got ${JSON.stringify(maxTurns)}`);
69
68
  }
70
69
  }
71
70
 
@@ -65,6 +65,7 @@ type ChildSettlement =
65
65
  type UnitState = {
66
66
  request: FlowUnitRequest;
67
67
  modelClass: FlowModelClass;
68
+ widgetTaskId: string;
68
69
  worktree: WorktreeInfo;
69
70
  base: string;
70
71
  implementation?: ChildSettlement;
@@ -125,12 +126,14 @@ export interface DelegateFlowRuntime {
125
126
  resolveLaunch: (role: Role, modelClass: FlowModelClass, ctx: ExtensionContext) => ResolvedRoleLaunch;
126
127
  startWidget: (
127
128
  id: string,
129
+ taskId: string,
128
130
  role: string,
129
131
  model: string,
130
132
  thinkingLevel: string | undefined,
131
133
  name: string,
132
134
  ctx: ExtensionContext,
133
135
  ) => void;
136
+ setWidgetTaskRetained: (taskId: string, retained: boolean) => void;
134
137
  updateWidgetTokens: (id: string, tokens: number) => void;
135
138
  updateWidgetActivity: (id: string, event: EphemeralSubagentActivityEvent) => void;
136
139
  finishWidget: (id: string, status: WidgetStatus) => void;
@@ -262,6 +265,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
262
265
  signal ? AbortSignal.any([signal, flow.sessionController.signal]) : flow.sessionController.signal;
263
266
 
264
267
  const invalidateActive = (): void => {
268
+ if (active?.blocked) runtime.setWidgetTaskRetained(active.blocked.unit.widgetTaskId, false);
265
269
  active?.sessionController.abort(new Error("Flow session ended."));
266
270
  active = undefined;
267
271
  };
@@ -346,6 +350,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
346
350
  modelClass: FlowModelClass,
347
351
  task: string,
348
352
  widgetName: string,
353
+ widgetTaskId: string,
349
354
  cwd: string,
350
355
  widgetId: string,
351
356
  signal: AbortSignal | undefined,
@@ -365,7 +370,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
365
370
  if (launch.missingSkills.length) {
366
371
  ctx.ui.notify(`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`, "warning");
367
372
  }
368
- runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, widgetName, ctx);
373
+ runtime.startWidget(widgetId, widgetTaskId, role.name, launch.model.id, launch.thinkingLevel, widgetName, ctx);
369
374
  started = true;
370
375
  return { launch, task, cwd };
371
376
  },
@@ -506,6 +511,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
506
511
  diagnostic: string,
507
512
  meter: UsageMeter,
508
513
  ) => {
514
+ if (flow.blocked) runtime.setWidgetTaskRetained(flow.blocked.unit.widgetTaskId, false);
509
515
  if (active === flow) active = undefined;
510
516
  return response(flow, "failed", meter, { classification, diagnostic: capOutput(diagnostic) });
511
517
  };
@@ -521,6 +527,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
521
527
  if (unit.repairUsed) return terminal(flow, classification, bounded, meter);
522
528
  flow.phase = "blocked";
523
529
  flow.blocked = { unit, classification, diagnostic: bounded };
530
+ runtime.setWidgetTaskRetained(unit.widgetTaskId, true);
524
531
  return response(flow, "blocked", meter);
525
532
  };
526
533
 
@@ -687,6 +694,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
687
694
  unit.modelClass,
688
695
  reviewerTask(unit.request, reviewCriterion, { base: evidence.base, tip: evidence.tip, patchPath: evidence.patchPath }),
689
696
  unit.request.name,
697
+ unit.widgetTaskId,
690
698
  unit.worktree.cwd,
691
699
  `${toolCallId}:flow:${flow.index}:review`,
692
700
  signal,
@@ -820,6 +828,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
820
828
  flow.units.push({
821
829
  request: unit,
822
830
  modelClass: unit.modelClass,
831
+ widgetTaskId: `${toolCallId}:flow:${index}`,
823
832
  worktree,
824
833
  base: worktree.baseCommit,
825
834
  repairUsed: false,
@@ -839,6 +848,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
839
848
  unit.modelClass,
840
849
  implementerTask(unit.request),
841
850
  unit.request.name,
851
+ unit.widgetTaskId,
842
852
  unit.worktree.cwd,
843
853
  `${toolCallId}:flow:${index}:implement`,
844
854
  operationSignal,
@@ -884,6 +894,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
884
894
  const blocked = flow.blocked;
885
895
  const unit = blocked.unit;
886
896
  if (unit.repairUsed) throw new Error("delegate_flow_continue repair was already used for this Unit.");
897
+ runtime.setWidgetTaskRetained(unit.widgetTaskId, false);
887
898
  flow.phase = "running";
888
899
  flow.blocked = undefined;
889
900
  unit.repairUsed = true;
@@ -901,6 +912,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
901
912
  unit.modelClass,
902
913
  repairTask(unit.request, blocked, guidance),
903
914
  unit.request.name,
915
+ unit.widgetTaskId,
904
916
  unit.worktree.cwd,
905
917
  `${toolCallId}:flow:${flow.index}:repair`,
906
918
  operationSignal,
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { CHILD_EXCLUDED_TOOL_NAMES, EXECUTION_BUDGET_ENV, MIN_MAX_TURNS, ROLE_TOOL_POLICY_FLAG } from "@henryqw/pi-subagent";
2
+ import { CHILD_EXCLUDED_TOOL_NAMES, EXECUTION_BUDGET_ENV, ROLE_TOOL_POLICY_FLAG } from "@henryqw/pi-subagent";
3
3
 
4
4
  const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
5
5
  const WARNING_RATIO = 0.8;
@@ -37,7 +37,7 @@ function executionBudget(value: string | undefined): { maxTurns: number; maxMs:
37
37
  }
38
38
  const budget = parsed as Record<string, unknown>;
39
39
  if (Object.keys(budget).length !== 3 || !("maxTurns" in budget) || !("maxMs" in budget) || !("startedAt" in budget)
40
- || !Number.isSafeInteger(budget.maxTurns) || (budget.maxTurns as number) < MIN_MAX_TURNS
40
+ || !Number.isSafeInteger(budget.maxTurns) || (budget.maxTurns as number) < 1
41
41
  || typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0
42
42
  || !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0) {
43
43
  throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
@@ -59,6 +59,7 @@ export default function roleTools(pi: ExtensionAPI): void {
59
59
  type: "string",
60
60
  });
61
61
  const budget = executionBudget(process.env[EXECUTION_BUDGET_ENV]);
62
+ let handoffSent = false;
62
63
  pi.on("session_start", () => {
63
64
  const selected = configuredTools(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
64
65
  const allTools = pi.getAllTools();
@@ -72,12 +73,16 @@ export default function roleTools(pi: ExtensionAPI): void {
72
73
  if (unavailable.length) {
73
74
  throw new Error(`Subagent requested unavailable tools: ${unavailable.join(", ")}. Check spelling and load the provider extension that registers them.`);
74
75
  }
76
+ if (budget?.maxTurns === 1 && !handoffSent) {
77
+ pi.setActiveTools([]);
78
+ pi.sendMessage(FINAL_HANDOFF_MESSAGE, { deliverAs: "steer", triggerTurn: false });
79
+ handoffSent = true;
80
+ }
75
81
  });
76
82
 
77
83
  if (!budget) return;
78
84
  const warningTurn = Math.ceil(budget.maxTurns * WARNING_RATIO);
79
85
  let completedTurns = 0;
80
- let handoffSent = false;
81
86
  let turnWarningSent = false;
82
87
  let runtimeWarningSent = false;
83
88
  pi.on("turn_end", (event) => {
@@ -60,6 +60,7 @@ const WIDGET_KEY = "subagent-status";
60
60
  const WIDGET_INTERVAL_MS = 80;
61
61
  const MAX_WIDGET_ITEMS = 8;
62
62
  const MAX_WIDGET_LINES = 6;
63
+ const MAX_WIDGET_GROUP_ROWS = 3;
63
64
  export const MAX_WIDGET_ACTIVE_TOOLS = 8;
64
65
  const DEFAULT_TIMEOUT_POLICY = {
65
66
  idleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
@@ -87,6 +88,7 @@ type WidgetItem = {
87
88
  role: string;
88
89
  model: string;
89
90
  thinkingLevel: string;
91
+ taskId: string;
90
92
  name: string;
91
93
  tokens: number;
92
94
  startedAt: number;
@@ -164,13 +166,35 @@ function renderWidgetRows(
164
166
  theme: Theme,
165
167
  ): string[] {
166
168
  const ordered = [...items.filter(({ status }) => status === "working"), ...items.filter(({ status }) => status !== "working")];
167
- const visible = ordered.slice(0, ordered.length > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES);
168
- if (!visible.length) return [];
169
- const hidden = ordered.slice(visible.length);
170
- const lines = visible.map((item) => truncateToWidth(
171
- `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} ${theme.fg("text", item.name)} · ${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`,
172
- width,
173
- ));
169
+ if (!ordered.length) return [];
170
+ const groups = new Map<string, { name: string; items: WidgetItem[] }>();
171
+ for (const item of ordered) {
172
+ const group = groups.get(item.taskId);
173
+ if (group) group.items.push(item);
174
+ else groups.set(item.taskId, { name: item.name, items: [item] });
175
+ }
176
+ const maxVisibleLines = ordered.length + groups.size > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES;
177
+ const workingGroups = [...groups.values()].filter(({ items }) => items.some(({ status }) => status === "working"));
178
+ const visibleWorkingGroups = new Set(workingGroups.slice(0, Math.floor(maxVisibleLines / 2)));
179
+ let remainingWorkingGroups = visibleWorkingGroups.size;
180
+ const visible = new Set<WidgetItem>();
181
+ const lines: string[] = [];
182
+ for (const group of groups.values()) {
183
+ const working = group.items.some(({ status }) => status === "working");
184
+ if (working && !visibleWorkingGroups.has(group)) continue;
185
+ const reservedLines = working ? --remainingWorkingGroups * 2 : 0;
186
+ const childCount = Math.min(MAX_WIDGET_GROUP_ROWS, group.items.length, maxVisibleLines - lines.length - reservedLines - 1);
187
+ if (childCount < 1) continue;
188
+ lines.push(truncateToWidth(theme.fg("text", group.name), width));
189
+ for (const item of group.items.slice(0, childCount)) {
190
+ visible.add(item);
191
+ lines.push(truncateToWidth(
192
+ ` ${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} ${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`,
193
+ width,
194
+ ));
195
+ }
196
+ }
197
+ const hidden = ordered.filter((item) => !visible.has(item));
174
198
  if (hidden.length) {
175
199
  const counts: Record<WidgetStatus, number> = { working: 0, success: 0, failure: 0, aborted: 0 };
176
200
  for (const { status } of hidden) counts[status] += 1;
@@ -250,6 +274,7 @@ export default function subagentExtension(
250
274
  ].join("\n"), outputPad, 0);
251
275
  });
252
276
  const widgetItems = new Map<string, WidgetItem>();
277
+ const retainedWidgetTaskIds = new Set<string>();
253
278
  // Each child is a full Pi process issuing its own model calls; cap parallel
254
279
  // spend. Precedence: PI_SUBAGENT_MAX_SUBAGENTS env > config/pi-subagent/config.json
255
280
  // maxSubagents > default 5. Invalid present config falls back to the default
@@ -301,6 +326,11 @@ export default function subagentExtension(
301
326
 
302
327
  const requestWidgetRender = () => activeTui?.requestRender();
303
328
 
329
+ const setWidgetTaskRetained = (taskId: string, retained: boolean) => {
330
+ if (retained) retainedWidgetTaskIds.add(taskId);
331
+ else retainedWidgetTaskIds.delete(taskId);
332
+ };
333
+
304
334
  const startWidgetTimer = () => {
305
335
  if (widgetTimer) return;
306
336
  widgetTimer = setInterval(() => {
@@ -324,6 +354,7 @@ export default function subagentExtension(
324
354
 
325
355
  const startWidgetItem = (
326
356
  id: string,
357
+ taskId: string,
327
358
  role: string,
328
359
  model: string,
329
360
  thinkingLevel: string | undefined,
@@ -334,15 +365,23 @@ export default function subagentExtension(
334
365
  ensureWidget(ctx);
335
366
  if (!widgetItems.has(id) && widgetItems.size >= MAX_WIDGET_ITEMS) {
336
367
  for (const [oldestId, item] of widgetItems) {
337
- if (item.status === "working") continue;
368
+ if (item.status === "working" || retainedWidgetTaskIds.has(item.taskId) || item.taskId === taskId) continue;
338
369
  widgetItems.delete(oldestId);
339
370
  if (widgetItems.size < MAX_WIDGET_ITEMS) break;
340
371
  }
372
+ if (widgetItems.size >= MAX_WIDGET_ITEMS) {
373
+ for (const [oldestId, item] of widgetItems) {
374
+ if (item.status === "working" || retainedWidgetTaskIds.has(item.taskId)) continue;
375
+ widgetItems.delete(oldestId);
376
+ if (widgetItems.size < MAX_WIDGET_ITEMS) break;
377
+ }
378
+ }
341
379
  }
342
380
  widgetItems.set(id, {
343
381
  role: roleBadge(role),
344
382
  model,
345
383
  thinkingLevel: thinkingLevel ?? "default",
384
+ taskId,
346
385
  name,
347
386
  tokens: 0,
348
387
  startedAt: Date.now(),
@@ -434,6 +473,7 @@ export default function subagentExtension(
434
473
  failedToolPatches.clear();
435
474
  stopWidgetTimer();
436
475
  widgetItems.clear();
476
+ retainedWidgetTaskIds.clear();
437
477
  activeTui = undefined;
438
478
  widgetInstalled = false;
439
479
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
@@ -451,7 +491,7 @@ export default function subagentExtension(
451
491
  pi.on("input", (event) => {
452
492
  if (event.source === "extension") return;
453
493
  for (const [id, item] of widgetItems) {
454
- if (item.status !== "working") widgetItems.delete(id);
494
+ if (item.status !== "working" && !retainedWidgetTaskIds.has(item.taskId)) widgetItems.delete(id);
455
495
  }
456
496
  requestWidgetRender();
457
497
  });
@@ -550,6 +590,7 @@ export default function subagentExtension(
550
590
  ...(modelClass === undefined ? {} : { modelClass }),
551
591
  }),
552
592
  startWidget: startWidgetItem,
593
+ setWidgetTaskRetained,
553
594
  updateWidgetTokens,
554
595
  updateWidgetActivity,
555
596
  finishWidget: finishWidgetItem,
@@ -695,7 +736,7 @@ export default function subagentExtension(
695
736
  if (role.isolation === "worktree") {
696
737
  worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
697
738
  }
698
- startWidgetItem(entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.name, ctx);
739
+ startWidgetItem(entry.id, entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.name, ctx);
699
740
  setState("running", "");
700
741
  emitUpdate(emitToolUpdates);
701
742
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "14.0.1",
3
+ "version": "15.0.1",
4
4
  "description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",