@henryqw/pi-subagent 27.1.2 → 27.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTEXT.md CHANGED
@@ -36,6 +36,7 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
36
36
  ### Global limits
37
37
 
38
38
  - `config/pi-subagent/config.json` is the only user execution-policy source. It owns `maxSubagents`, `maxTurns`, optional `maxTokens`, `maxCorrections`, and child idle/hard timeout.
39
+ - The config loads through `@henryqw/pi-config-store` fields. An unknown key or an invalid bounded limit, timeout, or `roleDefaults` key warns at launch and uses its finite default. An invalid `maxTokens`, or a file that cannot be read or parsed, disables delegation with an error, because the token default is unlimited. Role Markdown files keep per-file exclusion; durable state stays strict.
39
40
  - Request fields cannot override or replenish policy. Durable requests store the policy snapshot and cumulative correction count; current config may tighten the correction allowance.
40
41
  - Productive execution has no whole-run wall-clock deadline. Long productive work and later resume remain valid. Direct Herdr observation also has no whole-task elapsed deadline; worker idleness is bounded.
41
42
  - Child idle/hard runtime, turn/token handling, outer abort, process I/O, status inspection, termination, and cleanup remain independently bounded. Direct Herdr workers use idle limits, not ephemeral child hard runtime.
package/README.md CHANGED
@@ -45,7 +45,7 @@ Commands are for you. Delegation and state tools and the packaged skill are for
45
45
  | `subagent_abort` | tool | Abort an isolated request only when no retained candidates or integration worktrees remain; cannot discard them. |
46
46
  | `git_read` | tool | For Role-declared children (not Main): inspect bounded local Git evidence without Bash, tests, network access, or writes. |
47
47
  | `pi-subagent` | skill | Guide Main through delegation, authorization, checks, integration, and recovery. |
48
- | Direct status widget (`D`) | ui | For you in the TUI: show direct worker references (such as `D7.1`), status, Role badges, names, route, task elapsed time, measured session tokens, pending tools, and the latest tool's recorded time. |
48
+ | Direct status widget (`D`) | ui | For you in the TUI: show direct worker references (such as `D7.1`), status, Role badges, names, route, task elapsed time, measured task tokens, pending tools, and the latest tool's recorded time. |
49
49
  | Isolated status widget (`I`) | ui | For you in the TUI: show recorded task state, Role/model-class badges, and retained workspaces; readiness does not mean promotion. |
50
50
 
51
51
  ![Main, its coordinator extension, and a Herdr subagent through checked work, worker failure, and coordinator interruption](./docs/worker-lifecycle.svg)
@@ -56,7 +56,9 @@ The coordinator is extension code that runs in Main's Pi process. It is not anot
56
56
 
57
57
  The TUI has two separate compact status widgets. Each widget has one row for each visible worker or task. `D` marks direct work, and `I` marks isolated work. Badges such as `[S]` and `[I3]` identify the Role initial. Isolated badges also include the model class: `1` fast, `2` balanced, `3` frontier, `*` fav.
58
58
 
59
- Each direct row starts with a saved direct reference such as `D7.1`. `D7` is the workflow number in this Pi session, and `.1` is the position of the worker in the original request. `/subagent` uses the same reference and name. The Herdr tab label shows them at launch. Numbers continue after reload, and the session never uses a number again. A session switch can move a workflow into a session that already uses its number. In that case, the workflow gets a new number there. An existing Herdr tab keeps its original launch label. The direct widget shows the launched model, thinking level, elapsed time, and measured session tokens (`— tok` until usage is available). Failed rows show before working and completed rows. When rows overflow, `… N more` includes counts by status. The isolated widget shows the recorded task state and any retained workspace. It does not show reliable live model, thinking, or token readings from Herdr. Attention shows before ordinary work, and overflow uses `+N more · /subagent`. An aborted request with visible tasks has its own `request aborted` row. This row does not change the recorded statuses of the tasks. A committed worker without a checked candidate can show attention, not readiness. Status glyphs use the active TUI theme. The text also names the status without color.
59
+ Each direct row starts with a saved direct reference such as `D7.1`. `D7` is the workflow number in this Pi session, and `.1` is the position of the worker in the original request. `/subagent` uses the same reference and name. The Herdr tab label shows them at launch. Numbers continue after reload, and the session never uses a number again. A session switch can move a workflow into a session that already uses its number. In that case, the workflow gets a new number there. An existing Herdr tab keeps its original launch label. The direct widget shows the launched model, thinking level, elapsed time, and measured task tokens (`— tok` until usage is available). Failed rows show before working and completed rows. When rows overflow, `… N more` includes counts by status. The isolated widget shows the recorded task state and any retained workspace. It does not show reliable live model, thinking, or token readings from Herdr. Attention shows before ordinary work, and overflow uses `+N more · /subagent`. An aborted request with visible tasks has its own `request aborted` row. This row does not change the recorded statuses of the tasks. A committed worker without a checked candidate can show attention, not readiness. Status glyphs use the active TUI theme. The text also names the status without color.
60
+
61
+ For the direct widget and result summaries, measured tokens are the cumulative input and output tokens from completed assistant usage records for the current task turn. Cache-read and cache-write tokens are excluded. A continued worker counts only its new task turn. Missing usage stays unknown. This display count does not change token-limit accounting.
60
62
 
61
63
  A ready isolated candidate is **not promoted** to Main. To reload the menu and the isolated widget from saved state, choose **Refresh** in `/subagent`. Refresh does not recover or change a request. Use `/subagent` or `subagent_status` for exact identities, failures, and recovery.
62
64
 
@@ -111,7 +113,7 @@ Tab creation can time out or return an incomplete or mismatched identity. In tha
111
113
 
112
114
  Tasks in a write request must return one JSON completion object, such as `{"outcome":"succeeded","answer":"Scoped commit completed."}`. `outcome` must be `succeeded`, `failed`, or `blocked`, and `answer` must be non-empty. A normal final Pi turn is not sufficient. Failure, blocked work, plain text, or a malformed completion stops later dispatch. But admission depends on proved termination, not on the reported outcome. Success is the explicit report of the worker. It is not an independent validation of its changes.
113
115
 
114
- The tool returns a task ID and the first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues the result after the current turn. If Main is idle, the result starts a turn. The extension does not report a blocked, stalled, unknown, or truncated result as success. In the result, the line of each launched worker shows its model, thinking level, measured session tokens, and elapsed time, such as `✓ [1/2] Check · worker · provider/model · thinking high · 1.5k tok · 42s`. If Pi recorded no usage, the line shows `tokens unknown`. Failed and stopped workers show the usage that the extension measured until they ended. A worker that the extension skipped before launch shows no usage. The result details contain the same values in each entry as `model`, `thinkingLevel`, `tokens`, and `elapsedMs`. When the token count is unknown, `tokens` is absent. If a task fails after its worker produced text, the result keeps a bounded excerpt marked as partial output. This excerpt is never success. Every worker result delivered to Main starts with a header. The header states that the text is the output of the worker and carries no user authority. Text that imitates harness framing gets an inserted backslash. Examples are `<system-reminder>`-style tags and lines that start with `Human:` or `Assistant:`. Nothing else changes. Isolated status and follow-up answer previews use the same header. A session switch or shutdown stops observation and preserves tab identities for recovery. To inspect exact tabs, agents, and Pi session files, open `/subagent` on the session branch. This includes tabs launched after the first. The menu has one row for each workflow. A single worker shows its reference, Role, and name (`D7.1 · worker · Search audit`). A multi-worker workflow shows its reference and worker count (`D8 · 3 workers`). When you select it, the menu lists each recorded worker by reference, Role, and name with its exact IDs. References and names help you match rows. The extension never uses them to decide ownership. Older records without saved references show their saved allocation label or exact task ID. A `recorded only` row does not prove that its workers run or finished. The menu labels locally observed work separately.
116
+ The tool returns a task ID and the first Herdr tab after launch, not the answer. The extension observes each worker and sends one result to Main when the workflow finishes. If Main is busy, Pi queues the result after the current turn. If Main is idle, the result starts a turn. The extension does not report a blocked, stalled, unknown, or truncated result as success. In the result, the line of each launched worker shows its model, thinking level, measured task tokens, and elapsed time, such as `✓ [1/2] Check · worker · provider/model · thinking high · 1.5k tok · 42s`. If Pi recorded no usage, the line shows `tokens unknown`. Failed and stopped workers show the usage that the extension measured until they ended. A worker that the extension skipped before launch shows no usage. The result details contain the same values in each entry as `model`, `thinkingLevel`, `tokens`, and `elapsedMs`. When the token count is unknown, `tokens` is absent. If a task fails after its worker produced text, the result keeps a bounded excerpt marked as partial output. This excerpt is never success. Every worker result delivered to Main starts with a header. The header states that the text is the output of the worker and carries no user authority. Text that imitates harness framing gets an inserted backslash. Examples are `<system-reminder>`-style tags and lines that start with `Human:` or `Assistant:`. Nothing else changes. Isolated status and follow-up answer previews use the same header. A session switch or shutdown stops observation and preserves tab identities for recovery. To inspect exact tabs, agents, and Pi session files, open `/subagent` on the session branch. This includes tabs launched after the first. The menu has one row for each workflow. A single worker shows its reference, Role, and name (`D7.1 · worker · Search audit`). A multi-worker workflow shows its reference and worker count (`D8 · 3 workers`). When you select it, the menu lists each recorded worker by reference, Role, and name with its exact IDs. References and names help you match rows. The extension never uses them to decide ownership. Older records without saved references show their saved allocation label or exact task ID. A `recorded only` row does not prove that its workers run or finished. The menu labels locally observed work separately.
115
117
 
116
118
  ### Continue or stop one direct worker
117
119
 
@@ -220,11 +222,19 @@ pi-subagent owns `~/.pi/agent/config/pi-subagent/config.json`. If the file is mi
220
222
  | `maxCorrections` | Same-worker automatic corrections per isolated request | Safe integer ≥ 0 | `1` |
221
223
  | `timeout.idleMinutes` | Direct worker and ephemeral child idle timeout | Positive and within Node's timer range | `10` |
222
224
  | `timeout.maxMinutes` | Ephemeral child hard runtime | Greater than idle and within Node's timer range | `30` |
223
- | `roleDefaults` | Optional `extensions`, `tools`, and `skills` arrays merged into every built-in and user Role, with the same validation as Role fields | Object; unknown keys rejected | None |
225
+ | `roleDefaults` | Optional `extensions`, `tools`, and `skills` arrays merged into every built-in and user Role, with the same validation as Role fields | Object with `extensions`, `tools`, or `skills` | None |
224
226
 
225
227
  In each merged list, `roleDefaults` entries come first, and the entries of the Role follow. The merge removes duplicates and occurs before resource resolution. Use `roleDefaults` for the provider extension that every child needs. Limits come only from this global file. Request fields cannot override or replenish them. Existing durable requests keep their recorded policy, but a lower current correction limit can tighten recovery. Intentionally, there is no whole-run timeout setting.
226
228
 
227
- Malformed or unreadable JSON, unknown keys, and invalid values block delegation with one actionable warning. The extension preserves the file and never rewrites it automatically.
229
+ A bad config never stops the extension. At launch, and again each time it reads the file (a `delegate_task` call, each later worker of a direct chain, each isolated Role launch, `/subagent roles`, or a status inspection), the extension shows each new problem one time:
230
+
231
+ - An unknown key shows a warning. The extension ignores the key.
232
+ - A misspelled key that is near a missing key, such as `maxToken` for `maxTokens`, counts as an invalid value of that key. The warning gives the likely key.
233
+ - An invalid value in `maxSubagents`, `maxTurns`, `maxCorrections`, or `timeout` shows a warning. The extension uses the finite default for that key. If `timeout.maxMinutes` is not greater than `timeout.idleMinutes`, the extension uses the default `timeout`.
234
+ - An unknown or invalid key in `roleDefaults` shows a warning. The extension ignores only that key, so Roles get fewer default resources.
235
+ - An invalid or misspelled `maxTokens`, malformed JSON, invalid UTF-8, a file larger than 64 KiB, or an unreadable file shows an error and blocks delegation. The default token limit is unlimited, so the extension does not use a default in these cases. `/subagent` stays available. Correct the file to enable delegation again. The block also applies to work that already runs: the next worker of a direct workflow, or the next Role launch of an isolated request, fails before it starts. A workflow that runs keeps the limits that it started with. If the file had the problem when the session started, also run `/reload`, because the session took its execution limits from the defaults.
236
+
237
+ No key is required. The extension preserves the file and never rewrites it automatically.
228
238
 
229
239
  ### Roles
230
240
 
@@ -274,6 +284,7 @@ The servers named in `mcps` come only from the global `~/.pi/agent/mcp.json`. In
274
284
  | Surface | Type | Purpose |
275
285
  | --- | --- | --- |
276
286
  | `loadRoleCatalog`, `findRole`, `parseRoleName`, `parseRoleDefaults` | functions | Load built-in and user Roles with `roleDefaults` and per-file errors, then select one by name. |
287
+ | `registerRoleDefaultsReporting(pi, agentDir?)` | function | Reports `roleDefaults` problems at each session start. Returns `report(ctx)`; call it before a lazy `loadRoleCatalog`. Each problem shows one time in each session. |
277
288
  | `resolveRoleSkills`, `resolveRoleLaunch`, `resolveConfiguredRoleLaunch` | functions | Resolve effective Pi resources and model routes. |
278
289
  | `prepareRoleLaunch`, `finalizeRoleLaunch` | functions | Prepare Role prompts, arguments, and tool policy. |
279
290
  | `createEphemeralSubagentExecutor` | function | Run bounded no-session Pi children through a FIFO pool. |
@@ -155,7 +155,7 @@ export function directSessionUsage(jsonl, prompt) {
155
155
  else if (active && message.role === "assistant") {
156
156
  if (message.usage && typeof message.usage === "object") {
157
157
  const usage = message.usage;
158
- const values = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
158
+ const values = [usage.input, usage.output];
159
159
  if (values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) {
160
160
  total += values.reduce((sum, value) => sum + value, 0);
161
161
  observed = true;
package/dist/index.d.ts CHANGED
@@ -94,6 +94,40 @@ export interface ResolvedRoleSkills {
94
94
  export declare function parseRoleName(value: unknown, source?: string): RoleName;
95
95
  /** Validate `config.json` `roleDefaults` with the same rules as Role fields; `label` prefixes errors. */
96
96
  export declare function parseRoleDefaults(value: unknown, label?: string): RoleDefaults;
97
+ export interface SubagentConfig {
98
+ maxSubagents: number;
99
+ maxTurns: number;
100
+ maxTokens: number | undefined;
101
+ maxCorrections: number;
102
+ timeout: {
103
+ idleMinutes: number;
104
+ maxMinutes: number;
105
+ };
106
+ roleDefaults: RoleDefaults;
107
+ }
108
+ export declare const DEFAULT_SUBAGENT_CONFIG: Readonly<SubagentConfig>;
109
+ /**
110
+ * The user `config.json` store shared by the extension and `loadRoleCatalog`.
111
+ * Invalid bounded limits and `roleDefaults` keys fall back to defaults; an
112
+ * invalid `maxTokens` disables the config because its default is unlimited.
113
+ */
114
+ export declare function createSubagentConfigStore(agentDir?: string): import("@henryqw/pi-config-store").ConfigStore<import("@henryqw/pi-config-store").FieldsValue<{
115
+ maxSubagents: import("@henryqw/pi-config-store").Field<number>;
116
+ maxTurns: import("@henryqw/pi-config-store").Field<number>;
117
+ maxTokens: import("@henryqw/pi-config-store").Field<number | undefined>;
118
+ maxCorrections: import("@henryqw/pi-config-store").Field<number>;
119
+ timeout: import("@henryqw/pi-config-store").Field<{
120
+ idleMinutes: number;
121
+ maxMinutes: number;
122
+ }>;
123
+ roleDefaults: import("@henryqw/pi-config-store").Field<RoleDefaults>;
124
+ }>>;
125
+ /**
126
+ * Report `roleDefaults` issues at each session start, and return a function that reports them after a lazy
127
+ * Role catalog load, such as `loadRoleCatalog` or `resolveRoleLaunch`. Every pi-config-store reporter shares
128
+ * one record, so each issue shows one time in each session, also with the pi-subagent extension active.
129
+ */
130
+ export declare function registerRoleDefaultsReporting(pi: Pick<ExtensionAPI, "on">, agentDir?: string): (ctx: Pick<ExtensionContext, "hasUI" | "ui">) => void;
97
131
  declare const BUILTIN_ROLE_NAMES: readonly ["implementer", "reviewer", "scout"];
98
132
  export type BuiltinRoleName = (typeof BUILTIN_ROLE_NAMES)[number];
99
133
  export declare function loadBuiltinRole(name: BuiltinRoleName): Role;
@@ -101,7 +135,8 @@ export declare function loadBuiltinRole(name: BuiltinRoleName): Role;
101
135
  * Package-shipped Roles plus user Roles from `config/pi-subagent`, with
102
136
  * `roleDefaults` merged in. A user Role overrides a same-named built-in. Each
103
137
  * invalid or duplicate user file is reported and makes its claimed name
104
- * unavailable without hiding valid Roles. Malformed `roleDefaults` throws.
138
+ * unavailable without hiding valid Roles. Invalid `roleDefaults` keys are
139
+ * dropped; a `config.json` that cannot load (for example, malformed JSON) throws.
105
140
  */
106
141
  export declare function loadRoleCatalog(agentDir?: string): RoleCatalog;
107
142
  /** Select one catalog Role, naming invalid files that claim it. */
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@ import { readFileSync, readdirSync } from "node:fs";
2
2
  import { basename, isAbsolute, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { DefaultPackageManager, getAgentDir, parseFrontmatter, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
- import { extensionConfigDir, extensionConfigPath } from "@henryqw/pi-config-store";
5
+ import { createConfigStore, extensionConfigDir, field, formatConfigIssues, parsers, registerConfigReporting } from "@henryqw/pi-config-store";
6
6
  import { hasDisplayControlCharacters } from "./display-text.js";
7
+ import { DEFAULT_MAX_TURNS } from "./ephemeral.js";
7
8
  import { loadRoleMcpConfig, parseRoleMcpAllowlist } from "./mcp-role.js";
8
9
  import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, PROFILE_NAMES, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
9
10
  export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.js";
@@ -172,27 +173,97 @@ export function parseRoleDefaults(value, label = "roleDefaults") {
172
173
  ...(record.skills === undefined ? {} : { skills: stringList(record.skills, "skills", label) }),
173
174
  };
174
175
  }
175
- function readRoleDefaults(agentDir) {
176
- const path = extensionConfigPath("pi-subagent", agentDir);
177
- let raw;
178
- try {
179
- raw = readFileSync(path, "utf8");
180
- }
181
- catch (error) {
182
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
183
- return {};
184
- throw error;
185
- }
186
- let parsed;
187
- try {
188
- parsed = JSON.parse(raw);
189
- }
190
- catch (error) {
191
- throw new Error(`${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
176
+ /** Per-key `roleDefaults`: an unknown key or invalid list is reported and dropped, so fewer resources load. */
177
+ const roleDefaultsField = (raw, report) => {
178
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
179
+ throw new Error("must be a JSON object");
180
+ const defaults = {};
181
+ for (const [key, value] of Object.entries(raw)) {
182
+ if (!ROLE_DEFAULT_KEYS.includes(key)) {
183
+ report([key], "unknown key", "unknown-key");
184
+ continue;
185
+ }
186
+ try {
187
+ Object.assign(defaults, parseRoleDefaults({ [key]: value }));
188
+ }
189
+ catch (error) {
190
+ report([key], error instanceof Error ? error.message : String(error));
191
+ }
192
192
  }
193
- // Whole-file validation belongs to the execution policy reader, which blocks delegation.
194
- const value = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.roleDefaults : undefined;
195
- return value === undefined ? {} : parseRoleDefaults(value, `${path}: roleDefaults`);
193
+ return defaults;
194
+ };
195
+ export const DEFAULT_SUBAGENT_CONFIG = Object.freeze({
196
+ maxSubagents: 5,
197
+ maxTurns: DEFAULT_MAX_TURNS,
198
+ maxTokens: undefined,
199
+ maxCorrections: 1,
200
+ timeout: Object.freeze({ idleMinutes: 10, maxMinutes: 30 }),
201
+ roleDefaults: Object.freeze({}),
202
+ });
203
+ const timeoutMinutes = parsers.number({ greaterThan: 0, max: 2_147_483_647 / 60_000 });
204
+ const timeoutField = (raw, report) => {
205
+ const timeout = parsers.object({
206
+ idleMinutes: field(timeoutMinutes, DEFAULT_SUBAGENT_CONFIG.timeout.idleMinutes),
207
+ maxMinutes: field(timeoutMinutes, DEFAULT_SUBAGENT_CONFIG.timeout.maxMinutes),
208
+ })(raw, report);
209
+ if (timeout.maxMinutes <= timeout.idleMinutes) {
210
+ throw new Error(`maxMinutes (${timeout.maxMinutes}) must be greater than idleMinutes (${timeout.idleMinutes})`);
211
+ }
212
+ return timeout;
213
+ };
214
+ /**
215
+ * The user `config.json` store shared by the extension and `loadRoleCatalog`.
216
+ * Invalid bounded limits and `roleDefaults` keys fall back to defaults; an
217
+ * invalid `maxTokens` disables the config because its default is unlimited.
218
+ */
219
+ export function createSubagentConfigStore(agentDir = getAgentDir()) {
220
+ return createConfigStore({
221
+ extensionId: "pi-subagent",
222
+ agentDir,
223
+ fields: {
224
+ maxSubagents: field(parsers.integer({ min: 1 }), DEFAULT_SUBAGENT_CONFIG.maxSubagents),
225
+ maxTurns: field(parsers.integer({ min: 1 }), DEFAULT_SUBAGENT_CONFIG.maxTurns),
226
+ maxTokens: field(parsers.integer({ min: 1 }), undefined, { onInvalid: "disable" }),
227
+ maxCorrections: field(parsers.integer({ min: 0 }), DEFAULT_SUBAGENT_CONFIG.maxCorrections),
228
+ timeout: field(timeoutField, DEFAULT_SUBAGENT_CONFIG.timeout),
229
+ roleDefaults: field(roleDefaultsField, DEFAULT_SUBAGENT_CONFIG.roleDefaults),
230
+ },
231
+ });
232
+ }
233
+ /**
234
+ * Read only `roleDefaults`. The extension reports and enforces the execution limits, so a bad limit does not
235
+ * break the Role catalog of other consumers; other keys are not this reader's issues.
236
+ */
237
+ function loadRoleDefaults(agentDir) {
238
+ const store = createConfigStore({
239
+ extensionId: "pi-subagent",
240
+ agentDir,
241
+ fields: { roleDefaults: field(roleDefaultsField, DEFAULT_SUBAGENT_CONFIG.roleDefaults) },
242
+ });
243
+ const loaded = store.loadSync();
244
+ const issues = loaded.issues.filter(({ key }) => !key || /^roleDefaults(?![\w$])/.test(key));
245
+ // A file that cannot load has no trusted `roleDefaults`.
246
+ return issues.some(({ key }) => !key)
247
+ ? { store, loaded: { status: "disabled", source: loaded.source, issues } }
248
+ : { store, loaded: { ...loaded, issues } };
249
+ }
250
+ function readRoleDefaults(agentDir) {
251
+ const { store, loaded } = loadRoleDefaults(agentDir);
252
+ if (loaded.status !== "ok")
253
+ throw new Error(`${store.path}: ${formatConfigIssues(loaded.issues)}`);
254
+ return loaded.value.roleDefaults;
255
+ }
256
+ /**
257
+ * Report `roleDefaults` issues at each session start, and return a function that reports them after a lazy
258
+ * Role catalog load, such as `loadRoleCatalog` or `resolveRoleLaunch`. Every pi-config-store reporter shares
259
+ * one record, so each issue shows one time in each session, also with the pi-subagent extension active.
260
+ */
261
+ export function registerRoleDefaultsReporting(pi, agentDir = getAgentDir()) {
262
+ const target = loadRoleDefaults(agentDir).store;
263
+ const reporter = registerConfigReporting(pi, []);
264
+ const report = (ctx) => reporter.report(ctx, target, loadRoleDefaults(agentDir).loaded);
265
+ pi.on("session_start", (_event, ctx) => report(ctx));
266
+ return report;
196
267
  }
197
268
  const withDefaults = (defaults, values) => [...new Set([...(defaults ?? []), ...values])];
198
269
  function applyRoleDefaults(role, defaults) {
@@ -262,7 +333,8 @@ function claimedRoleName(file) {
262
333
  * Package-shipped Roles plus user Roles from `config/pi-subagent`, with
263
334
  * `roleDefaults` merged in. A user Role overrides a same-named built-in. Each
264
335
  * invalid or duplicate user file is reported and makes its claimed name
265
- * unavailable without hiding valid Roles. Malformed `roleDefaults` throws.
336
+ * unavailable without hiding valid Roles. Invalid `roleDefaults` keys are
337
+ * dropped; a `config.json` that cannot load (for example, malformed JSON) throws.
266
338
  */
267
339
  export function loadRoleCatalog(agentDir = getAgentDir()) {
268
340
  const defaults = readRoleDefaults(agentDir);
@@ -326,13 +398,8 @@ export function findRole(catalog, name) {
326
398
  throw new Error(`Unknown Subagent role: ${name}. Available roles: ${catalog.roles.map((candidate) => candidate.name).join(", ") || "none"}.${catalog.errors.length ? ` ${catalog.errors.length} invalid Role file(s); run /subagent roles.` : ""}`);
327
399
  }
328
400
  export function resolveTaskRoute(ctx, profileName, agentDir = getAgentDir()) {
329
- let config;
330
- try {
331
- config = loadTaskModelsConfig(agentDir).value;
332
- }
333
- catch {
334
- throw new Error("Couldn't read task model config. Run /task-models.");
335
- }
401
+ // A disabled config throws with the path and the problems.
402
+ const config = loadTaskModelsConfig(agentDir).value;
336
403
  return resolveConfiguredRoute(ctx, profileName, config.profiles[profileName]);
337
404
  }
338
405
  function resolveConfiguredRoute(ctx, profileName, profile) {
@@ -1,158 +1,21 @@
1
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
- import { createConfigStore, extensionConfigPath } from "@henryqw/pi-config-store";
1
+ import { formatConfigIssues, type ConfigLoad } from "@henryqw/pi-config-store";
3
2
  import type { ExecutionPolicySnapshot } from "../dist/schema.js";
4
- import { parseRoleDefaults, type RoleDefaults } from "@henryqw/pi-subagent";
5
-
6
- export interface SubagentTimeoutConfig {
7
- /** Minutes a child may stay idle before it is asked to stop. */
8
- idleMinutes?: number;
9
- /** Hard cap in minutes before a child is killed regardless of activity. */
10
- maxMinutes?: number;
11
- }
12
-
13
- export interface SubagentConfig {
14
- maxSubagents?: number;
15
- maxTurns?: number;
16
- maxTokens?: number;
17
- maxCorrections?: number;
18
- timeout?: SubagentTimeoutConfig;
19
- /** Merged into every Role by `loadRoleCatalog`; validated here so malformed values block delegation. */
20
- roleDefaults?: RoleDefaults;
21
- }
22
-
23
- export interface LoadedSubagentConfig {
24
- source: "file" | "missing";
25
- config: SubagentConfig;
26
- /** Human-readable problems; malformed files are preserved and delegation fails closed. */
27
- error?: string;
28
- }
3
+ import type { SubagentConfig } from "@henryqw/pi-subagent";
29
4
 
30
5
  export type EffectiveExecutionPolicy = ExecutionPolicySnapshot;
31
6
 
32
- type ParsedSubagentConfig = Omit<LoadedSubagentConfig, "source">;
33
-
34
- const EXTENSION_ID = "pi-subagent";
35
- const positive = (value: unknown): value is number =>
36
- typeof value === "number" && Number.isFinite(value) && value > 0;
37
- const MAX_TIMER_DELAY_MS = 2_147_483_647;
38
- export const DEFAULT_TIMEOUT_CONFIG = { idleMinutes: 10, maxMinutes: 30 } as const;
39
- export const DEFAULT_EXECUTION_POLICY = {
40
- maxSubagents: 5,
41
- maxTurns: 50,
42
- maxCorrections: 1,
43
- } as const;
44
- const INTEGER_FIELDS = ["maxSubagents", "maxTurns", "maxTokens"] as const;
45
- const POLICY_FIELDS = ["maxCorrections"] as const;
46
- const TIMEOUT_FIELDS = ["idleMinutes", "maxMinutes"] as const;
47
-
48
- export const configPath = (agentDir = getAgentDir()): string =>
49
- extensionConfigPath(EXTENSION_ID, agentDir);
50
-
51
- function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfig {
52
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
53
- return { config: {}, error: `${path} must contain a JSON object; delegation is blocked until this file is corrected.` };
54
- }
55
- const record = parsed as Record<string, unknown>;
56
- const problems: string[] = [];
57
- const config: SubagentConfig = {};
58
- for (const key of Object.keys(record)) {
59
- if (![...INTEGER_FIELDS, ...POLICY_FIELDS, "timeout", "roleDefaults"].includes(key as never)) {
60
- problems.push(`unknown config key ${JSON.stringify(key)}; expected ${[...INTEGER_FIELDS, ...POLICY_FIELDS].join(", ")}, timeout, roleDefaults`);
61
- }
62
- }
63
- if (record.roleDefaults !== undefined) {
64
- try { config.roleDefaults = parseRoleDefaults(record.roleDefaults); }
65
- catch (error) { problems.push(error instanceof Error ? error.message : String(error)); }
7
+ /** Resolve one immutable policy. A disabled config blocks delegation. */
8
+ export function resolveExecutionPolicy(path: string, loaded: ConfigLoad<SubagentConfig>): EffectiveExecutionPolicy {
9
+ if (loaded.status === "disabled") {
10
+ throw new Error(`${path}: ${formatConfigIssues(loaded.issues)}; delegation is blocked until this file is corrected.`);
66
11
  }
67
- for (const key of INTEGER_FIELDS) {
68
- const value = record[key];
69
- if (value === undefined) continue;
70
- if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1) config[key] = value;
71
- else problems.push(`${key} must be a safe integer >= 1, got ${JSON.stringify(value)}`);
72
- }
73
- if (record.maxCorrections !== undefined) {
74
- if (!Number.isSafeInteger(record.maxCorrections) || (record.maxCorrections as number) < 0) {
75
- problems.push(`maxCorrections must be a safe integer >= 0, got ${JSON.stringify(record.maxCorrections)}`);
76
- } else config.maxCorrections = record.maxCorrections as number;
77
- }
78
- if (record.timeout !== undefined) {
79
- if (!record.timeout || typeof record.timeout !== "object" || Array.isArray(record.timeout)) {
80
- problems.push(`timeout must be a JSON object, got ${JSON.stringify(record.timeout)}`);
81
- } else {
82
- const timeoutRecord = record.timeout as Record<string, unknown>;
83
- const timeout: SubagentTimeoutConfig = {};
84
- for (const key of TIMEOUT_FIELDS) {
85
- const value = timeoutRecord[key];
86
- if (value === undefined) continue;
87
- if (!positive(value)) problems.push(`timeout.${key} must be a positive number of minutes, got ${JSON.stringify(value)}`);
88
- else if (value * 60_000 > MAX_TIMER_DELAY_MS) problems.push(`timeout.${key} exceeds ${MAX_TIMER_DELAY_MS} ms, got ${JSON.stringify(value)} minutes`);
89
- else timeout[key] = value;
90
- }
91
- for (const key of Object.keys(timeoutRecord)) {
92
- if (!TIMEOUT_FIELDS.includes(key as keyof SubagentTimeoutConfig)) problems.push(`unknown timeout.${key}; expected ${TIMEOUT_FIELDS.join(", ")}`);
93
- }
94
- if (Object.keys(timeout).length) config.timeout = timeout;
95
- }
96
- }
97
- if (config.timeout) {
98
- const idleMinutes = config.timeout.idleMinutes ?? DEFAULT_TIMEOUT_CONFIG.idleMinutes;
99
- const maxMinutes = config.timeout.maxMinutes ?? DEFAULT_TIMEOUT_CONFIG.maxMinutes;
100
- if (maxMinutes <= idleMinutes) {
101
- problems.push(`timeout.maxMinutes (${maxMinutes}) must be greater than timeout.idleMinutes (${idleMinutes})`);
102
- delete config.timeout;
103
- }
104
- }
105
- return {
106
- config,
107
- error: problems.length ? `${path}: ${problems.join("; ")}; delegation is blocked until this file is corrected.` : undefined,
108
- };
109
- }
110
-
111
- /** Resolve one immutable policy. Malformed explicit safety settings fail closed. */
112
- export function resolveExecutionPolicy(loaded: LoadedSubagentConfig): EffectiveExecutionPolicy {
113
- if (loaded.error) throw new Error(loaded.error);
114
- const timeout = loaded.config.timeout;
12
+ const { maxSubagents, maxTurns, maxTokens, maxCorrections, timeout } = loaded.value;
115
13
  return Object.freeze({
116
- maxSubagents: loaded.config.maxSubagents ?? DEFAULT_EXECUTION_POLICY.maxSubagents,
117
- maxTurns: loaded.config.maxTurns ?? DEFAULT_EXECUTION_POLICY.maxTurns,
118
- ...(loaded.config.maxTokens === undefined ? {} : { maxTokens: loaded.config.maxTokens }),
119
- childIdleMs: (timeout?.idleMinutes ?? DEFAULT_TIMEOUT_CONFIG.idleMinutes) * 60_000,
120
- childMaxMs: (timeout?.maxMinutes ?? DEFAULT_TIMEOUT_CONFIG.maxMinutes) * 60_000,
121
- maxCorrections: loaded.config.maxCorrections ?? DEFAULT_EXECUTION_POLICY.maxCorrections,
14
+ maxSubagents,
15
+ maxTurns,
16
+ ...(maxTokens === undefined ? {} : { maxTokens }),
17
+ childIdleMs: timeout.idleMinutes * 60_000,
18
+ childMaxMs: timeout.maxMinutes * 60_000,
19
+ maxCorrections,
122
20
  });
123
21
  }
124
-
125
- /** Read untrusted optional config without rewriting it. */
126
- export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConfig {
127
- const path = configPath(agentDir);
128
- let validationError: string | undefined;
129
- const store = createConfigStore<SubagentConfig>({
130
- extensionId: EXTENSION_ID,
131
- agentDir,
132
- defaults: () => ({}),
133
- parse(value) {
134
- const parsed = parseSubagentConfig(value, path);
135
- validationError = parsed.error;
136
- return parsed.config;
137
- },
138
- });
139
- try {
140
- const loaded = store.loadSync();
141
- return {
142
- source: loaded.source,
143
- config: loaded.value,
144
- ...(validationError === undefined ? {} : { error: validationError }),
145
- };
146
- } catch (error) {
147
- const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
148
- if (code !== undefined && code !== "ERR_ENCODING_INVALID_ENCODED_DATA") throw error;
149
- const reason = error instanceof Error ? error.message : String(error);
150
- return {
151
- source: "file",
152
- config: {},
153
- error: error instanceof SyntaxError
154
- ? `${path} is not valid JSON (${reason}); delegation is blocked until this file is corrected.`
155
- : `${path} couldn't be read (${reason}); delegation is blocked until this file is corrected.`,
156
- };
157
- }
158
- }
@@ -482,6 +482,8 @@ export interface RegisterIsolatedOptions {
482
482
  executor: EphemeralSubagentExecutor;
483
483
  policy: EffectiveExecutionPolicy;
484
484
  currentPolicy(): EffectiveExecutionPolicy;
485
+ /** Report new config issues, and throw when the config now blocks delegation, before each deferred Role launch. */
486
+ beforeRoleLaunch?(ctx: ExtensionContext): void;
485
487
  componentsFactory?: CreateIsolatedComponents;
486
488
  }
487
489
 
@@ -504,7 +506,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
504
506
  };
505
507
  const getComponents = () => components ??= componentsFactory({
506
508
  pi,
507
- context: latestContext,
509
+ // The launch runtime reads this context only to resolve a Role launch.
510
+ context: () => {
511
+ const ctx = latestContext();
512
+ options.beforeRoleLaunch?.(ctx);
513
+ return ctx;
514
+ },
508
515
  executor: options.executor,
509
516
  policy: options.policy,
510
517
  currentPolicy: options.currentPolicy,
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
- import { availableTaskModels, loadTaskModelsConfig, modelReference, type ProfileName, registerModelTask, resolveAvailableModel, resolveConfiguredTaskRoute, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
5
- import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, findRole, formatDuration, loadRoleCatalog, prepareRoleLaunch, resolveRolePackageResources, resolveTaskRoute, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
6
- import { readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
4
+ import { availableTaskModels, loadTaskModelsConfig, modelReference, type ProfileName, registerModelTask, registerTaskModelsConfigReporting, resolveAvailableModel, resolveConfiguredTaskRoute, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
5
+ import { registerConfigReporting } from "@henryqw/pi-config-store";
6
+ import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, createSubagentConfigStore, DEFAULT_SUBAGENT_CONFIG, DELEGATE_TASK, findRole, formatDuration, loadRoleCatalog, prepareRoleLaunch, resolveRolePackageResources, resolveTaskRoute, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
7
+ import { resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
7
8
  import { registerCheckoutAdmission } from "./admission.ts";
8
9
  import { registerIsolatedExtension } from "./isolated.ts";
9
10
  import { directRef, registerSubagentCommand, type DirectDisplay, type DirectTask } from "./subagent-command.ts";
@@ -180,20 +181,45 @@ export default function subagentExtension(pi: ExtensionAPI): void {
180
181
  const widgetItems = new Map<string, WidgetItem>();
181
182
  const tokenHandles = new Map<string, DirectHandle>();
182
183
  const samplingTokens = new Set<string>();
183
- const loadedConfig = readSubagentConfig();
184
- let initialPolicy: EffectiveExecutionPolicy;
185
- try {
186
- initialPolicy = resolveExecutionPolicy(loadedConfig);
187
- } catch {
188
- initialPolicy = resolveExecutionPolicy({ source: "missing", config: {} });
189
- }
184
+ const configStore = createSubagentConfigStore();
185
+ const configReporter = registerConfigReporting(pi, [configStore]);
186
+ const reportTaskModels = registerTaskModelsConfigReporting(pi);
187
+ /** Report new issues before a lazy Role catalog, route, or policy read. */
188
+ const reportConfig = (ctx: Pick<ExtensionContext, "hasUI" | "ui">): void => {
189
+ configReporter.report(ctx, configStore, configStore.loadSync());
190
+ reportTaskModels(ctx);
191
+ };
192
+ const loadedConfig = configStore.loadSync();
193
+ // A disabled config blocks each delegation; the executor still needs finite limits.
194
+ const initialPolicy = resolveExecutionPolicy(configStore.path, loadedConfig.status === "ok" ? loadedConfig
195
+ : { status: "ok", source: loadedConfig.source, value: DEFAULT_SUBAGENT_CONFIG, issues: [] });
190
196
  const executor = createEphemeralSubagentExecutor({
191
197
  maxConcurrency: initialPolicy.maxSubagents,
192
198
  maxTurns: initialPolicy.maxTurns,
193
199
  maxTokens: initialPolicy.maxTokens,
194
200
  timeout: { idleMs: initialPolicy.childIdleMs, maxMs: initialPolicy.childMaxMs },
195
201
  });
196
- const currentPolicy = (): EffectiveExecutionPolicy => resolveExecutionPolicy(readSubagentConfig());
202
+ // The isolated runner reads the policy lazily without a context, so each read reports through the session's.
203
+ let latestCtx: ExtensionContext | undefined;
204
+ const currentPolicy = (): EffectiveExecutionPolicy => {
205
+ const loaded = configStore.loadSync();
206
+ if (latestCtx) configReporter.report(latestCtx, configStore, loaded);
207
+ const policy = resolveExecutionPolicy(configStore.path, loaded);
208
+ // The executor took its limits from the defaults, which can be unlimited, so a repaired file needs a reload.
209
+ if (loadedConfig.status === "disabled") {
210
+ throw new Error(`${configStore.path} was disabled when the session started; run /reload to apply its execution limits.`);
211
+ }
212
+ return policy;
213
+ };
214
+ /**
215
+ * Before a deferred Role launch: report new issues, then revalidate so a now-disabled config blocks the child.
216
+ * The workflow keeps the limits that it admitted at entry (direct idle timeout and concurrency, or the durable
217
+ * isolated request's recorded policy); a limit change applies to the next workflow.
218
+ */
219
+ const beforeRoleLaunch = (ctx: ExtensionContext): void => {
220
+ reportConfig(ctx);
221
+ currentPolicy();
222
+ };
197
223
  const canWrite = (input: unknown): boolean => {
198
224
  try {
199
225
  const parsed = parseDelegateTask(input);
@@ -222,6 +248,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
222
248
  executor,
223
249
  policy: initialPolicy,
224
250
  currentPolicy,
251
+ beforeRoleLaunch,
225
252
  });
226
253
  let directSequence = 0;
227
254
  const directTasks = new Map<string, {
@@ -237,7 +264,6 @@ export default function subagentExtension(pi: ExtensionAPI): void {
237
264
  return tab.entryId === saved.entryId && tab.name === saved.name && tab.tabId === saved.tabId
238
265
  && tab.paneId === saved.paneId && tab.sessionFile === saved.sessionFile;
239
266
  });
240
- let latestCtx: ExtensionContext | undefined;
241
267
  let sessionEpoch = 0;
242
268
  // Shutdown still saves outgoing identities; only a session start defers records to its carry loop.
243
269
  let directRecordEpoch = 0;
@@ -311,6 +337,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
311
337
  if (!current()) throw new Error("Session or branch changed before status inspection.");
312
338
  const snapshot = notices.join("\n");
313
339
  if (Buffer.byteLength(snapshot, "utf8") > 32_000) throw new Error("Status exceeds the 32 KiB inspection limit; use subagent_status for exact evidence.");
340
+ reportConfig(ctx);
314
341
  const role = findRole(loadRoleCatalog(), "scout");
315
342
  const prepared = await prepareDirectLaunch(role, ctx, { modelClass: "fast" });
316
343
  const herdr = createDirectHerdr(pi, ctx.cwd, currentPolicy().childIdleMs);
@@ -354,6 +381,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
354
381
  roles(ctx) {
355
382
  const message = (error: unknown) => error instanceof Error ? error.message : String(error);
356
383
  let catalog: ReturnType<typeof loadRoleCatalog>;
384
+ reportConfig(ctx);
357
385
  try { catalog = loadRoleCatalog(); } catch (error) { return `Role catalog unavailable: ${message(error)}`; }
358
386
  const list = (values: readonly string[] | undefined) => values?.length ? values.join(", ") : "none";
359
387
  const lines = catalog.roles.flatMap((role) => {
@@ -507,13 +535,12 @@ export default function subagentExtension(pi: ExtensionAPI): void {
507
535
  }
508
536
  latestCtx = ctx;
509
537
  ensureWidget(ctx);
510
- if (loadedConfig.error !== undefined) ctx.ui.notify(loadedConfig.error, "warning");
511
538
  try {
512
539
  if (loadTaskModelsConfig().source === "missing") {
513
540
  ctx.ui.notify("Task model config is missing; run /task-models to configure it.", "warning");
514
541
  }
515
542
  } catch {
516
- // Route resolution retains the existing malformed shared-config error.
543
+ // A disabled shared config throws; the task-models config reporter already shows the path and the problems.
517
544
  }
518
545
  });
519
546
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -620,6 +647,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
620
647
  },
621
648
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
622
649
  signal?.throwIfAborted();
650
+ // Isolated requests read the policy in the runner; report it here with the tool's context.
651
+ reportConfig(ctx);
623
652
  const parsed = parseDelegateTask(params);
624
653
  if (parsed.mode === "isolated") return await isolatedSurface.execute(parsed.request, signal, ctx);
625
654
  const workflow = parsed.workflow;
@@ -719,6 +748,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
719
748
  directTasks.set(taskId, local);
720
749
  const launch = async (entry: WorkflowEntry, activeSignal: AbortSignal) => {
721
750
  activeSignal.throwIfAborted();
751
+ // A later worker launches after earlier ones finish; report and revalidate config edits made meanwhile.
752
+ beforeRoleLaunch(latestCtx ?? ctx);
722
753
  const role = findRole(loadRoleCatalog(), entry.delegation.role);
723
754
  const { modelClass, model } = entry.delegation;
724
755
  const prepared = await prepareDirectLaunch(role, latestCtx ?? ctx, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.1.2",
3
+ "version": "27.2.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -66,11 +66,11 @@
66
66
  "image": "https://raw.githubusercontent.com/HenryQW/pi-harness/main/extensions/pi-subagent/example.png"
67
67
  },
68
68
  "dependencies": {
69
- "@henryqw/pi-config-store": "^3.0.0",
69
+ "@henryqw/pi-config-store": "^4.0.0",
70
70
  "@henryqw/pi-herdr": "^0.5.0",
71
71
  "@henryqw/pi-multi-codex": "^3.0.0",
72
72
  "@henryqw/pi-process": "^0.2.0",
73
- "@henryqw/pi-task-models": "^8.0.0",
73
+ "@henryqw/pi-task-models": "^9.0.0",
74
74
  "proper-lockfile": "^4.1.2"
75
75
  },
76
76
  "devDependencies": {