@henryqw/pi-subagent 15.1.0 → 15.1.2

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,8 @@ 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 >= 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).
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), attempted continuation after the optional token handoff, 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. Optional `maxTokens` is a safe integer >= 1 with an unlimited default. It is one global executor default applied independently to every `delegate_task` child and every Flow Implementer, Reviewer, and repair child; it is neither a shared pool nor a per-call or environment option. Token accounting sums each completed assistant response's `Usage.totalTokens` once, matching the executor's aggregate `Usage`. A terminal response crossing the limit succeeds. A continuing crossing turn and its tools finish, then exactly one extra response turn is permitted. Further continuation rejects with typed `token_limit`, aggregate usage, and bounded last assistant output. This permits a crossing turn plus the final response to overshoot; it is not an exact hard cap. Raw executor launches enforce the extra-turn window but do not guarantee tools are disabled.
27
+ - Every Role launch installs the shared tool policy. On a continuing token crossing, or the continuing penultimate `maxTurns` turn, the policy waits for `turn_end`, disables every tool, and steers one structured final report. This covers `delegate_task` and each Flow launch. With `maxTurns` set to 1, tools are disabled during `session_start`, and the sole provider turn is the response-only handoff. The fixed decision packet is the default, but exact task or Role output takes precedence and is returned alone. Terminal boundary responses get no handoff. Before the final boundary, the policy steers the fixed convergence warning once at each 80% threshold for completed turns, aggregate tokens when configured, and maximum runtime. It combines thresholds first due together and starts no timer or extra warning turn. Timeout, provider, or child-process failures can prevent a handoff. 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` defaults to 50; `maxTokens` defaults to unlimited; `timeout.idleMinutes`/`maxMinutes` default to 10/30).
27
28
  - 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
29
  - 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
30
  - 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
@@ -80,6 +80,15 @@ See the [orchestration guide](./docs/orchestration.md) for full delegation, tran
80
80
 
81
81
  The bundled [`pi-subagent-delegated-development`](./skills/pi-subagent-delegated-development/SKILL.md) Skill guides Main's planning and orchestration. It adds no runtime code, config, or Role installation.
82
82
 
83
+ Before delegating:
84
+
85
+ - Find concrete outcomes that can ship on their own.
86
+ - Split only those outcomes. Give each one owner and a focused check.
87
+ - Run independent work in parallel.
88
+ - Prefer parallel delegation when at least two outcomes are independent.
89
+ - For naturally multi-part work, roughly three to five useful units can help.
90
+ - This is a guide, not a quota. Never create units just to reach it.
91
+
83
92
  Its ordinary review loop is optional. Use it only when the caller or repository policy explicitly requires judgment review.
84
93
 
85
94
  - Call `delegate_task` with `role: "reviewer"` to select the effective `reviewer` Role.
@@ -92,7 +101,11 @@ Flow is separate. It owns exact review evidence, exact `PASS` approval, validati
92
101
 
93
102
  ## Flow
94
103
 
95
- Flow requires a clean Main worktree on an attached branch with a committed `HEAD`. Use it only for independent Git changes that can merge in any order. Do not split units that overlap files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.
104
+ Flow requires a clean Main worktree on an attached branch with a committed `HEAD`. Use it only for independent Git changes that can merge in any order.
105
+
106
+ Keep work together or run it in order when a split divides an invariant or adds coordination. Do not split units with overlapping mutable ownership. Do not split units that overlap files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.
107
+
108
+ One Implementer launch must plausibly finish before the configured maximum runtime. Cohesion is not enough when work has several preservable, separately verifiable milestones. Split oversized dependent work into serial one-unit Flows after each milestone integrates. Units in one Flow stay independent and commuting.
96
109
 
97
110
  ```text
98
111
  delegate_flow({ units: [{ id, name, task, modelClass?, validation: [{ command, args }], review? }] })
@@ -128,22 +141,29 @@ pi-subagent owns `~/.pi/agent/config/pi-subagent/config.json`. It is optional. A
128
141
  | --- | --- | --- | --- |
129
142
  | `maxSubagents` | Sets the maximum number of active child processes. | Safe integer of at least 1. | `5` |
130
143
  | `maxTurns` | Sets the hard provider-turn limit for each child. | Safe integer of at least 1. | `50` |
131
- | `timeout.idleMinutes` | Sets the idle timeout for a child. | Positive minutes. | `10` |
132
- | `timeout.maxMinutes` | Sets the maximum runtime for a child. | Positive minutes greater than `idleMinutes`. | `30` |
144
+ | `maxTokens` | Sets the token limit for each child. | Safe integer of at least 1. | Unlimited |
145
+ | `timeout.idleMinutes` | Sets the idle timeout for a child. | Positive minutes; minutes × 60,000 ≤ 2,147,483,647 ms | `10` |
146
+ | `timeout.maxMinutes` | Sets the maximum runtime for a child. | Positive minutes greater than `idleMinutes`; minutes × 60,000 ≤ 2,147,483,647 ms | `30` |
147
+
148
+ `maxTokens` applies separately to every child. This includes `delegate_task` and each Flow Implementer, Reviewer, and repair launch. It is not a shared pool or per-call option. Set it only in this file.
149
+
150
+ Pi adds each completed assistant response's `Usage.totalTokens` once. This matches the executor's aggregate `Usage`. At 80%, a Role receives one convergence warning.
151
+
152
+ A terminal response that crosses `maxTokens` succeeds. A continuing crossing turn completes its tools. Pi then disables tools and allows one response-only handoff. That handoff can overshoot the limit, so `maxTokens` is not an exact cap. Further continuation rejects with `token_limit`, aggregate `Usage`, and bounded last output.
133
153
 
134
154
  Excess children wait FIFO without using a child timeout. A terminal response on turn 50 succeeds; an attempted continuation rejects with `turn_limit`.
135
155
 
136
156
  ### Final response handoff
137
157
 
138
- 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`.
158
+ Role launches reserve a response-only handoff at a continuing `maxTokens` crossing or the penultimate `maxTurns` turn. This includes `delegate_task` and every Implementer or Reviewer launch within `delegate_flow`.
139
159
 
140
160
  With `maxTurns` set to 1, Pi disables tools at startup. The sole provider turn is the response-only handoff.
141
161
 
142
- 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.
162
+ Pi waits for the current turn's tools. It then disables all tools and requests a final report. A terminal boundary response gets no handoff.
143
163
 
144
- 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.
164
+ 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. The handoff stays within `maxTurns`, but it is the one allowed turn after a token crossing. Commits, validation, and retained-worktree facts from executor/Flow structured evidence remain authoritative; the model handoff supplies semantic context and a suggested next action.
145
165
 
146
- A raw `createEphemeralSubagentExecutor` launch does not guarantee this handoff. A timeout, provider failure, or child-process failure can end a Role launch before handoff.
166
+ A raw `createEphemeralSubagentExecutor` launch can enforce the extra-turn window. It cannot guarantee disabled tools or the final handoff. A timeout, provider failure, or child-process failure can also end a Role launch before handoff.
147
167
 
148
168
  Malformed or unreadable JSON, a non-object root, unknown keys, and invalid values produce one warning. Invalid settings use defaults while valid settings still apply. If the effective maximum is not greater than the idle timeout, both timeout settings use defaults. The file is never rewritten.
149
169
 
@@ -2,6 +2,12 @@ import type { Usage } from "@earendil-works/pi-ai";
2
2
  import type { PiLaunch } from "./index.ts";
3
3
  export declare const DEFAULT_MAX_TURNS = 50;
4
4
  export declare const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
5
+ export interface EphemeralSubagentExecutionBudget {
6
+ maxTurns: number;
7
+ maxMs: number;
8
+ startedAt: number;
9
+ maxTokens?: number;
10
+ }
5
11
  export interface EphemeralSubagentTimeout {
6
12
  idleMs: number;
7
13
  maxMs: number;
@@ -9,6 +15,7 @@ export interface EphemeralSubagentTimeout {
9
15
  export interface EphemeralSubagentExecutorOptions {
10
16
  maxConcurrency: number;
11
17
  maxTurns?: number;
18
+ maxTokens?: number;
12
19
  timeout: EphemeralSubagentTimeout;
13
20
  }
14
21
  export type EphemeralSubagentActivityEvent = {
@@ -47,7 +54,7 @@ export type EphemeralSubagentResult = (EphemeralSubagentResultBase & {
47
54
  }) | (EphemeralSubagentResultBase & {
48
55
  outcome: "failure";
49
56
  });
50
- export type EphemeralSubagentErrorCode = "aborted" | "timeout" | "turn_limit" | "spawn" | "protocol" | "prepare" | "callback";
57
+ export type EphemeralSubagentErrorCode = "aborted" | "timeout" | "turn_limit" | "token_limit" | "spawn" | "protocol" | "prepare" | "callback";
51
58
  export declare class EphemeralSubagentError extends Error {
52
59
  name: string;
53
60
  readonly code: EphemeralSubagentErrorCode;
package/dist/ephemeral.js CHANGED
@@ -70,6 +70,9 @@ function validateOptions(options) {
70
70
  if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
71
71
  throw new RangeError("maxTurns must be a safe integer >= 1.");
72
72
  }
73
+ if (options.maxTokens !== undefined && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens < 1)) {
74
+ throw new RangeError("maxTokens must be a safe integer >= 1.");
75
+ }
73
76
  if (!options.timeout || typeof options.timeout !== "object")
74
77
  throw new TypeError("timeout is required.");
75
78
  const timeout = {
@@ -78,7 +81,12 @@ function validateOptions(options) {
78
81
  };
79
82
  if (timeout.maxMs <= timeout.idleMs)
80
83
  throw new RangeError("timeout.maxMs must be greater than timeout.idleMs.");
81
- return { maxConcurrency: options.maxConcurrency, maxTurns, timeout };
84
+ return {
85
+ maxConcurrency: options.maxConcurrency,
86
+ maxTurns,
87
+ ...(options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }),
88
+ timeout,
89
+ };
82
90
  }
83
91
  function abortError(signal, cause = signal?.reason, usage) {
84
92
  return new EphemeralSubagentError("aborted", "Subagent was aborted.", cause, usage);
@@ -378,12 +386,18 @@ async function runPi(prepared, input, budget, invocation) {
378
386
  const maxDeadline = startedAt + timeoutPolicy.maxMs;
379
387
  let child;
380
388
  try {
389
+ const executionBudget = {
390
+ maxTurns: budget.maxTurns,
391
+ maxMs: timeoutPolicy.maxMs,
392
+ startedAt,
393
+ ...(budget.maxTokens === undefined ? {} : { maxTokens: budget.maxTokens }),
394
+ };
381
395
  child = spawn(invocation.command, args, {
382
396
  cwd: prepared.cwd,
383
397
  env: {
384
398
  ...process.env,
385
399
  ...prepared.launch.env,
386
- [EXECUTION_BUDGET_ENV]: JSON.stringify({ maxTurns: budget.maxTurns, maxMs: timeoutPolicy.maxMs, startedAt }),
400
+ [EXECUTION_BUDGET_ENV]: JSON.stringify(executionBudget),
387
401
  },
388
402
  shell: false,
389
403
  stdio: ["ignore", "pipe", "pipe"],
@@ -411,6 +425,8 @@ async function runPi(prepared, input, budget, invocation) {
411
425
  let protocolError;
412
426
  let aborted = false;
413
427
  let turnLimited = false;
428
+ let tokenBudget = "within";
429
+ const tokenLimited = () => tokenBudget === "limited";
414
430
  let startedTurns = 0;
415
431
  let lastEventAt = startedAt;
416
432
  let deadline = Math.min(startedAt + timeoutPolicy.idleMs, maxDeadline);
@@ -498,6 +514,11 @@ async function runPi(prepared, input, budget, invocation) {
498
514
  const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
499
515
  reject(new EphemeralSubagentError("turn_limit", message, new Error(message), accumulatedUsage(), output));
500
516
  }
517
+ else if (tokenLimited()) {
518
+ const summary = `Subagent reached its maximum token limit of ${budget.maxTokens}.`;
519
+ const message = output ? capEphemeralSubagentOutput(`${summary}\n\nLast assistant output:\n${output}`) : summary;
520
+ reject(new EphemeralSubagentError("token_limit", message, new Error(message), accumulatedUsage(), output));
521
+ }
501
522
  else if (protocolError) {
502
523
  reject(new EphemeralSubagentError("protocol", protocolError.message, protocolError, accumulatedUsage()));
503
524
  }
@@ -558,7 +579,7 @@ async function runPi(prepared, input, budget, invocation) {
558
579
  deadlineTimer.unref();
559
580
  };
560
581
  const observeEvent = () => {
561
- if (callbackFailure || aborted || timedOutAfterMs !== undefined || childExited)
582
+ if (callbackFailure || aborted || timedOutAfterMs !== undefined || turnLimited || tokenLimited() || childExited)
562
583
  return;
563
584
  const now = Date.now();
564
585
  if (now >= deadline) {
@@ -568,8 +589,31 @@ async function runPi(prepared, input, budget, invocation) {
568
589
  lastEventAt = now;
569
590
  scheduleDeadline();
570
591
  };
592
+ const advanceTokenBudget = (event) => {
593
+ switch (tokenBudget) {
594
+ case "within":
595
+ if (event === "turn_end" && budget.maxTokens !== undefined && completedTokens >= budget.maxTokens) {
596
+ tokenBudget = "crossed";
597
+ }
598
+ return false;
599
+ case "crossed":
600
+ if (event === "turn_start")
601
+ tokenBudget = "final_turn";
602
+ return false;
603
+ case "final_turn":
604
+ if (event === "turn_start") {
605
+ if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
606
+ tokenBudget = "limited";
607
+ stop(true);
608
+ return true;
609
+ }
610
+ return false;
611
+ case "limited":
612
+ return true;
613
+ }
614
+ };
571
615
  const processLine = (line) => {
572
- if (turnLimited || !line.trim())
616
+ if (turnLimited || tokenLimited() || !line.trim())
573
617
  return;
574
618
  let event;
575
619
  try {
@@ -584,12 +628,18 @@ async function runPi(prepared, input, budget, invocation) {
584
628
  if (typeof record.type !== "string" || !Object.hasOwn(PI_JSON_EVENTS, record.type))
585
629
  return;
586
630
  observeEvent();
587
- if (record.type === "turn_start" && ++startedTurns > budget.maxTurns) {
588
- if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
589
- turnLimited = true;
590
- stop(true);
591
- return;
631
+ if (record.type === "turn_start") {
632
+ if (++startedTurns > budget.maxTurns) {
633
+ if (!callbackFailure && !aborted && timedOutAfterMs === undefined)
634
+ turnLimited = true;
635
+ stop(true);
636
+ return;
637
+ }
638
+ if (advanceTokenBudget("turn_start"))
639
+ return;
592
640
  }
641
+ if (record.type === "turn_end")
642
+ advanceTokenBudget("turn_end");
593
643
  if (record.type === "message_start") {
594
644
  partial.prefix = "";
595
645
  partial.totalBytes = 0;
@@ -714,7 +764,7 @@ async function runPi(prepared, input, budget, invocation) {
714
764
  };
715
765
  onStdoutData = (data) => {
716
766
  armPostExitIdleDeadline();
717
- if (callbackFailure || protocolError || turnLimited)
767
+ if (callbackFailure || protocolError || turnLimited || tokenLimited())
718
768
  return;
719
769
  let offset = 0;
720
770
  while (offset < data.length) {
@@ -755,7 +805,7 @@ async function runPi(prepared, input, budget, invocation) {
755
805
  invokeCallback("onActivity", input.onActivity, activity);
756
806
  }
757
807
  }
758
- if (callbackFailure || turnLimited)
808
+ if (callbackFailure || turnLimited || tokenLimited())
759
809
  return;
760
810
  lineParts = [];
761
811
  lineBytes = 0;
@@ -795,13 +845,13 @@ async function runPi(prepared, input, budget, invocation) {
795
845
  killTimer.unref();
796
846
  }
797
847
  const abort = () => {
798
- if (timedOutAfterMs !== undefined || turnLimited || childExited)
848
+ if (timedOutAfterMs !== undefined || turnLimited || tokenLimited() || childExited)
799
849
  return;
800
850
  aborted = true;
801
851
  stop();
802
852
  };
803
853
  function timeout(afterMs, reason) {
804
- if (timedOutAfterMs !== undefined || turnLimited || childExited)
854
+ if (timedOutAfterMs !== undefined || turnLimited || tokenLimited() || childExited)
805
855
  return;
806
856
  if (reason === "maximum") {
807
857
  if (!aborted) {
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, 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 EphemeralSubagentExecutionBudget, 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";
@@ -144,13 +144,15 @@ Role Skill names resolve through Main's effective Pi Skill registry at launch. M
144
144
 
145
145
  ### Role final-turn handoff
146
146
 
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.
147
+ Role launches made by `createRoleLaunch` reserve a response-only handoff at either boundary. They use the penultimate `maxTurns` turn or a continuing `maxTokens` crossing. This includes `delegate_task` and every Flow Implementer, Reviewer, and repair launch.
148
148
 
149
149
  With `maxTurns` set to 1, Pi disables tools during `session_start`. The sole provider turn is the response-only handoff.
150
150
 
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.
151
+ After a continuing boundary turn, Pi completes its tools. It then disables every active tool and queues one structured final handoff. A terminal boundary response succeeds without a handoff. If the child continues after the token handoff, the executor rejects with `token_limit`.
152
152
 
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.
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. The handoff remains inside `maxTurns`, but it is the one permitted response after crossing `maxTokens`. 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.
154
+
155
+ A raw `createEphemeralSubagentExecutor` launch enforces the token extra-turn window. It does not guarantee disabled tools or the handoff message. Only Role launches install that policy.
154
156
 
155
157
  ## Public Role and executor API
156
158
 
@@ -181,11 +183,14 @@ A loaded `Role` contains `name`, `description`, required normalized `tools`, `ex
181
183
  const executorOptions = {
182
184
  maxConcurrency: 4,
183
185
  maxTurns: 50,
186
+ maxTokens: 100_000, // optional; omitted means unlimited
184
187
  timeout: { idleMs: 10 * 60_000, maxMs: 30 * 60_000 },
185
188
  };
186
189
  ```
187
190
 
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.
191
+ 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`, `maxTurns`, and configured `maxTokens` must be safe integers >= 1. `idleMs` and `maxMs` must be positive. `maxMs` must exceed `idleMs`. Omitted `maxTurns` defaults to 50. Omitted `maxTokens` is unlimited.
192
+
193
+ One executor applies `maxTokens` independently to every `run`; it is not a shared pool. `run` has no token override. The built-in extension reads its global value only from `config/pi-subagent/config.json`.
189
194
 
190
195
  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.
191
196
 
@@ -214,6 +219,7 @@ export function createRunRole(pi) {
214
219
  const executor = createEphemeralSubagentExecutor({
215
220
  maxConcurrency: 4,
216
221
  maxTurns: 50,
222
+ maxTokens: 100_000,
217
223
  timeout: { idleMs: 10 * 60_000, maxMs: 30 * 60_000 },
218
224
  });
219
225
 
@@ -267,7 +273,11 @@ export function createRunRole(pi) {
267
273
  }
268
274
  ```
269
275
 
270
- `run` resolves to `EphemeralSubagentResult`. Both outcome variants contain `exitCode`, `output`, `stderr`, and optional `stopReason`, `errorMessage`, and `usage`. A launched child/model failure is a typed `{ outcome: "failure", ... }` result. Abort, timeout, turn-limit, spawn, protocol, preparation, and callback failures reject with `EphemeralSubagentError` and a stable `code`. A terminal response at `maxTurns` succeeds; an attempted continuation rejects with `turn_limit`, accumulated `usage`, and bounded `output`. The executor passes its turn and runtime budget through the child environment. On a Role launch, its tool policy steers the fixed convergence warning before another model turn once at each 80% threshold, combines thresholds first due together, and uses no timer or extra turn. Assistant `output` and `stderr` are bounded, and `usage` contains aggregate child usage when Pi supplies it.
276
+ `run` resolves to `EphemeralSubagentResult`. Both outcome variants contain `exitCode`, `output`, `stderr`, and optional `stopReason`, `errorMessage`, and `usage`. A launched child/model failure is a typed `{ outcome: "failure", ... }` result. Abort, timeout, turn-limit, token-limit, spawn, protocol, preparation, and callback failures reject with `EphemeralSubagentError` and a stable `code`. A terminal response at `maxTurns` succeeds; an attempted continuation rejects with `turn_limit`, accumulated `usage`, and bounded `output`.
277
+
278
+ Token accounting adds each completed assistant response's `Usage.totalTokens` once. It uses the same aggregate `usage` returned by the executor. A terminal response crossing `maxTokens` succeeds. A continuing crossing response and its tools finish before one final response turn. That turn may overshoot the configured value. Further continuation rejects with `token_limit`, aggregate `usage`, and bounded last assistant `output`.
279
+
280
+ The executor passes its turn, token, and runtime budget through its internal child protocol. On a Role launch, its tool policy sends one convergence warning at each 80% threshold. It combines thresholds first due together and uses no timer or extra warning turn. Assistant `output` and `stderr` are bounded, and `usage` contains aggregate child usage when Pi supplies it.
271
281
 
272
282
  ### Activity callbacks
273
283
 
@@ -11,6 +11,7 @@ export interface SubagentTimeoutConfig {
11
11
  export interface SubagentConfig {
12
12
  maxSubagents?: number;
13
13
  maxTurns?: number;
14
+ maxTokens?: number;
14
15
  timeout?: SubagentTimeoutConfig;
15
16
  }
16
17
 
@@ -31,6 +32,7 @@ const positive = (value: unknown): value is number =>
31
32
  // every child immediately instead of applying the configured deadline.
32
33
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
33
34
  export const DEFAULT_TIMEOUT_CONFIG = { idleMinutes: 10, maxMinutes: 30 } as const;
35
+ const INTEGER_FIELDS = ["maxSubagents", "maxTurns", "maxTokens"] as const;
34
36
  const TIMEOUT_FIELDS = ["idleMinutes", "maxMinutes"] as const;
35
37
 
36
38
  /** Return the canonical default JSON path for pi-subagent's config home. */
@@ -45,26 +47,18 @@ function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfi
45
47
  const problems: string[] = [];
46
48
  const config: SubagentConfig = {};
47
49
  for (const key of Object.keys(record)) {
48
- if (key !== "maxSubagents" && key !== "maxTurns" && key !== "timeout") {
49
- problems.push(`unknown config key ${JSON.stringify(key)}; expected maxSubagents, maxTurns, timeout`);
50
+ if (![...INTEGER_FIELDS, "timeout"].includes(key as typeof INTEGER_FIELDS[number] | "timeout")) {
51
+ problems.push(`unknown config key ${JSON.stringify(key)}; expected ${INTEGER_FIELDS.join(", ")}, timeout`);
50
52
  }
51
53
  }
52
54
 
53
- const maxSubagents = record.maxSubagents;
54
- if (maxSubagents !== undefined) {
55
- if (typeof maxSubagents === "number" && Number.isSafeInteger(maxSubagents) && maxSubagents >= 1) {
56
- config.maxSubagents = maxSubagents;
55
+ for (const key of INTEGER_FIELDS) {
56
+ const value = record[key];
57
+ if (value === undefined) continue;
58
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1) {
59
+ config[key] = value;
57
60
  } else {
58
- problems.push(`maxSubagents must be a safe integer >= 1, got ${JSON.stringify(maxSubagents)}`);
59
- }
60
- }
61
-
62
- const maxTurns = record.maxTurns;
63
- if (maxTurns !== undefined) {
64
- if (typeof maxTurns === "number" && Number.isSafeInteger(maxTurns) && maxTurns >= 1) {
65
- config.maxTurns = maxTurns;
66
- } else {
67
- problems.push(`maxTurns must be a safe integer >= 1, got ${JSON.stringify(maxTurns)}`);
61
+ problems.push(`${key} must be a safe integer >= 1, got ${JSON.stringify(value)}`);
68
62
  }
69
63
  }
70
64
 
@@ -770,7 +770,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
770
770
  description: "Run 1–8 independent Implementers in isolated Unit Worktrees, validate and serially fast-forward each tip, with exact review only for units that declare a judgment criterion.",
771
771
  promptSnippet: "Run a deterministic parallel-implementation, serial-verification Flow",
772
772
  promptGuidelines: [
773
- "Use delegate_flow only for cohesive units expected to commute: split independent outcomes into units, sequence dependent work outside delegate_flow, and never divide one invariant across multiple units. Combine work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.",
773
+ "Use delegate_flow only for cohesive units expected to commute: make independent commuting outcomes separate units rather than combining them merely to reduce Implementer count; sequence dependent work outside delegate_flow. On naturally multi-part work, actively look for roughly 3–5 useful units, but never manufacture units, split one invariant across multiple units, assign overlapping mutable ownership, or use a quota. Combine work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.",
774
774
  `${TASK_NAME_CONTRACT.promptGuidance} Each delegate_flow unit must own one concrete outcome with one focused validation story: include explicit bounded requirements and its authoritative direct command/argument validation gate. If the affected flow or scope is not yet known, perform bounded read-only discovery first. Add review only for an explicit judgment that validation cannot establish.`,
775
775
  `For each delegate_flow unit, ${MODEL_CLASS_GUIDANCE}`,
776
776
  "If a Flow blocks, inspect its classification and call delegate_flow_continue once with explicit repair guidance; modelClass may replace that one repair's current class.",
@@ -1,5 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { CHILD_EXCLUDED_TOOL_NAMES, EXECUTION_BUDGET_ENV, ROLE_TOOL_POLICY_FLAG } from "@henryqw/pi-subagent";
2
+ import {
3
+ CHILD_EXCLUDED_TOOL_NAMES,
4
+ EXECUTION_BUDGET_ENV,
5
+ ROLE_TOOL_POLICY_FLAG,
6
+ type EphemeralSubagentExecutionBudget,
7
+ } from "@henryqw/pi-subagent";
3
8
 
4
9
  const childExcludedTools: ReadonlySet<string> = new Set(CHILD_EXCLUDED_TOOL_NAMES);
5
10
  const WARNING_RATIO = 0.8;
@@ -24,7 +29,7 @@ function configuredTools(value: unknown): string[] {
24
29
  return [...new Set(parsed.map((name) => name.trim()))];
25
30
  }
26
31
 
27
- function executionBudget(value: string | undefined): { maxTurns: number; maxMs: number; startedAt: number } | undefined {
32
+ function executionBudget(value: string | undefined): EphemeralSubagentExecutionBudget | undefined {
28
33
  if (value === undefined) return;
29
34
  let parsed: unknown;
30
35
  try {
@@ -36,13 +41,22 @@ function executionBudget(value: string | undefined): { maxTurns: number; maxMs:
36
41
  throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
37
42
  }
38
43
  const budget = parsed as Record<string, unknown>;
39
- if (Object.keys(budget).length !== 3 || !("maxTurns" in budget) || !("maxMs" in budget) || !("startedAt" in budget)
44
+ const maxTokensValid = budget.maxTokens === undefined
45
+ || Number.isSafeInteger(budget.maxTokens) && (budget.maxTokens as number) >= 1;
46
+ if (Object.keys(budget).some((key) => !["maxTurns", "maxMs", "startedAt", "maxTokens"].includes(key))
47
+ || !("maxTurns" in budget) || !("maxMs" in budget) || !("startedAt" in budget)
40
48
  || !Number.isSafeInteger(budget.maxTurns) || (budget.maxTurns as number) < 1
41
49
  || typeof budget.maxMs !== "number" || !Number.isFinite(budget.maxMs) || budget.maxMs <= 0
42
- || !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0) {
50
+ || !Number.isSafeInteger(budget.startedAt) || (budget.startedAt as number) < 0
51
+ || !maxTokensValid) {
43
52
  throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
44
53
  }
45
- return { maxTurns: budget.maxTurns as number, maxMs: budget.maxMs, startedAt: budget.startedAt as number };
54
+ return {
55
+ maxTurns: budget.maxTurns as number,
56
+ maxMs: budget.maxMs,
57
+ startedAt: budget.startedAt as number,
58
+ ...(budget.maxTokens === undefined ? {} : { maxTokens: budget.maxTokens as number }),
59
+ };
46
60
  }
47
61
 
48
62
  function expectsAnotherTurn(message: unknown): boolean {
@@ -53,6 +67,18 @@ function expectsAnotherTurn(message: unknown): boolean {
53
67
  && (part as Record<string, unknown>).type === "toolCall");
54
68
  }
55
69
 
70
+ function messageTokens(message: unknown): number | undefined {
71
+ if (!message || typeof message !== "object" || Array.isArray(message)) return;
72
+ const usage = (message as Record<string, unknown>).usage;
73
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return;
74
+ const totalTokens = (usage as Record<string, unknown>).totalTokens;
75
+ return typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens >= 0 ? totalTokens : undefined;
76
+ }
77
+
78
+ function joinBudgetParts(parts: string[]): string {
79
+ return `${parts.slice(0, -1).join(", ")}${parts.length > 2 ? "," : ""} and ${parts.at(-1)}`;
80
+ }
81
+
56
82
  export default function roleTools(pi: ExtensionAPI): void {
57
83
  pi.registerFlag(ROLE_TOOL_POLICY_FLAG, {
58
84
  description: "Internal Pi Subagent Role tool policy",
@@ -82,30 +108,48 @@ export default function roleTools(pi: ExtensionAPI): void {
82
108
 
83
109
  if (!budget) return;
84
110
  const warningTurn = Math.ceil(budget.maxTurns * WARNING_RATIO);
111
+ const warningTokens = budget.maxTokens === undefined ? undefined : Math.ceil(budget.maxTokens * WARNING_RATIO);
85
112
  let completedTurns = 0;
113
+ let completedTokens = 0;
114
+ let currentTokens = 0;
86
115
  let turnWarningSent = false;
116
+ let tokenWarningSent = false;
87
117
  let runtimeWarningSent = false;
118
+ pi.on("message_update", (event) => {
119
+ currentTokens = messageTokens(event.message) ?? currentTokens;
120
+ });
88
121
  pi.on("turn_end", (event) => {
89
122
  completedTurns += 1;
90
- if (!expectsAnotherTurn(event.message) || handoffSent) return;
91
- if (completedTurns === budget.maxTurns - 1) {
123
+ completedTokens += messageTokens(event.message) ?? currentTokens;
124
+ currentTokens = 0;
125
+ const continuing = expectsAnotherTurn(event.message);
126
+ const tokenBudgetCrossed = budget.maxTokens !== undefined && completedTokens >= budget.maxTokens;
127
+ if (!handoffSent && (continuing && completedTurns === budget.maxTurns - 1 || tokenBudgetCrossed)) {
92
128
  pi.setActiveTools([]);
93
129
  pi.sendMessage(FINAL_HANDOFF_MESSAGE, { deliverAs: "steer", triggerTurn: false });
94
130
  handoffSent = true;
95
131
  return;
96
132
  }
133
+ if (!continuing || handoffSent) return;
97
134
  const elapsedMs = Math.max(0, Date.now() - budget.startedAt);
98
135
  const turnWarningDue = !turnWarningSent && completedTurns >= warningTurn;
136
+ const tokenWarningDue = warningTokens !== undefined && !tokenWarningSent && completedTokens >= warningTokens;
99
137
  const runtimeWarningDue = !runtimeWarningSent && elapsedMs >= budget.maxMs * WARNING_RATIO;
100
- if (!turnWarningDue && !runtimeWarningDue) return;
138
+ if (!turnWarningDue && !tokenWarningDue && !runtimeWarningDue) return;
101
139
  if (turnWarningDue) turnWarningSent = true;
140
+ if (tokenWarningDue) tokenWarningSent = true;
102
141
  if (runtimeWarningDue) runtimeWarningSent = true;
103
142
  const remainingTurns = Math.max(0, budget.maxTurns - completedTurns);
104
143
  const remainingMinutes = Math.max(0, Math.ceil((budget.maxMs - elapsedMs) / 60_000));
105
144
  const maxMinutes = budget.maxMs / 60_000;
145
+ const parts = [
146
+ `${remainingTurns} of ${budget.maxTurns} turns`,
147
+ ...(budget.maxTokens === undefined ? [] : [`${Math.max(0, budget.maxTokens - completedTokens)} of ${budget.maxTokens} tokens`]),
148
+ `approximately ${remainingMinutes} of ${maxMinutes} minutes`,
149
+ ];
106
150
  pi.sendMessage({
107
151
  customType: WARNING_MESSAGE_TYPE,
108
- content: `**Execution budget warning:** ${remainingTurns} of ${budget.maxTurns} turns and approximately ${remainingMinutes} of ${maxMinutes} minutes remain before forced termination.\nConverge now: stop expanding scope, complete the highest-priority required work, perform only essential validation, and return a concise final result. If completion is impossible, follow your role’s recovery requirements and report the blocker and exact remaining work. This warning does not change your role, scope, or permissions.`,
152
+ content: `**Execution budget warning:** ${joinBudgetParts(parts)} remain before forced termination.\nConverge now: stop expanding scope, complete the highest-priority required work, perform only essential validation, and return a concise final result. If completion is impossible, follow your role’s recovery requirements and report the blocker and exact remaining work. This warning does not change your role, scope, or permissions.`,
109
153
  display: true,
110
154
  }, { deliverAs: "steer", triggerTurn: false });
111
155
  });
@@ -301,6 +301,7 @@ export default function subagentExtension(
301
301
  const executor = createEphemeralSubagentExecutor({
302
302
  maxConcurrency: maxActiveSubagents,
303
303
  maxTurns: loadedConfig.config.maxTurns ?? DEFAULT_MAX_TURNS,
304
+ maxTokens: loadedConfig.config.maxTokens,
304
305
  timeout: timeoutPolicy,
305
306
  });
306
307
  // Background children outlive the launching tool call, so they get their own
@@ -595,10 +596,10 @@ export default function subagentExtension(
595
596
  description: `Delegate one selected single, parallel, or chain workflow of bounded tasks to isolated Pi Subagents. Roles: ${roleSummary()}.`,
596
597
  promptSnippet: "Delegate one bounded single, parallel, or chain workflow to isolated roles",
597
598
  promptGuidelines: [
598
- "Call delegate_task with exactly one mode: role+name+task for one task, tasks for 1–8 independent parallel tasks, or chain for 1–8 dependent sequential tasks using {previous} for the immediately preceding assistant output; split independent, commuting outcomes into parallel entries, sequence dependent work in chain entries, and never divide one invariant across multiple entries.",
599
- `${TASK_NAME_CONTRACT.promptGuidance} Every delegate_task entry must own one concrete outcome with one focused validation story: state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; if the affected flow or scope is not yet known, perform bounded read-only discovery first; never pass the parent request unchanged.`,
599
+ "Call delegate_task with exactly one mode: role+name+task in single mode for one atomic task, tasks for 1–8 independent parallel tasks, or chain for 1–8 dependent sequential tasks using {previous} for the immediately preceding assistant output; prefer parallel mode whenever at least two independent outcomes exist and each is independently deliverable and independently verifiable; do not combine independent outcomes merely to reduce child count. Sequence dependent work in chain entries, and never divide one invariant across multiple entries.",
600
+ `${TASK_NAME_CONTRACT.promptGuidance} Every delegate_task entry must own one concrete outcome with one focused validation story: state its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and validation; if the affected flow or scope is not yet known, perform bounded read-only discovery first; never pass the parent request unchanged. For naturally multi-part delegate_task work, you may look for roughly 3–5 useful entries, but do not manufacture entries or enforce a quota.`,
600
601
  `For each delegate_task entry, ${MODEL_CLASS_GUIDANCE} A direct model replaces only the selected route's model; its thinking level stays unchanged.`,
601
- "Parallel delegate_task entries must own non-overlapping files. Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.",
602
+ "Parallel mutating delegate_task entries must own non-overlapping files and changes. Read-only delegate_task entries may inspect overlapping sources when their questions and deliverables differ. Keep integration and cross-cutting decisions in Main.",
602
603
  "delegate_task background applies to the whole selected workflow and returns before results exist; use it only when the user explicitly asks for non-blocking work.",
603
604
  ],
604
605
  parameters: WorkflowSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "15.1.0",
3
+ "version": "15.1.2",
4
4
  "description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -11,7 +11,9 @@ You are Main, the planner/orchestrator: slice work and choose `delegate_flow` or
11
11
 
12
12
  Before slicing, identify applicable repository prohibitions. If the request or plan conflicts with them, stop and resolve the conflict before delegation. Copy them into every affected task and into `review` when automated validation cannot establish compliance; never replace repository policy with generic preservation or migration assumptions. When compatibility is disallowed, require deletion of replaced paths and forbid legacy readers, aliases, adapters, dual schemas, deprecation paths, and compatibility fallbacks.
13
13
 
14
- Use the fewest cohesive units. `delegate_flow` is for independent units expected to commute: split independent outcomes into units, combine or sequence work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants, and never divide one invariant across multiple units. Dependent work remains outside Flow; sequence it in one task or ordinary caller-controlled sequencing.
14
+ Run a concise decomposition pass. Identify independently deliverable concrete outcomes. Maximize safe parallelism at those boundaries: give each outcome focused validation and clear ownership, and parallelize commuting work. Prefer parallel delegation when at least two independent outcomes exist. For naturally multi-part work, roughly 3–5 useful units is a guide, not a quota. Never manufacture units.
15
+
16
+ Before selecting a Flow unit, require that one Implementer launch can plausibly finish before the configured maximum runtime. Cohesion alone is not enough when work has multiple preservable, separately verifiable milestones. Use `delegate_flow` for independent units expected to commute. Keep work together or sequence it when splitting would divide an invariant, overlap mutable ownership, or create coordination. Combine or sequence work that overlaps files, APIs, schemas, generated output, package metadata, lockfiles, or invariants. Units inside one Flow remain independent and commuting. Dependent work remains outside Flow. Split oversized dependent work into serial one-unit Flows after each milestone integrates; otherwise sequence it in one task or ordinary caller-controlled sequencing.
15
17
 
16
18
  Give every unit a bounded objective, owned scope and exclusions, and its direct validation command/argument array. Each task packet must name the neighboring behavior that must stay unchanged. Include the exact test name or error when known CI evidence exists. Never claim a validation command matches unknown CI.
17
19
 
@@ -29,13 +31,13 @@ A successful Flow owns integration and cleanup. A blocked outcome is repairable
29
31
  delegate_flow_continue({ guidance: "Address the reported block and complete the bounded unit.", modelClass: "balanced" })
30
32
  ```
31
33
 
32
- Make the guidance specific to the reported implementation, validation, or review failure. Omit `modelClass` to retain an explicit blocked-unit class or otherwise use each frozen Role's default; supply it only to replace both defaults for that one repair. Do not call continuation unless Flow reports a repairable block. If continuation or Flow returns a terminal failure, inspect every retained path reported by the runtime, then reslice or manually recover from Main; do not retry the Flow or guess a rebase resolution.
34
+ Make the guidance specific to the reported implementation, validation, or review failure. Omit `modelClass` to retain an explicit blocked-unit class or otherwise use each frozen Role's default; supply it only to replace both defaults for that one repair. Do not call continuation unless Flow reports a repairable block. If continuation or Flow returns a terminal failure, inspect each exact retained path reported by the runtime directly, then reslice or manually recover from Main; do not retry the Flow or guess a rebase resolution. Do not run `git worktree list` merely to rediscover a retained path.
33
35
 
34
36
  A cleanup warning does not undo successful integration. Report a cleanup warning from a successful Flow as-is. Do not investigate it unless the user asks or cleanup is part of acceptance.
35
37
 
36
38
  ## Ordinary delegation
37
39
 
38
- Use `delegate_task` for a single bounded task, independent parallel tasks, or dependent chain work that is not a Flow. Give each entry its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and focused validation. Choose `modelClass` according to the delegation tool's guidance. A direct `model` replaces only the selected route's model. The route keeps its thinking level. Keep integration and cross-cutting decisions in Main, and use the minimum number of Subagents needed.
40
+ Use `delegate_task` for a single bounded task, independent parallel tasks, or dependent chain work that is not a Flow. Give each entry its objective, exact scope and exclusions, relevant context and constraints, expected deliverable, and focused validation. Choose `modelClass` according to the delegation tool's guidance. A direct `model` replaces only the selected route's model. The route keeps its thinking level. Keep integration and cross-cutting decisions in Main.
39
41
 
40
42
  ### Optional evidence loop for implementation
41
43