@henryqw/pi-subagent 27.1.2 → 27.2.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
@@ -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
@@ -220,11 +220,19 @@ pi-subagent owns `~/.pi/agent/config/pi-subagent/config.json`. If the file is mi
220
220
  | `maxCorrections` | Same-worker automatic corrections per isolated request | Safe integer ≥ 0 | `1` |
221
221
  | `timeout.idleMinutes` | Direct worker and ephemeral child idle timeout | Positive and within Node's timer range | `10` |
222
222
  | `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 |
223
+ | `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
224
 
225
225
  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
226
 
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.
227
+ 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:
228
+
229
+ - An unknown key shows a warning. The extension ignores the key.
230
+ - 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.
231
+ - 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`.
232
+ - An unknown or invalid key in `roleDefaults` shows a warning. The extension ignores only that key, so Roles get fewer default resources.
233
+ - 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.
234
+
235
+ No key is required. The extension preserves the file and never rewrites it automatically.
228
236
 
229
237
  ### Roles
230
238
 
@@ -274,6 +282,7 @@ The servers named in `mcps` come only from the global `~/.pi/agent/mcp.json`. In
274
282
  | Surface | Type | Purpose |
275
283
  | --- | --- | --- |
276
284
  | `loadRoleCatalog`, `findRole`, `parseRoleName`, `parseRoleDefaults` | functions | Load built-in and user Roles with `roleDefaults` and per-file errors, then select one by name. |
285
+ | `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
286
  | `resolveRoleSkills`, `resolveRoleLaunch`, `resolveConfiguredRoleLaunch` | functions | Resolve effective Pi resources and model routes. |
278
287
  | `prepareRoleLaunch`, `finalizeRoleLaunch` | functions | Prepare Role prompts, arguments, and tool policy. |
279
288
  | `createEphemeralSubagentExecutor` | function | Run bounded no-session Pi children through a FIFO pool. |
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.0",
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": {