@henryqw/pi-subagent 9.0.0 → 10.1.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
@@ -23,13 +23,13 @@ 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`, positive safe integer) 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. On continuing `turn_end`, the existing child 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/pi-subagent.json` (`maxTurns` default 50; `timeout.idleMinutes`/`maxMinutes` defaults 10/30).
27
- - Up to five active ephemeral `delegate_task` children run per Main by default, configurable via `maxSubagents` in `~/.pi/agent/config/pi-subagent/pi-subagent.json` or the `PI_SUBAGENT_MAX_SUBAGENTS` environment variable; excess calls wait FIFO. Queued calls do not start a child or consume child timeout.
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`, positive safe integer) 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. On continuing `turn_end`, the existing child 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; `timeout.idleMinutes`/`maxMinutes` defaults 10/30).
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.
30
30
  - Main policy populates direct `model` and `thinking` only for explicit user overrides; otherwise it selects only `modelClass` (`fast` normally, `balanced` upfront for obvious complexity). This has no provenance tracking or runtime enforcement. An omitted class uses pi-subagent's local `pi-subagent/delegateTask` Model Task declaration (default `fast`); library callers select a Role plus their own Model Task declaration.
31
- - The selected profile resolves primary then fallback only before launch when a route, model, or thinking level is unavailable. If neither route is usable, launch rejects with `Run /task-models`; a started child is never retried by this package.
32
- - User Role Markdown files and Subagent JSON config live only in the user `config/pi-subagent` directory; model routes live in shared `config/pi-task-models.json`. Package-shipped built-in Roles (`implementer`, `reviewer`) resolve from the package's own `examples/roles/` Markdown through the same parser; a same-named user file explicitly overrides a built-in for `delegate_task` and `delegate_flow`.
31
+ - The selected profile resolves primary then fallback only before launch when a route, model, or thinking level is unavailable. A missing local JSON config uses defaults quietly. Missing shared task-model config warns once per session because delegation needs a route. If neither route is usable, launch rejects with `Run /task-models`; a started child is never retried by this package.
32
+ - User Role Markdown files and Subagent JSON config (`config/pi-subagent/config.json`) live only in the user `config/pi-subagent` directory; model routes live in shared `config/pi-task-models/config.json`. Package-shipped built-in Roles (`implementer`, `reviewer`, `scout`) resolve from the package's own `examples/roles/` Markdown through the same parser; same-named user files override built-ins for `delegate_task`, while same-named `implementer` and `reviewer` files override Flow defaults. The `synthesizer` Markdown remains the only optional inert sample.
33
33
  - `delegate_flow` accepts 1–8 independent units with direct validation commands, optional `modelClass`, and optional non-empty `review` judgment text. Omitted classes use pi-subagent's local `pi-subagent/delegateTask` declaration (default `fast`); each unit's current class resolves through its shared profile route for its Implementer and, when `review` exists, Reviewer. At Flow start it always resolves/freezes the effective Implementer and resolves/freezes a Reviewer only when at least one requested unit has `review`. One active Flow creates every Unit Worktree before launching Implementers in parallel, then processes settled units in declared order. For each unit Flow rebases in place when earlier units advanced Main, inspects committed Git state, and runs declared validation. Validation is authoritative for objective verification: units without `review` fast-forward their exact validated tip; units with `review` send the exact Review Packet to the Reviewer in the same worktree and require exact `PASS` before the same guarded `git merge --ff-only` path.
34
34
  - Flow is memory-only. Only a post-rebase commit drop produces a no-op (`base === tip`); it validates, skips Reviewer and merge, then cleans up ordinarily. Initial zero-commit implementations block. Implementer, validation, or reviewer blocks allow one `delegate_flow_continue({ guidance, modelClass? })` repair in the same worktree; omission retains the Unit's current class and presence replaces it for that repair. A second block is terminal. Rebase and evidence/Reviewer/infrastructure failures retain worktrees. A reported fast-forward failure retains its worktree unless Main is clean at the exact integrated tip, which completes with the merge diagnostic as a warning. Cleanup uses non-forced worktree removal and branch deletion; cleanup refusal is a completion warning.
35
35
  - Flow has no graph, saved recovery, automatic retry, aggregate review, or post-merge validation. It never changes generic `delegate_task` Role resolution, isolation, non-Git fallback, or ordinary direct plan/file review.
package/README.md CHANGED
@@ -22,7 +22,9 @@ pi install npm:@henryqw/pi-subagent
22
22
  | `@henryqw/pi-multi-codex` | Required. Children can use Main's active Codex slot. |
23
23
  | `@henryqw/pi-task-models` | Required. Shared `fast` / `balanced` / `frontier` / `fav` routes. |
24
24
 
25
- Model routing is not configured here. Children resolve routes through shared `@henryqw/pi-task-models` config at `~/.pi/agent/config/pi-task-models.json`, which stores only explicit task overrides. The local `pi-subagent/delegateTask` declaration supplies the omitted-class default.
25
+ Model routing is not configured here. Children resolve routes through shared `@henryqw/pi-task-models` config at `~/.pi/agent/config/pi-task-models/config.json`, which stores only explicit task overrides. The local `pi-subagent/delegateTask` declaration supplies the omitted-class default.
26
+
27
+ The local config is optional and uses defaults quietly when missing. If shared task-model config is missing, pi-subagent warns once at session start because delegation needs a route.
26
28
 
27
29
  ## Use
28
30
 
@@ -32,7 +34,7 @@ Model routing is not configured here. Children resolve routes through shared `@h
32
34
  | `delegate_flow` | tool | Package-owned parallel implementation and declared-order Git integration for 1–8 independent units. |
33
35
  | `delegate_flow_continue` | tool | Repair the blocked Flow unit once in its existing worktree. |
34
36
 
35
- All three delegation tool blocks use Pi's default boxed shell and background. Their compact custom content is an immutable call label, foreground aggregate partial-result status, and bounded final summaries. There is no expanded view.
37
+ All three delegation tools use Pi's built-in renderer. Its tool-execution block shows live updates and Pi's configured expansion hint; expanding shows the call and result content.
36
38
 
37
39
  ### `delegate_task`
38
40
 
@@ -40,15 +42,17 @@ Select exactly one shape:
40
42
 
41
43
  ```text
42
44
  // Single
43
- { role, task, model?, modelClass?, thinking?, background? }
45
+ { role, name, task, model?, modelClass?, thinking?, background? }
44
46
 
45
47
  // Parallel: 1–8 independent delegations
46
- { tasks: [{ role, task, model?, modelClass?, thinking? }], background? }
48
+ { tasks: [{ role, name, task, model?, modelClass?, thinking? }], background? }
47
49
 
48
50
  // Chain: 1–8 dependent delegations
49
- { chain: [{ role, task, model?, modelClass?, thinking? }], background? }
51
+ { chain: [{ role, name, task, model?, modelClass?, thinking? }], background? }
50
52
  ```
51
53
 
54
+ Main supplies every delegation's required `name`: a short description of about five words and fewer than 30 characters. Names must not contain C0/C1 control characters, including newlines and terminal escape characters.
55
+
52
56
  #### Model precedence
53
57
 
54
58
  1. An explicit `model` is `provider/modelId` and overrides `modelClass`.
@@ -60,6 +64,8 @@ Select exactly one shape:
60
64
 
61
65
  This is Main policy only. The runtime records no provenance and does not enforce it. `background` applies to the whole selected mode and is never a per-delegation field.
62
66
 
67
+ The transient status widget renders one line per child with: status glyph, bracketed uppercase role initial (`[I]` for `implementer`, `[R]` for `reviewer`, and likewise for custom Roles), Main-supplied short name, activity (thinking… or active tool with elapsed time and path basename), and metrics (completed turns, started tools, compact `model·thinking` pair, tokens, total duration). Rows are ordered active-first (working items first, stable insertion order for the rest). A hard six-physical-line maximum applies: when total items are six or fewer, all child rows render; above six, five child rows plus one status-aware overflow line render (`… N more · X working · Y complete · Z failed · W stopped`). Terminal rows clear on the next real user input; active rows persist until the child settles. It is separate from Pi's built-in tool-execution block.
68
+
63
69
  #### Modes, limits, and isolation
64
70
 
65
71
  - Parallel mode starts entries concurrently, waits for every entry, and reports them in input order.
@@ -77,10 +83,10 @@ See [Orchestration, isolation, and the public API](./docs/orchestration.md) for
77
83
 
78
84
  Use Flow only for independent, commuting Git changes. Commuting changes can integrate in any order.
79
85
 
80
- Flow accepts 1–8 uniquely identified units. Each unit has a bounded task, optional `modelClass`, direct command/argument validation gate, and optional non-empty `review` judgment criterion:
86
+ Flow accepts 1–8 uniquely identified units. Each unit has a required Main-supplied short `name`, bounded task, optional `modelClass`, direct command/argument validation gate, and optional non-empty `review` judgment criterion. Names must not contain C0/C1 control characters, including newlines and terminal escape characters.
81
87
 
82
88
  ```text
83
- delegate_flow({ units: [{ id, task, modelClass?, validation: [{ command, args }], review? }] })
89
+ delegate_flow({ units: [{ id, name, task, modelClass?, validation: [{ command, args }], review? }] })
84
90
  delegate_flow_continue({ guidance, modelClass? })
85
91
  ```
86
92
 
@@ -129,7 +135,7 @@ Rebase and infrastructure failures are terminal. A reported fast-forward failure
129
135
 
130
136
  Otherwise it is terminal and retains the affected worktree. Flow has no graph, saved recovery, automatic retry, aggregate review, or post-merge gate.
131
137
 
132
- `delegate_task` keeps its generic isolation behavior. Flow uses the package-shipped Implementer by default and the package-shipped Reviewer only when a unit requests review. Same-named user Roles remain supported overrides.
138
+ `delegate_task` keeps its generic isolation behavior. Flow uses the package-shipped Implementer by default and the package-shipped Reviewer only when a unit requests review; the built-in Scout is not part of Flow. Same-named user Roles remain supported overrides.
133
139
 
134
140
  ### Delegate UI summary
135
141
 
@@ -137,13 +143,11 @@ The transient status widget shows status glyph, role, status label, task summary
137
143
 
138
144
  | Aspect | Behavior |
139
145
  | --- | --- |
140
- | Call label | `delegate_task · single/parallel/chain · N task(s)`; `delegate_flow · parallel→serial · N unit(s)`; `delegate_flow_continue · repair continuation` |
141
- | Partial progress | `delegate_task`: aggregate counts (running/pending/complete/failed/skipped); `delegate_flow`: phase transitions (setup implement → verify/integrate → review → repair) |
142
- | Widget rows | One line per child: glyph, role, status, task, activity, metrics |
146
+ | Tool-execution block | Pi's built-in renderer: live updates and expandable call/result content |
147
+ | Widget rows | One line per child: glyph, `[role initial]`, short name, activity, metrics |
143
148
  | Ordering | Active-first stable (working first, then insertion order) |
144
149
  | Line cap | 6 physical lines max (≤6 items: all child rows; >6 items: 5 rows + 1 status-aware overflow) |
145
150
  | Terminal retention | Active rows persist; terminal rows clear on next user input |
146
- | Final result | Bounded summaries with recovery paths; no expanded view |
147
151
 
148
152
  - Rows are active-first: working items first, then stable insertion order.
149
153
  - The hard six-physical-line maximum shows all child rows for six or fewer items.
@@ -157,7 +161,7 @@ pi-subagent owns the extension-named config directory `~/.pi/agent/config/pi-sub
157
161
 
158
162
  One Markdown file belongs to each Role (see [Roles](#roles)). The other is its own optional JSON file below.
159
163
 
160
- `~/.pi/agent/config/pi-subagent/pi-subagent.json` controls the child pool and execution limits. All fields are optional. A missing file uses defaults.
164
+ `~/.pi/agent/config/pi-subagent/config.json` controls the child pool and execution limits. All fields are optional. A missing file uses defaults without a warning.
161
165
 
162
166
  | Field | Required | Possible values | Default |
163
167
  | --- | --- | --- | --- |
@@ -196,28 +200,28 @@ An unreadable or invalid Role file fails role loading fast. Duplicate role names
196
200
 
197
201
  ## Roles
198
202
 
199
- The package ships two working built-in Roles. They are always available without configuration.
203
+ The package ships three working built-in Roles. They are always available without configuration.
200
204
 
201
205
  - `implementer`: focused edits requesting worktree isolation; commits completed scoped changes locally and never pushes or opens PRs without authorization
202
206
  - `reviewer`: read-only correctness review of supplied plans or files, or—only when a Flow unit declares `review`—of Flow's exact `{base, tip, patchPath}` packet in its Unit Worktree; never edits or commits
207
+ - `scout`: read-only code and evidence mapping for one bounded task; never changes files
203
208
 
204
- A same-named Markdown file in `~/.pi/agent/config/pi-subagent/` explicitly overrides the built-in default.
209
+ A same-named Markdown file in `~/.pi/agent/config/pi-subagent/` explicitly overrides the built-in default. The built-in `scout` is available to generic `delegate_task`; `delegate_flow` remains limited to its fixed Implementer/Reviewer protocol.
205
210
 
206
- The repository also includes optional inert samples:
211
+ The repository also includes one optional inert sample:
207
212
 
208
- - [`scout`](./examples/roles/scout.md): read-only discovery
209
213
  - [`synthesizer`](./examples/roles/synthesizer.md): reconcile supplied reports
210
214
 
211
- Copy them manually from your installed `@henryqw/pi-subagent` package if you want a starting point. npm installs ship the `examples/roles/` directory.
215
+ Copy it manually from your installed `@henryqw/pi-subagent` package if you want a starting point. npm installs ship the `examples/roles/` directory.
212
216
 
213
217
  ```bash
214
218
  mkdir -p ~/.pi/agent/config/pi-subagent
215
- cp <package-install-dir>/examples/roles/scout.md ~/.pi/agent/config/pi-subagent/
219
+ cp <package-install-dir>/examples/roles/synthesizer.md ~/.pi/agent/config/pi-subagent/
216
220
  ```
217
221
 
218
222
  Locate the install directory with `npm root` inside your project, or through Pi's package installation path.
219
223
 
220
- The package never installs or writes Role configuration. Sample names are not built-ins. After copying, edit or replace them as your own Roles.
224
+ The package never installs or writes Role configuration. After copying, edit or replace `synthesizer.md` as your own Role.
221
225
 
222
226
  ## Skill
223
227
 
package/dist/index.d.ts CHANGED
@@ -45,14 +45,13 @@ export interface ResolvedRoleSkills {
45
45
  paths: string[];
46
46
  missing: string[];
47
47
  }
48
- declare const BUILTIN_ROLE_NAMES: readonly ["implementer", "reviewer"];
48
+ declare const BUILTIN_ROLE_NAMES: readonly ["implementer", "reviewer", "scout"];
49
49
  export type BuiltinRoleName = (typeof BUILTIN_ROLE_NAMES)[number];
50
50
  export declare function loadBuiltinRole(name: BuiltinRoleName): Role;
51
51
  /**
52
- * Validated built-in implementer/reviewer Roles plus valid user roles from
53
- * `config/pi-subagent`. A user role with a built-in name overrides the default;
54
- * duplicate names among user files are an error. Missing user directory returns
55
- * the built-ins.
52
+ * Validated package-shipped Roles plus valid user roles from `config/pi-subagent`.
53
+ * A user role with a built-in name overrides the default; duplicate names among
54
+ * user files are an error. Missing user directory returns the built-ins.
56
55
  */
57
56
  export declare function loadRoles(agentDir?: string): Role[];
58
57
  export declare function resolveTaskRoute(ctx: ExtensionContext, profileName: ProfileName, agentDir?: string, thinking?: ThinkingLevel): ResolvedTaskRoute;
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import { readFileSync, readdirSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
5
- import { modelReference, orderedProfileRoutes, readTaskModelsConfig, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
5
+ import { extensionConfigDir } from "@henryqw/pi-config-store";
6
+ import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
6
7
  export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
7
8
  export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, worktreeContextNote, } from "./worktree.js";
8
9
  export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
@@ -45,9 +46,8 @@ function validateExtension(extension, source) {
45
46
  function extensionList(value, source) {
46
47
  return stringList(value, "extensions", source).map((extension) => validateExtension(extension, source));
47
48
  }
48
- // Built-in Roles required by the bundled pi-subagent-delegated-development Skill;
49
- // resolved from the package-shipped Markdown relative to this module.
50
- const BUILTIN_ROLE_NAMES = ["implementer", "reviewer"];
49
+ // Built-in Roles resolved from the package-shipped Markdown relative to this module.
50
+ const BUILTIN_ROLE_NAMES = ["implementer", "reviewer", "scout"];
51
51
  /** Single-file Role parser shared by built-in and user roles. */
52
52
  function parseRoleFile(file, raw) {
53
53
  let parsed;
@@ -88,14 +88,13 @@ function builtinRoles() {
88
88
  return BUILTIN_ROLE_NAMES.map(loadBuiltinRole);
89
89
  }
90
90
  /**
91
- * Validated built-in implementer/reviewer Roles plus valid user roles from
92
- * `config/pi-subagent`. A user role with a built-in name overrides the default;
93
- * duplicate names among user files are an error. Missing user directory returns
94
- * the built-ins.
91
+ * Validated package-shipped Roles plus valid user roles from `config/pi-subagent`.
92
+ * A user role with a built-in name overrides the default; duplicate names among
93
+ * user files are an error. Missing user directory returns the built-ins.
95
94
  */
96
95
  export function loadRoles(agentDir = getAgentDir()) {
97
96
  const byName = new Map(builtinRoles().map((role) => [role.name, role]));
98
- const dir = join(agentDir, "config", "pi-subagent");
97
+ const dir = extensionConfigDir("pi-subagent", agentDir);
99
98
  let entries;
100
99
  try {
101
100
  entries = readdirSync(dir, { withFileTypes: true });
@@ -121,7 +120,7 @@ export function loadRoles(agentDir = getAgentDir()) {
121
120
  export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir(), thinking) {
122
121
  let config;
123
122
  try {
124
- config = readTaskModelsConfig(agentDir);
123
+ config = loadTaskModelsConfig(agentDir).value;
125
124
  }
126
125
  catch {
127
126
  throw new Error("Couldn't read task model config. Run /task-models.");
@@ -12,7 +12,7 @@ Resource Policy is split at launch preparation:
12
12
  - Caller may add explicit tools, extensions, and environment through `createRoleLaunch`.
13
13
  - The executor receives the resulting Pi Launch and does not discover resources.
14
14
 
15
- Built-in `implementer` and `reviewer` Roles ship as Markdown in `examples/roles/` and use the same parser as user Roles. For generic delegation, a same-named user Role explicitly overrides a built-in. The package does not install, copy, or write user configuration.
15
+ Built-in `implementer`, `reviewer`, and `scout` Roles ship as Markdown in `examples/roles/` and use the same parser as user Roles. For generic delegation, a same-named user Role explicitly overrides a built-in. The package does not install, copy, or write user configuration.
16
16
 
17
17
  Main populates direct `model` and `thinking` only for explicit user overrides; otherwise it chooses only `modelClass` (`fast` normally, `balanced` upfront for obvious complexity). This is tool policy, not executor provenance tracking or runtime enforcement.
18
18
 
@@ -23,6 +23,7 @@ A call selects exactly one of these shapes. Unknown properties and nested modes
23
23
  ```json
24
24
  {
25
25
  "role": "user-configured-role",
26
+ "name": "Inspect authentication flow",
26
27
  "task": "One bounded task packet",
27
28
  "modelClass": "balanced",
28
29
  "thinking": "high",
@@ -37,8 +38,8 @@ Single mode puts one delegation's fields at the top level.
37
38
  ```json
38
39
  {
39
40
  "tasks": [
40
- { "role": "user-role-a", "task": "Inspect subsystem A", "modelClass": "fast" },
41
- { "role": "user-role-b", "task": "Inspect subsystem B", "model": "provider/model-id" }
41
+ { "role": "user-role-a", "name": "Inspect subsystem A", "task": "Inspect subsystem A", "modelClass": "fast" },
42
+ { "role": "user-role-b", "name": "Inspect subsystem B", "task": "Inspect subsystem B", "model": "provider/model-id" }
42
43
  ],
43
44
  "background": false
44
45
  }
@@ -51,9 +52,9 @@ Single mode puts one delegation's fields at the top level.
51
52
  ```json
52
53
  {
53
54
  "chain": [
54
- { "role": "user-role-a", "task": "Collect evidence" },
55
- { "role": "user-role-b", "task": "Review this evidence:\n{previous}" },
56
- { "role": "user-role-c", "task": "Summarize this review:\n{previous}" }
55
+ { "role": "user-role-a", "name": "Collect evidence", "task": "Collect evidence" },
56
+ { "role": "user-role-b", "name": "Review collected evidence", "task": "Review this evidence:\n{previous}" },
57
+ { "role": "user-role-c", "name": "Summarize evidence review", "task": "Summarize this review:\n{previous}" }
57
58
  ],
58
59
  "background": false
59
60
  }
@@ -65,13 +66,14 @@ Single mode puts one delegation's fields at the top level.
65
66
 
66
67
  | Field | Required | Contract |
67
68
  | --- | --- | --- |
68
- | `role` | yes | Name of a Role in the user's effective `config/pi-subagent` directory or a package-shipped built-in (`implementer`, `reviewer`); a same-named user file overrides the built-in. |
69
+ | `role` | yes | Name of a Role in the user's effective `config/pi-subagent` directory or a package-shipped built-in (`implementer`, `reviewer`, `scout`); a same-named user file overrides the built-in. |
70
+ | `name` | yes | Main-supplied short task name: about five words and fewer than 30 characters; C0/C1 control characters are rejected. |
69
71
  | `task` | yes | Non-empty bounded task packet. |
70
72
  | `model` | no | Designated `provider/modelId`; takes precedence over `modelClass`, and Main supplies it only for an explicit user override. |
71
73
  | `modelClass` | no | `fast`, `balanced`, `frontier`, or `fav`; Main normally chooses `fast`, may choose `balanced` upfront for obvious complexity, and omission uses pi-subagent's local Model Task declaration. |
72
74
  | `thinking` | no | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; Main supplies it only for an explicit user override. Route selection skips models that cannot honor it. |
73
75
 
74
- Those five fields are the complete delegation object. The direct-model/thinking rule is Main-facing policy only: the runtime adds no provenance tracking or enforcement. `tasks`, `chain`, and `background` cannot be nested. Route fallback occurs only before launch; a started child is never retried by this package.
76
+ Those six fields are the complete delegation object. The direct-model/thinking rule is Main-facing policy only: the runtime adds no provenance tracking or enforcement. `tasks`, `chain`, and `background` cannot be nested. Route fallback occurs only before launch; a started child is never retried by this package.
75
77
 
76
78
  ### Background, failures, and transport
77
79
 
@@ -83,7 +85,7 @@ All Main-visible text for one tool call shares one aggregate 50 KiB UTF-8 transp
83
85
 
84
86
  ## `delegate_flow`
85
87
 
86
- `delegate_flow({ units })` accepts 1–8 units with unique non-empty `id` and `task`, one or more direct `{command, args}` validation commands, optional `modelClass`, and optional non-empty `review` text. `delegate_flow_continue({ guidance, modelClass? })` is available only for the one blocked unit of the active Flow.
88
+ `delegate_flow({ units })` accepts 1–8 units with unique non-empty `id`, a required Main-supplied short `name` (about five words and fewer than 30 characters, without C0/C1 control characters), a non-empty `task`, one or more direct `{command, args}` validation commands, optional `modelClass`, and optional non-empty `review` text. `delegate_flow_continue({ guidance, modelClass? })` is available only for the one blocked unit of the active Flow.
87
89
 
88
90
  A Flow is memory-only and permits one active Flow. At start it always resolves/freezes the effective `implementer` Role, including a same-named user override. It resolves/freezes the effective `reviewer` only if at least one requested unit declares `review`. Omitted unit classes use pi-subagent's local `pi-subagent/delegateTask` declaration (default `fast`); a selected class resolves through its existing `pi-task-models` profile model-and-thinking route for the unit's Implementer and, when applicable, Reviewer. It requires clean committed Git Main and creates every Unit Worktree before launching work; setup failure launches no Implementer. Each unit gets exactly one worktree and one Implementer. Implementers run in parallel and all settle. Flow then processes units in declared order:
89
91
 
@@ -106,7 +108,7 @@ If rebase drops all unit commits, `base === tip` is a no-op: Flow validates curr
106
108
 
107
109
  Flow has no dependency graph, saved state, automatic retry, aggregate review, or post-merge validation. Use it only for commuting changes; combine or sequence units that overlap files, APIs, schemas, generated output, package metadata, lockfiles, or invariants.
108
110
 
109
- `delegate_task` remains generic: its optional worktree isolation, non-Git behavior, and direct plan/file review are unchanged. Flow uses package-shipped Roles as defaults while retaining same-named user Role overrides; the Reviewer is needed only for a requested review criterion.
111
+ `delegate_task` remains generic: its optional worktree isolation, non-Git behavior, and direct plan/file review are unchanged. The built-in `scout` is available only through generic `delegate_task`; Flow uses package-shipped Implementer and conditional Reviewer Roles as defaults while retaining same-named user Role overrides.
110
112
 
111
113
  ## Per-delegation resources and isolation
112
114
 
@@ -389,30 +391,30 @@ The verdict schema, parser, round state, shared workspace, and terminal decision
389
391
 
390
392
  ## Built-in Roles and samples
391
393
 
392
- The package ships two working built-in Roles, validated by the same parser as user roles and always present even with no `config/pi-subagent` directory:
394
+ The package ships three working built-in Roles, validated by the same parser as user roles and always present even with no `config/pi-subagent` directory:
393
395
 
394
396
  | Built-in | Behavior |
395
397
  | --- | --- |
396
398
  | `implementer` | Focused implementation requesting `isolation: worktree`; commits scoped changes locally, never pushes or opens PRs without authorization. Non-Git or unborn-`HEAD` contexts may use Main's cwd. |
397
399
  | `reviewer` | Read-only correctness review of supplied plans/files, or—when a Flow unit declares `review`—Flow's exact `{base, tip, patchPath}` packet in its Unit Worktree; never edits or commits. |
400
+ | `scout` | Read-only code and evidence mapping for one bounded task; never changes files. |
398
401
 
399
402
  A same-named Markdown file in `config/pi-subagent/` explicitly overrides the built-in default.
400
403
 
401
- Additional repository samples are inert starting points, not installed configuration:
404
+ The repository includes one optional inert sample, not installed configuration:
402
405
 
403
406
  | Sample | Intended starting point |
404
407
  | --- | --- |
405
- | [`scout`](../examples/roles/scout.md) | Read-only code/evidence mapping. |
406
408
  | [`synthesizer`](../examples/roles/synthesizer.md) | Reconcile supplied reports without broad discovery. |
407
409
 
408
- Copy the package-shipped samples from your installed `@henryqw/pi-subagent` package (npm installs include `examples/roles/`) if you want them:
410
+ Copy the package-shipped sample from your installed `@henryqw/pi-subagent` package (npm installs include `examples/roles/`) if you want it:
409
411
 
410
412
  ```bash
411
413
  mkdir -p ~/.pi/agent/config/pi-subagent
412
- cp <package-install-dir>/examples/roles/scout.md ~/.pi/agent/config/pi-subagent/
414
+ cp <package-install-dir>/examples/roles/synthesizer.md ~/.pi/agent/config/pi-subagent/
413
415
  ```
414
416
 
415
- The package never creates, copies, updates, or removes files in `~/.pi/agent/config/pi-subagent/`. Once copied, the files and their names are entirely user-owned.
417
+ The package never creates, copies, updates, or removes files in `~/.pi/agent/config/pi-subagent/`. Once copied, the file and its name are entirely user-owned.
416
418
 
417
419
  The bundled [`pi-subagent-delegated-development`](../skills/pi-subagent-delegated-development/SKILL.md) Skill is Main-side planner/orchestrator policy only. `delegate_flow` owns its fixed Git mechanics and objective validation authority; the Skill defines no runtime code or configuration. `delegate_task` remains the generic flat single/parallel/chain mechanism.
418
420
 
@@ -14,12 +14,12 @@ skills: []
14
14
  isolation: worktree
15
15
  ---
16
16
 
17
- Implement one bounded task.
17
+ Implement the bounded outcome, not a preassigned file list. Work in the assigned cwd. Read applicable repository instructions and domain context first; inspect the relevant flow, callers, and tests before editing. Preserve unrelated work. Fix the root cause with the smallest complete diff, reusing existing patterns and dependencies. Do not add speculative work. Stop when the outcome is complete or blocked.
18
18
 
19
- Read applicable repository instructions and domain context first. Inspect the existing flow and its callers before editing. Work only in the assigned cwd and explicitly assigned files; preserve unrelated changes. Fix the root cause with the smallest complete diff, reusing existing patterns and dependencies.
19
+ For ordinary delegation, run focused validation needed to establish correctness. For Flow, the declared validation gate is authoritative: run only narrow development checks while implementing and do not duplicate that final gate.
20
20
 
21
- For ordinary delegation, run the focused validation needed to establish that the change is correct. When a Flow packet declares an authoritative validation gate, treat that gate as the final validation: run only narrow development checks needed while implementing, and do not duplicate the declared gate. Do not access credentials, use the network, generate artifacts, or broaden scope unless the task explicitly requires it. Never invoke external LLM APIs, SDKs, agent harnesses, or model CLIs; deterministic developer tools remain allowed.
21
+ Do not access credentials, use the network, generate artifacts, or broaden scope unless the task explicitly requires it. Never invoke external LLM APIs, SDKs, agent harnesses, or model CLIs.
22
22
 
23
- Commit completed scoped changes locally unless the task forbids it. Never create or manage another worktree. Never push or open pull requests without explicit authorization.
23
+ Commit completed scoped changes locally unless the task says otherwise. Do not create or manage another worktree. Never push or open a pull request without explicit authorization. For Flow, leave the assigned worktree and branch intact.
24
24
 
25
- For ordinary delegation, report the completed change, validation, and remaining risks. For Flow, return the retained assigned cwd, branch, base commit, tip commit, changed files from the base-to-tip committed diff, clean `git status --porcelain=v1 --untracked-files=all` result, and validation results. Do not remove the retained worktree or task branch; Main cleans them only after successful integration and validation.
25
+ Report briefly: outcome, commit, checks run, and remaining risks. Do not repeat Flow's Git-derived evidence.
@@ -10,15 +10,11 @@ extensions: []
10
10
  skills: []
11
11
  ---
12
12
 
13
- Perform a read-only correctness review of one bounded change.
13
+ Review read-only in exactly two modes:
14
14
 
15
- Support exactly two review modes:
15
+ 1. Ordinary delegation: use supplied requirements and named files/evidence only. Do not prepare Git, require a commit/Review Packet, or broaden discovery. If evidence is insufficient, say so and stop.
16
+ 2. Flow exact review: only with an explicit judgment criterion, use the same assigned Unit Worktree and exact Review Packet `{base, tip, patchPath}`. Treat the exact patch at `patchPath` as authoritative; read only referenced files/context. Declared validation is authoritative for objective verification. Judge only the explicit criterion; never infer a diff from another branch/worktree.
16
17
 
17
- 1. For ordinary delegation, review the supplied plan and explicitly named files directly. Do not prepare Git, require commits, or require a patch packet.
18
- 2. For Flow exact review, only when the task supplies an explicit judgment criterion, require a Review Packet `{base, tip, patchPath}` and the same assigned Unit Worktree context. Declared validation is authoritative for objective verification; judge only that criterion. Read the exact patch as authoritative, then read only the files it references and relevant criterion context. Do not infer a diff from a branch or another worktree.
18
+ Report only actionable correctness risks introduced by the change—not style preferences, speculative hypotheticals, or unrelated pre-existing issues. Use only `read`, `grep`, `find`, and `ls`; run no commands/tests and never edit, write, commit, push, or manage Git/worktrees. Never invoke external LLM APIs, SDKs, agent harnesses, or model CLIs.
19
19
 
20
- In either mode, review only the supplied requirements and explicitly referenced context. Use only `read`, `grep`, `find`, or `ls` for that review. For ordinary delegation, check correctness, regressions, trust-boundary validation, error handling, and missing high-value tests. Do not run commands or tests. Never manage Main, Git, or tests; never edit or write files, commit, push, or otherwise modify state.
21
-
22
- Never invoke external LLM APIs, SDKs, agent harnesses, or model CLIs.
23
-
24
- Emit exactly `PASS` when there are zero findings. Any finding must block approval: return findings first, ordered by severity, with file and line evidence, impact, and the smallest valid fix. Do not emit `PASS` alongside findings.
20
+ Output exactly `PASS` when there are no findings. Otherwise output findings only, ordered by severity, with file:line evidence, impact, and smallest valid fix; any finding blocks approval. Never combine `PASS` with findings. Stop when supplied evidence is covered; in Flow, stop after its criterion.
@@ -1,19 +1,23 @@
1
1
  ---
2
2
  name: scout
3
3
  description: Maps relevant code and evidence for one bounded task without changing files
4
- tools: [read, grep, find, ls]
4
+ tools:
5
+ - read
6
+ - grep
7
+ - find
8
+ - ls
5
9
  extensions: []
6
10
  skills: []
7
11
  ---
8
12
 
9
- Perform read-only discovery for one bounded task.
13
+ Answer only the bounded discovery questions.
10
14
 
11
- Stay within the paths and questions named in the task. Read applicable repository instructions and domain context before tracing the concrete execution or data flow far enough to identify affected files, callers, tests, and constraints. Do not design or implement changes.
15
+ Read applicable repository instructions and domain context first. Trace the relevant execution/data flow, callers, tests, and constraints only far enough to answer. Separate observed facts, supported inferences, and unknowns. Stop when answered; if blocked, state what is missing.
12
16
 
13
- Do not edit files or run shell commands. Return:
17
+ Do not design, recommend, implement, edit, or run shell commands.
14
18
 
15
- - a concise map of relevant files and symbols and how they connect;
16
- - evidence with file paths and line numbers;
17
- - uncertainties or missing context.
19
+ Return concisely:
18
20
 
19
- Stop when the task's questions are answered.
21
+ - a map of relevant files and symbols and how they connect;
22
+ - path:line evidence;
23
+ - uncertainties or missing context.
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: synthesizer
3
3
  description: Reconciles bounded worker reports into one evidence-based result
4
- tools: [read]
4
+ tools:
5
+ - read
5
6
  extensions: []
6
7
  skills: []
7
8
  ---
@@ -1,6 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { createConfigStore, extensionConfigPath } from "@henryqw/pi-config-store";
4
3
 
5
4
  export interface SubagentTimeoutConfig {
6
5
  /** Minutes a child may stay idle before it is asked to stop. */
@@ -16,11 +15,15 @@ export interface SubagentConfig {
16
15
  }
17
16
 
18
17
  export interface LoadedSubagentConfig {
18
+ source: "file" | "missing";
19
19
  config: SubagentConfig;
20
20
  /** Human-readable problems when the file exists but is partly unusable; the file is never rewritten. */
21
21
  error?: string;
22
22
  }
23
23
 
24
+ type ParsedSubagentConfig = Omit<LoadedSubagentConfig, "source">;
25
+
26
+ const EXTENSION_ID = "pi-subagent";
24
27
  const positive = (value: unknown): value is number =>
25
28
  typeof value === "number" && Number.isFinite(value) && value > 0;
26
29
 
@@ -30,36 +33,11 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
30
33
  export const DEFAULT_TIMEOUT_CONFIG = { idleMinutes: 10, maxMinutes: 30 } as const;
31
34
  const TIMEOUT_FIELDS = ["idleMinutes", "maxMinutes"] as const;
32
35
 
33
- /**
34
- * All pi-subagent config lives in its existing extension-named directory;
35
- * agentDir is injectable so tests can point at a temp directory.
36
- */
36
+ /** Return the canonical default JSON path for pi-subagent's config home. */
37
37
  export const configPath = (agentDir = getAgentDir()): string =>
38
- join(agentDir, "config", "pi-subagent", "pi-subagent.json");
38
+ extensionConfigPath(EXTENSION_ID, agentDir);
39
39
 
40
- /**
41
- * Read the optional user config at `<agentDir>/config/pi-subagent/pi-subagent.json`.
42
- * Treated as untrusted user data: malformed files are preserved untouched and
43
- * reported instead of crashing the session; callers fall back to defaults.
44
- */
45
- export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConfig {
46
- const path = configPath(agentDir);
47
- let raw: string;
48
- try {
49
- raw = readFileSync(path, "utf8");
50
- } catch (error) {
51
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { config: {} };
52
- throw error;
53
- }
54
- let parsed: unknown;
55
- try {
56
- parsed = JSON.parse(raw);
57
- } catch (error) {
58
- return {
59
- config: {},
60
- error: `${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults.`,
61
- };
62
- }
40
+ function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfig {
63
41
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
64
42
  return { config: {}, error: `${path} must contain a JSON object; using defaults.` };
65
43
  }
@@ -122,3 +100,42 @@ export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConf
122
100
 
123
101
  return { config, error: problems.length ? `${path}: ${problems.join("; ")}; using defaults.` : undefined };
124
102
  }
103
+
104
+ /**
105
+ * Read the optional user config at `<agentDir>/config/pi-subagent/config.json`.
106
+ * Treated as untrusted user data: malformed files are preserved untouched and
107
+ * reported instead of crashing the session; callers fall back to defaults.
108
+ */
109
+ export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConfig {
110
+ const path = configPath(agentDir);
111
+ let validationError: string | undefined;
112
+ const store = createConfigStore<SubagentConfig>({
113
+ extensionId: EXTENSION_ID,
114
+ agentDir,
115
+ defaults: () => ({}),
116
+ parse(value) {
117
+ const parsed = parseSubagentConfig(value, path);
118
+ validationError = parsed.error;
119
+ return parsed.config;
120
+ },
121
+ });
122
+ try {
123
+ const loaded = store.loadSync();
124
+ return {
125
+ source: loaded.source,
126
+ config: loaded.value,
127
+ ...(validationError === undefined ? {} : { error: validationError }),
128
+ };
129
+ } catch (error) {
130
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
131
+ if (code !== undefined && code !== "ERR_ENCODING_INVALID_ENCODED_DATA") throw error;
132
+ const reason = error instanceof Error ? error.message : String(error);
133
+ return {
134
+ source: "file",
135
+ config: {},
136
+ error: error instanceof SyntaxError
137
+ ? `${path} is not valid JSON (${reason}); using defaults.`
138
+ : `${path} couldn't be read (${reason}); using defaults.`,
139
+ };
140
+ }
141
+ }
@@ -19,8 +19,8 @@ import {
19
19
  } from "@henryqw/pi-subagent";
20
20
  import { Type, type Static } from "typebox";
21
21
  import { Check } from "typebox/value";
22
+ import { TASK_NAME_CONTRACT, TaskNameSchema, normalizeTaskName } from "./task-name.ts";
22
23
  import { runDelegation } from "./delegation.ts";
23
- import { renderToolLines } from "./tool-render.ts";
24
24
 
25
25
  const MAX_UNITS = 8;
26
26
  const GIT_TIMEOUT_MS = 30_000;
@@ -35,6 +35,7 @@ const ModelClassSchema = StringEnum(PROFILE_NAMES, { description: "Task model pr
35
35
 
36
36
  const UnitSchema = Type.Object({
37
37
  id: Type.String({ minLength: 1 }),
38
+ name: TaskNameSchema,
38
39
  task: Type.String({ minLength: 1 }),
39
40
  validation: Type.Array(ValidationSchema, { minItems: 1 }),
40
41
  modelClass: Type.Optional(ModelClassSchema),
@@ -108,7 +109,6 @@ type FlowState = {
108
109
  };
109
110
 
110
111
  type UsageMeter = { usage?: Usage };
111
- type FlowProgress = { line: string };
112
112
 
113
113
  type CommandResult = {
114
114
  stdout: string;
@@ -128,7 +128,7 @@ export interface DelegateFlowRuntime {
128
128
  role: string,
129
129
  model: string,
130
130
  thinkingLevel: string | undefined,
131
- task: string,
131
+ name: string,
132
132
  ctx: ExtensionContext,
133
133
  ) => void;
134
134
  updateWidgetTokens: (id: string, tokens: number) => void;
@@ -157,6 +157,7 @@ export function parseDelegateFlow(value: unknown): FlowRequest {
157
157
  ids.add(id);
158
158
  return {
159
159
  id,
160
+ name: normalizeTaskName(unit.name, `units[${unitIndex}].name`),
160
161
  task: text(unit.task, `units[${unitIndex}].task`),
161
162
  validation: unit.validation.map((validation, validationIndex) => ({
162
163
  command: text(validation.command, `units[${unitIndex}].validation[${validationIndex}].command`),
@@ -181,40 +182,6 @@ function unitCount(count: number): string {
181
182
  return `${count} unit${count === 1 ? "" : "s"}`;
182
183
  }
183
184
 
184
- function flowCallLabel(args: { units?: unknown }): string {
185
- const count = Array.isArray(args.units) ? args.units.length : 0;
186
- return `delegate_flow · parallel→serial · ${unitCount(count)}`;
187
- }
188
-
189
- function isFlowProgress(value: unknown): value is FlowProgress {
190
- return typeof value === "object" && value !== null && !Array.isArray(value)
191
- && typeof (value as { line?: unknown }).line === "string";
192
- }
193
-
194
- function flowResultLines(text: string): string[] {
195
- const lines = text.split(/\r?\n/).filter((line) => line.trim());
196
- const diagnosticHeader = lines.findIndex((line) => line.trim() === "Diagnostic:");
197
- const diagnostic = diagnosticHeader === -1 ? undefined : lines[diagnosticHeader + 1];
198
- const recoveryHeader = lines.findIndex((line) => line.trim() === "Retained Flow state:" || line.trim() === "Attempted allocations preserved without cleanup:");
199
- const recovery = recoveryHeader === -1 || !lines[recoveryHeader + 1]?.trim().startsWith("- unit=")
200
- ? undefined
201
- : lines[recoveryHeader + 1];
202
- if (diagnostic === undefined) {
203
- if (recovery === undefined) return lines;
204
- const recoveryIndex = lines.indexOf(recovery);
205
- return [lines[0]!, recovery, ...lines.filter((_, index) => index !== 0 && index !== recoveryIndex)];
206
- }
207
- const leading = lines.slice(0, Math.min(1, diagnosticHeader));
208
- if (recovery !== undefined) return [...leading, `Diagnostic: ${diagnostic}`, recovery];
209
- // Promote the first diagnostic ahead of the result cap.
210
- return [
211
- ...leading,
212
- `Diagnostic: ${diagnostic}`,
213
- ...lines.slice(leading.length, diagnosticHeader),
214
- ...lines.slice(diagnosticHeader + 2),
215
- ];
216
- }
217
-
218
185
  function errorText(error: unknown): string {
219
186
  return capOutput(error instanceof Error ? error.message : String(error));
220
187
  }
@@ -378,7 +345,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
378
345
  role: Role,
379
346
  modelClass: FlowModelClass,
380
347
  task: string,
381
- widgetTask: string,
348
+ widgetName: string,
382
349
  cwd: string,
383
350
  widgetId: string,
384
351
  signal: AbortSignal | undefined,
@@ -398,7 +365,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
398
365
  if (launch.missingSkills.length) {
399
366
  ctx.ui.notify(`Subagent role ${role.name} skipped unavailable Pi skills: ${launch.missingSkills.join(", ")}.`, "warning");
400
367
  }
401
- runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, widgetTask, ctx);
368
+ runtime.startWidget(widgetId, role.name, launch.model.id, launch.thinkingLevel, widgetName, ctx);
402
369
  started = true;
403
370
  return { launch, task, cwd };
404
371
  },
@@ -719,7 +686,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
719
686
  reviewer,
720
687
  unit.modelClass,
721
688
  reviewerTask(unit.request, reviewCriterion, { base: evidence.base, tip: evidence.tip, patchPath: evidence.patchPath }),
722
- unit.request.task,
689
+ unit.request.name,
723
690
  unit.worktree.cwd,
724
691
  `${toolCallId}:flow:${flow.index}:review`,
725
692
  signal,
@@ -796,18 +763,10 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
796
763
  promptSnippet: "Run a deterministic parallel-implementation, serial-verification Flow",
797
764
  promptGuidelines: [
798
765
  "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.",
799
- "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.",
766
+ `${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.`,
800
767
  "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.",
801
768
  ],
802
769
  parameters: DelegateFlowSchema,
803
- renderCall(args, theme, _context) {
804
- return renderToolLines([theme.fg("toolTitle", flowCallLabel(args))], theme);
805
- },
806
- renderResult(result, { isPartial }, theme, _context) {
807
- if (isPartial) return renderToolLines(isFlowProgress(result.details) ? [theme.fg("muted", result.details.line)] : [], theme);
808
- const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
809
- return renderToolLines(flowResultLines(text), theme);
810
- },
811
770
  prepareArguments: parseDelegateFlow,
812
771
  async execute(toolCallId, params, signal, onUpdate, ctx) {
813
772
  const request = parseDelegateFlow(params);
@@ -819,8 +778,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
819
778
  if (!implementer) throw new Error("delegate_flow requires an implementer Role.");
820
779
  if (needsReviewer && !reviewer) throw new Error("delegate_flow requires a reviewer Role when a unit declares review.");
821
780
  const emitProgress = (line: string) => {
822
- const progress: FlowProgress = { line };
823
- onUpdate?.({ content: [{ type: "text", text: progress.line }], details: progress });
781
+ onUpdate?.({ content: [{ type: "text", text: line }], details: { line } });
824
782
  };
825
783
  const flow: FlowState = {
826
784
  phase: "running",
@@ -879,7 +837,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
879
837
  flow.implementer,
880
838
  unit.modelClass,
881
839
  implementerTask(unit.request),
882
- unit.request.task,
840
+ unit.request.name,
883
841
  unit.worktree.cwd,
884
842
  `${toolCallId}:flow:${index}:implement`,
885
843
  operationSignal,
@@ -915,14 +873,6 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
915
873
  promptSnippet: "Repair and continue the blocked deterministic Flow",
916
874
  promptGuidelines: ["Call delegate_flow_continue only after delegate_flow reports a repairable block, with explicit guidance addressing that block."],
917
875
  parameters: DelegateFlowContinueSchema,
918
- renderCall(_args, theme, _context) {
919
- return renderToolLines([theme.fg("toolTitle", "delegate_flow_continue · repair continuation")], theme);
920
- },
921
- renderResult(result, { isPartial }, theme, _context) {
922
- if (isPartial) return renderToolLines(isFlowProgress(result.details) ? [theme.fg("muted", result.details.line)] : [], theme);
923
- const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
924
- return renderToolLines(flowResultLines(text), theme);
925
- },
926
876
  prepareArguments: parseDelegateFlowContinue,
927
877
  async execute(toolCallId, params, signal, onUpdate, ctx) {
928
878
  const { guidance, modelClass } = parseDelegateFlowContinue(params);
@@ -940,8 +890,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
940
890
  const operationSignal = bindSignal(flow, signal);
941
891
  const meter: UsageMeter = {};
942
892
  const emitProgress = (line: string) => {
943
- const progress: FlowProgress = { line };
944
- onUpdate?.({ content: [{ type: "text", text: progress.line }], details: progress });
893
+ onUpdate?.({ content: [{ type: "text", text: line }], details: { line } });
945
894
  };
946
895
  try {
947
896
  emitProgress(`repair · unit ${flow.index + 1}/${flow.units.length}`);
@@ -950,7 +899,7 @@ export function registerDelegateFlow(pi: ExtensionAPI, runtime: DelegateFlowRunt
950
899
  flow.implementer,
951
900
  unit.modelClass,
952
901
  repairTask(unit.request, blocked, guidance),
953
- unit.request.task,
902
+ unit.request.name,
954
903
  unit.worktree.cwd,
955
904
  `${toolCallId}:flow:${flow.index}:repair`,
956
905
  operationSignal,
@@ -4,6 +4,7 @@ import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-
4
4
  import { type Component, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
5
5
  import {
6
6
  availableTaskModels,
7
+ loadTaskModelsConfig,
7
8
  type ThinkingLevel,
8
9
  modelReference,
9
10
  registerModelTask,
@@ -34,7 +35,6 @@ import {
34
35
  } from "@henryqw/pi-subagent";
35
36
  import { DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, type SubagentTimeoutConfig } from "./config.ts";
36
37
  import { registerDelegateFlow } from "./delegate-flow.ts";
37
- import { renderToolLines } from "./tool-render.ts";
38
38
  import { runDelegation } from "./delegation.ts";
39
39
  import {
40
40
  formatBackgroundWorkflowResult,
@@ -42,13 +42,10 @@ import {
42
42
  formatWorkflowUpdate,
43
43
  WorkflowAbortedError,
44
44
  WorkflowFailureError,
45
- displaySummary,
46
45
  type WorkflowTransportEntry,
47
- type WorkflowTransportDetails,
48
46
  } from "./result-transport.ts";
49
47
  import {
50
48
  identifyWorkflowEntries,
51
- MAX_WORKFLOW_ENTRIES,
52
49
  parseWorkflow,
53
50
  runForegroundWorkflow,
54
51
  WorkflowSchema,
@@ -56,6 +53,7 @@ import {
56
53
  type ParsedWorkflow,
57
54
  type WorkflowEntry,
58
55
  } from "./workflow.ts";
56
+ import { TASK_NAME_CONTRACT } from "./task-name.ts";
59
57
 
60
58
  const WIDGET_KEY = "subagent-status";
61
59
  const WIDGET_INTERVAL_MS = 80;
@@ -88,7 +86,7 @@ type WidgetItem = {
88
86
  role: string;
89
87
  model: string;
90
88
  thinkingLevel: string;
91
- task: string;
89
+ name: string;
92
90
  tokens: number;
93
91
  startedAt: number;
94
92
  status: WidgetStatus;
@@ -100,8 +98,9 @@ type WidgetItem = {
100
98
  activityOrder: number;
101
99
  };
102
100
 
103
- function taskSummary(task: string): string {
104
- return Array.from(task.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).slice(0, 4).join(" ")).slice(0, 160).join("");
101
+ function roleBadge(role: string): string {
102
+ const initial = Array.from(role)[0]!.toUpperCase();
103
+ return `[${Array.from(initial)[0]!}]`;
105
104
  }
106
105
 
107
106
  function formatTokens(tokens: number): string {
@@ -150,88 +149,12 @@ function activityMetrics(item: WidgetItem, now: number): string {
150
149
  ...(item.startedToolCount === 0
151
150
  ? []
152
151
  : [`${item.startedToolCount} tool${item.startedToolCount === 1 ? "" : "s"}`]),
153
- item.model,
154
- item.thinkingLevel,
152
+ `${item.model}·${item.thinkingLevel}`,
155
153
  `${formatTokens(item.tokens)} tok`,
156
154
  formatDuration((item.finishedAt ?? now) - item.startedAt),
157
155
  ].join(" · ");
158
156
  }
159
157
 
160
- function isWorkflowTransportDetails(value: unknown): value is WorkflowTransportDetails {
161
- const isRecord = (candidate: unknown): candidate is Record<string, unknown> => typeof candidate === "object" && candidate !== null && !Array.isArray(candidate);
162
- const isOptionalString = (candidate: unknown) => candidate === undefined || typeof candidate === "string";
163
- if (!isRecord(value) || !(value.mode === "single" || value.mode === "parallel" || value.mode === "chain") || !Array.isArray(value.entries)) return false;
164
- const entries = value.entries;
165
- return entries.length >= 1 && entries.length <= MAX_WORKFLOW_ENTRIES
166
- && (value.mode !== "single" || entries.length === 1)
167
- && entries.every((entry) => isRecord(entry)
168
- && typeof entry.id === "string" && typeof entry.index === "number" && Number.isFinite(entry.index) && Number.isInteger(entry.index) && entry.index >= 0
169
- && typeof entry.role === "string" && ["pending", "running", "succeeded", "failed", "rejected", "skipped"].includes(entry.status as string)
170
- && (["running", "succeeded", "failed", "rejected"].includes(entry.status as string)
171
- ? typeof entry.summary === "string" && entry.summary === displaySummary(entry.summary)
172
- : entry.summary === undefined)
173
- && isOptionalString(entry.model) && isOptionalString(entry.thinkingLevel)
174
- && (entry.worktree === undefined || isRecord(entry.worktree)
175
- && typeof entry.worktree.path === "string" && typeof entry.worktree.branch === "string"
176
- && typeof entry.worktree.commits === "number" && Number.isFinite(entry.worktree.commits) && Number.isInteger(entry.worktree.commits) && entry.worktree.commits >= 0
177
- && typeof entry.worktree.dirty === "boolean" && typeof entry.worktree.pruned === "boolean"
178
- && (entry.worktree.inspection_failed === undefined || typeof entry.worktree.inspection_failed === "boolean")
179
- && (entry.worktree.note === undefined || typeof entry.worktree.note === "string")))
180
- && entries.every((entry, index) => index === 0 || (entry as { index: number }).index > (entries[index - 1] as { index: number }).index);
181
- }
182
-
183
- function workflowCallLabel(args: { tasks?: unknown; chain?: unknown }): string {
184
- if (Array.isArray(args.chain)) return `delegate_task · chain · ${args.chain.length} task${args.chain.length === 1 ? "" : "s"}`;
185
- if (Array.isArray(args.tasks)) return `delegate_task · parallel · ${args.tasks.length} task${args.tasks.length === 1 ? "" : "s"}`;
186
- return "delegate_task · single · 1 task";
187
- }
188
-
189
- function workflowProgressLine(details: WorkflowTransportDetails): string {
190
- let running = 0;
191
- let pending = 0;
192
- let complete = 0;
193
- let failed = 0;
194
- let skipped = 0;
195
- for (const { status } of details.entries) {
196
- switch (status) {
197
- case "running": running += 1; break;
198
- case "pending": pending += 1; break;
199
- case "succeeded": complete += 1; break;
200
- case "failed":
201
- case "rejected": failed += 1; break;
202
- case "skipped": skipped += 1; break;
203
- }
204
- }
205
- return [[running, "running"], [pending, "pending"], [complete, "complete"], [failed, "failed"], [skipped, "skipped"]]
206
- .flatMap(([count, label]) => count ? [`${count} ${label}`] : [])
207
- .join(" · ");
208
- }
209
-
210
- function workflowResultLines(details: WorkflowTransportDetails, theme: Theme): string[] {
211
- if (details.entries.some(({ status }) => status === "pending" || status === "running")) return [];
212
- const entries = details.entries.filter(({ status }) => status !== "skipped");
213
- const withRecovery = (entry: typeof entries[0]) => entry.worktree && !entry.worktree.pruned;
214
- const isTerminalFailure = (entry: typeof entries[0]) => entry.status === "failed" || entry.status === "rejected";
215
- const sorted = [...entries].sort((a, b) => {
216
- const aFailure = isTerminalFailure(a);
217
- const bFailure = isTerminalFailure(b);
218
- if (aFailure !== bFailure) return aFailure ? -1 : 1;
219
- const aRecovery = !aFailure && withRecovery(a);
220
- const bRecovery = !bFailure && withRecovery(b);
221
- if (aRecovery !== bRecovery) return aRecovery ? -1 : 1;
222
- return 0;
223
- });
224
- return sorted.map((entry) => {
225
- const summary = entry.summary || "(no output)";
226
- const text = details.mode === "single" ? summary : `${entry.role}: ${summary}`;
227
- const style = entry.status === "failed" || entry.status === "rejected" ? "error" : "text";
228
- const recovery = withRecovery(entry) ? `Recovery: ${entry.worktree!.path}` : undefined;
229
- return recovery === undefined
230
- ? theme.fg(style, text)
231
- : `${theme.fg("warning", recovery)} · ${theme.fg(style, text)}`;
232
- });
233
- }
234
-
235
158
  function renderWidgetRows(
236
159
  items: WidgetItem[],
237
160
  width: number,
@@ -244,7 +167,7 @@ function renderWidgetRows(
244
167
  if (!visible.length) return [];
245
168
  const hidden = ordered.slice(visible.length);
246
169
  const lines = visible.map((item) => truncateToWidth(
247
- `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} · ${statusLabel(item.status)} · ${theme.fg("text", item.task)} · ${theme.fg("text", activityLabel(item, now))} · ${theme.fg("muted", activityMetrics(item, now))}`,
170
+ `${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))}`,
248
171
  width,
249
172
  ));
250
173
  if (hidden.length) {
@@ -309,11 +232,10 @@ export default function subagentExtension(
309
232
  registerModelTask(pi, DELEGATE_TASK);
310
233
  const widgetItems = new Map<string, WidgetItem>();
311
234
  // Each child is a full Pi process issuing its own model calls; cap parallel
312
- // spend. Precedence: PI_SUBAGENT_MAX_SUBAGENTS env > config/pi-subagent/pi-subagent.json
313
- // maxSubagents > default 5. Invalid config falls back to the default and is
314
- // reported once the UI exists; an invalid env value fails fast.
235
+ // spend. Precedence: PI_SUBAGENT_MAX_SUBAGENTS env > config/pi-subagent/config.json
236
+ // maxSubagents > default 5. Invalid present config falls back to the default
237
+ // and is reported at session start; an invalid env value fails fast.
315
238
  const loadedConfig = readSubagentConfig();
316
- const startupWarnings = [loadedConfig.error].filter((message): message is string => message !== undefined);
317
239
  let maxActiveSubagents = loadedConfig.config.maxSubagents ?? 5;
318
240
  const maxSubagentsRaw = process.env.PI_SUBAGENT_MAX_SUBAGENTS;
319
241
  if (maxSubagentsRaw !== undefined) {
@@ -386,7 +308,7 @@ export default function subagentExtension(
386
308
  role: string,
387
309
  model: string,
388
310
  thinkingLevel: string | undefined,
389
- task: string,
311
+ name: string,
390
312
  ctx: ExtensionContext,
391
313
  ) => {
392
314
  if (!ctx.hasUI) return;
@@ -399,10 +321,10 @@ export default function subagentExtension(
399
321
  }
400
322
  }
401
323
  widgetItems.set(id, {
402
- role,
324
+ role: roleBadge(role),
403
325
  model,
404
326
  thinkingLevel: thinkingLevel ?? "default",
405
- task: taskSummary(task),
327
+ name,
406
328
  tokens: 0,
407
329
  startedAt: Date.now(),
408
330
  status: "working",
@@ -480,7 +402,14 @@ export default function subagentExtension(
480
402
  invalidateDelegateFlow();
481
403
  latestCtx = ctx;
482
404
  ensureWidget(ctx);
483
- for (const warning of startupWarnings.splice(0)) ctx.ui.notify(warning, "warning");
405
+ if (loadedConfig.error !== undefined) ctx.ui.notify(loadedConfig.error, "warning");
406
+ try {
407
+ if (loadTaskModelsConfig().source === "missing") {
408
+ ctx.ui.notify("Task model config is missing; run /task-models to configure it.", "warning");
409
+ }
410
+ } catch {
411
+ // Route resolution retains the existing malformed shared-config error.
412
+ }
484
413
  });
485
414
  pi.on("session_shutdown", async (_event, ctx) => {
486
415
  failedToolPatches.clear();
@@ -607,28 +536,13 @@ export default function subagentExtension(
607
536
  description: `Delegate one selected single, parallel, or chain workflow of bounded tasks to isolated Pi Subagents. Roles: ${roleSummary()}.`,
608
537
  promptSnippet: "Delegate one bounded single, parallel, or chain workflow to isolated roles",
609
538
  promptGuidelines: [
610
- "Call delegate_task with exactly one mode: role+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.",
611
- "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.",
539
+ "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.",
540
+ `${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.`,
612
541
  "For each delegate_task entry, populate model and thinking only for an explicit user override; otherwise choose only modelClass: fast normally, or balanced upfront for obviously complex work. This is Main policy, not runtime enforcement.",
613
542
  "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.",
614
543
  "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.",
615
544
  ],
616
545
  parameters: WorkflowSchema,
617
- renderCall(args, theme, _context) {
618
- return renderToolLines([theme.fg("toolTitle", workflowCallLabel(args))], theme);
619
- },
620
- renderResult(result, { isPartial }, theme, _context) {
621
- const details = result.details;
622
- if (isPartial) return renderToolLines(isWorkflowTransportDetails(details)
623
- ? [theme.fg("muted", workflowProgressLine(details))]
624
- : [], theme);
625
- if (isWorkflowTransportDetails(details)) return renderToolLines(workflowResultLines(details, theme), theme);
626
- if (typeof details === "object" && details !== null && (details as { background?: unknown }).background === true) {
627
- return renderToolLines([theme.fg("muted", "Background workflow accepted.")], theme);
628
- }
629
- const text = result.content.find((part) => part.type === "text")?.text ?? "(no output)";
630
- return renderToolLines([theme.fg("muted", text)], theme);
631
- },
632
546
  prepareArguments(args) {
633
547
  try {
634
548
  const workflow = parseWorkflow(args);
@@ -749,7 +663,7 @@ export default function subagentExtension(
749
663
  if (role.isolation === "worktree") {
750
664
  worktree = await createChildWorktree(ctx.cwd, entry.id, undefined, workflowSignal);
751
665
  }
752
- startWidgetItem(entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.task, ctx);
666
+ startWidgetItem(entry.id, role.name, launch.model.id, launch.thinkingLevel, entry.delegation.name, ctx);
753
667
  setState("running", "");
754
668
  emitUpdate(emitToolUpdates);
755
669
  return {
@@ -0,0 +1,33 @@
1
+ import { Type, type Static } from "typebox";
2
+
3
+ const TASK_NAME_MAX_LENGTH = 29 as const;
4
+ const TASK_NAME_CONTROL_RANGES = "\\u0000-\\u001F\\u007F-\\u009F" as const;
5
+ const TASK_NAME_LIMIT_WORDING = `about five words and fewer than ${TASK_NAME_MAX_LENGTH + 1} characters`;
6
+ const TASK_NAME_WORDING = `short descriptive name of ${TASK_NAME_LIMIT_WORDING}`;
7
+
8
+ export const TASK_NAME_CONTRACT = {
9
+ minLength: 1,
10
+ maxLength: TASK_NAME_MAX_LENGTH,
11
+ description: `Short descriptive task name, ${TASK_NAME_LIMIT_WORDING}; C0/C1 control characters are rejected.`,
12
+ promptGuidance: `Use a ${TASK_NAME_WORDING} without C0/C1 control characters.`,
13
+ controlRanges: TASK_NAME_CONTROL_RANGES,
14
+ pattern: `^(?![\\s\\S]*[${TASK_NAME_CONTROL_RANGES}])[\\s\\S]+$`,
15
+ } as const;
16
+
17
+ export const TaskNameSchema = Type.String({
18
+ minLength: TASK_NAME_CONTRACT.minLength,
19
+ maxLength: TASK_NAME_CONTRACT.maxLength,
20
+ description: TASK_NAME_CONTRACT.description,
21
+ pattern: TASK_NAME_CONTRACT.pattern,
22
+ });
23
+
24
+ export type TaskName = Static<typeof TaskNameSchema>;
25
+
26
+ const TASK_NAME_CONTROL_PATTERN = new RegExp(`[${TASK_NAME_CONTRACT.controlRanges}]`, "u");
27
+
28
+ export function normalizeTaskName(value: TaskName, path: string): TaskName {
29
+ if (TASK_NAME_CONTROL_PATTERN.test(value)) throw new Error(`${path} must not contain C0/C1 control characters.`);
30
+ const normalized = value.trim();
31
+ if (!normalized) throw new Error(`${path} must be non-empty text.`);
32
+ return normalized;
33
+ }
@@ -2,6 +2,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { PROFILE_NAMES, THINKING_LEVELS } from "@henryqw/pi-task-models";
3
3
  import { Type, type Static } from "typebox";
4
4
  import { Check } from "typebox/value";
5
+ import { TaskNameSchema, normalizeTaskName } from "./task-name.ts";
5
6
 
6
7
  export const MAX_WORKFLOW_ENTRIES = 8;
7
8
 
@@ -13,6 +14,7 @@ const ThinkingSchema = StringEnum(THINKING_LEVELS, { description: "Task thinking
13
14
 
14
15
  export const DelegationSchema = Type.Object({
15
16
  role: RoleSchema,
17
+ name: TaskNameSchema,
16
18
  task: TaskSchema,
17
19
  model: Type.Optional(ModelSchema),
18
20
  modelClass: Type.Optional(ModelClassSchema),
@@ -21,6 +23,7 @@ export const DelegationSchema = Type.Object({
21
23
 
22
24
  export const WorkflowSchema = Type.Object({
23
25
  role: Type.Optional(RoleSchema),
26
+ name: Type.Optional(TaskNameSchema),
24
27
  task: Type.Optional(TaskSchema),
25
28
  model: Type.Optional(ModelSchema),
26
29
  modelClass: Type.Optional(ModelClassSchema),
@@ -38,7 +41,7 @@ export const WorkflowSchema = Type.Object({
38
41
  background: Type.Optional(Type.Boolean({ description: "Run the selected workflow without blocking" })),
39
42
  }, {
40
43
  additionalProperties: false,
41
- description: "Exactly one mode: role and task, tasks, or chain",
44
+ description: "Exactly one mode: role, name, and task; tasks; or chain",
42
45
  });
43
46
 
44
47
  export type Delegation = Static<typeof DelegationSchema>;
@@ -50,7 +53,7 @@ export type ParsedWorkflow =
50
53
 
51
54
  type WorkflowInput = Static<typeof WorkflowSchema>;
52
55
 
53
- const DELEGATION_KEYS = ["role", "task", "model", "modelClass", "thinking"] as const;
56
+ const DELEGATION_KEYS = ["role", "name", "task", "model", "modelClass", "thinking"] as const;
54
57
 
55
58
  function text(value: string, path: string): string {
56
59
  const normalized = value.trim();
@@ -61,6 +64,7 @@ function text(value: string, path: string): string {
61
64
  function normalizeDelegation(value: Delegation, path: string): Delegation {
62
65
  return {
63
66
  role: text(value.role, `${path}.role`),
67
+ name: normalizeTaskName(value.name, `${path}.name`),
64
68
  task: text(value.task, `${path}.task`),
65
69
  ...(value.model === undefined ? {} : { model: text(value.model, `${path}.model`) }),
66
70
  ...(value.modelClass === undefined ? {} : { modelClass: value.modelClass }),
@@ -69,7 +73,7 @@ function normalizeDelegation(value: Delegation, path: string): Delegation {
69
73
  }
70
74
 
71
75
  function hasDelegation(value: WorkflowInput): value is WorkflowInput & Delegation {
72
- return Object.hasOwn(value, "role") && Object.hasOwn(value, "task");
76
+ return Object.hasOwn(value, "role") && Object.hasOwn(value, "name") && Object.hasOwn(value, "task");
73
77
  }
74
78
 
75
79
  function workflowMode(value: unknown): WorkflowMode | undefined {
@@ -78,7 +82,7 @@ function workflowMode(value: unknown): WorkflowMode | undefined {
78
82
  const parallel = Object.hasOwn(value, "tasks");
79
83
  const chain = Object.hasOwn(value, "chain");
80
84
  if (Number(single) + Number(parallel) + Number(chain) !== 1) {
81
- throw new Error("workflow must select exactly one mode: role and task, tasks, or chain.");
85
+ throw new Error("workflow must select exactly one mode: role, name, and task; tasks; or chain.");
82
86
  }
83
87
  return single ? "single" : parallel ? "parallel" : "chain";
84
88
  }
@@ -86,11 +90,11 @@ function workflowMode(value: unknown): WorkflowMode | undefined {
86
90
  export function parseWorkflow(value: unknown): ParsedWorkflow {
87
91
  const mode = workflowMode(value);
88
92
  if (!Check(WorkflowSchema, value)) throw new Error("workflow must match the declared tool schema.");
89
- if (!mode) throw new Error("workflow must select exactly one mode: role and task, tasks, or chain.");
93
+ if (!mode) throw new Error("workflow must select exactly one mode: role, name, and task; tasks; or chain.");
90
94
  const input = value;
91
95
  const background = input.background ?? false;
92
96
  if (mode === "single") {
93
- if (!hasDelegation(input)) throw new Error("workflow requires both role and task.");
97
+ if (!hasDelegation(input)) throw new Error("workflow requires role, name, and task.");
94
98
  return { mode, background, delegations: [normalizeDelegation(input, "workflow")] };
95
99
  }
96
100
  if (mode === "parallel") return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "9.0.0",
3
+ "version": "10.1.0",
4
4
  "description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -64,7 +64,8 @@
64
64
  ]
65
65
  },
66
66
  "dependencies": {
67
+ "@henryqw/pi-config-store": "^0.1.0",
67
68
  "@henryqw/pi-multi-codex": "^0.3.8",
68
- "@henryqw/pi-task-models": "^3.0.0"
69
+ "@henryqw/pi-task-models": "^4.0.0"
69
70
  }
70
71
  }
@@ -1,16 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
3
-
4
- export const MAX_RENDERED_RESULT_LINES = 3;
5
-
6
- export function renderToolLines(lines: readonly string[], theme: Theme): Component {
7
- return {
8
- invalidate() {},
9
- render: (width) => {
10
- const shown = lines.length > MAX_RENDERED_RESULT_LINES
11
- ? [...lines.slice(0, MAX_RENDERED_RESULT_LINES - 1), theme.fg("muted", `… ${lines.length - MAX_RENDERED_RESULT_LINES + 1} more`)]
12
- : lines;
13
- return shown.map((line) => truncateToWidth(line.replace(/[\r\n]+/g, " "), width));
14
- },
15
- };
16
- }