@bermudi/pi-delegate 0.1.11 → 0.1.12

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/config.ts CHANGED
@@ -1,13 +1,164 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
5
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
6
  import {
5
7
  MAX_ASYNC_TICKETS,
6
8
  MAX_CONCURRENCY,
7
9
  OUTPUT_SPILL_TAIL_CHARS,
8
10
  OUTPUT_SPILL_THRESHOLD_CHARS,
11
+ VALID_THINKING,
9
12
  } from "./constants.ts";
10
13
 
14
+ export interface AgentOverride {
15
+ model?: string;
16
+ thinking?: ThinkingLevel;
17
+ tools?: string[];
18
+ }
19
+
20
+ const DELEGATE_CONFIG_SOURCE = "delegate.json";
21
+
22
+ /** Validate one agentOverrides entry. A malformed entry is dropped whole
23
+ * (warn) rather than partially applied — a half-parsed override that
24
+ * silently changes only `thinking` is worse than a loud no-op. */
25
+ function normalizeAgentOverride(
26
+ raw: unknown,
27
+ agentName: string,
28
+ ): AgentOverride | null {
29
+ if (!isRecord(raw)) {
30
+ console.warn(
31
+ `[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: expected an object.`,
32
+ );
33
+ return null;
34
+ }
35
+
36
+ const result: AgentOverride = {};
37
+ for (const [key, value] of Object.entries(raw)) {
38
+ if (key === "model") {
39
+ if (typeof value !== "string" || value.trim().length === 0) {
40
+ console.warn(
41
+ `[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: model must be a nonempty string.`,
42
+ );
43
+ return null;
44
+ }
45
+ result.model = value.trim();
46
+ } else if (key === "thinking") {
47
+ if (typeof value !== "string" || !VALID_THINKING.has(value)) {
48
+ console.warn(
49
+ `[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: thinking must be a supported level.`,
50
+ );
51
+ return null;
52
+ }
53
+ result.thinking = value as ThinkingLevel;
54
+ } else if (key === "tools") {
55
+ if (
56
+ !Array.isArray(value) ||
57
+ value.some((tool) => typeof tool !== "string")
58
+ ) {
59
+ console.warn(
60
+ `[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: tools must be a string array.`,
61
+ );
62
+ return null;
63
+ }
64
+ result.tools = [...value];
65
+ } else if (key === "skills") {
66
+ console.warn(
67
+ `[delegate] ignoring unsupported skills override for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: per-agent skill filtering is not supported.`,
68
+ );
69
+ return null;
70
+ } else {
71
+ console.warn(
72
+ `[delegate] ignoring malformed agentOverrides entry for agent '${agentName}' in ${DELEGATE_CONFIG_SOURCE}: unknown field '${key}'.`,
73
+ );
74
+ return null;
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ /** Normalize the `agentOverrides` map (agent name → override). Keys are
81
+ * trimmed; collisions after trimming keep the first entry and warn. Returns
82
+ * undefined when the block is absent. */
83
+ function normalizeAgentOverrides(
84
+ raw: unknown,
85
+ ): Record<string, AgentOverride> | undefined {
86
+ if (raw === undefined) return undefined;
87
+ if (!isRecord(raw)) {
88
+ console.warn(
89
+ `[delegate] ignoring malformed agentOverrides in ${DELEGATE_CONFIG_SOURCE}: expected an object.`,
90
+ );
91
+ return undefined;
92
+ }
93
+
94
+ // Null-prototype map so config keys such as `constructor` and `__proto__`
95
+ // cannot resolve to Object.prototype members during normalization.
96
+ const out = Object.create(null) as Record<string, AgentOverride>;
97
+ const seenNames = new Map<string, string>();
98
+ for (const [agentName, value] of Object.entries(raw)) {
99
+ const normalizedAgentName = agentName.trim();
100
+ if (normalizedAgentName.length === 0) {
101
+ console.warn(
102
+ `[delegate] ignoring malformed agentOverrides entry in ${DELEGATE_CONFIG_SOURCE}: agent name must be nonempty.`,
103
+ );
104
+ continue;
105
+ }
106
+ const previousName = seenNames.get(normalizedAgentName);
107
+ if (previousName !== undefined) {
108
+ console.warn(
109
+ `[delegate] ignoring duplicate agentOverrides entry in ${DELEGATE_CONFIG_SOURCE}: agent keys '${previousName}' and '${agentName}' both normalize to '${normalizedAgentName}'.`,
110
+ );
111
+ continue;
112
+ }
113
+ seenNames.set(normalizedAgentName, agentName);
114
+ const override = normalizeAgentOverride(value, normalizedAgentName);
115
+ if (override) out[normalizedAgentName] = override;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Normalize `agentOverridesByParentModel` (`provider/model-id` → agent →
121
+ * override). Model keys are trimmed, not lowercased: they must match the
122
+ * parent's exact `provider/model-id`. Returns undefined when absent. */
123
+ function normalizeAgentOverridesByParentModel(
124
+ raw: unknown,
125
+ ): Record<string, Record<string, AgentOverride>> | undefined {
126
+ if (raw === undefined) return undefined;
127
+ if (!isRecord(raw)) {
128
+ console.warn(
129
+ `[delegate] ignoring malformed agentOverridesByParentModel in ${DELEGATE_CONFIG_SOURCE}: expected an object.`,
130
+ );
131
+ return undefined;
132
+ }
133
+
134
+ const out = Object.create(null) as Record<
135
+ string,
136
+ Record<string, AgentOverride>
137
+ >;
138
+ const seenModels = new Map<string, string>();
139
+ for (const [parentModel, overrides] of Object.entries(raw)) {
140
+ const normalizedParentModel = parentModel.trim();
141
+ if (normalizedParentModel.length === 0) {
142
+ console.warn(
143
+ `[delegate] ignoring malformed parent-model override in ${DELEGATE_CONFIG_SOURCE}: model key must be nonempty.`,
144
+ );
145
+ continue;
146
+ }
147
+ const previousModel = seenModels.get(normalizedParentModel);
148
+ if (previousModel !== undefined) {
149
+ console.warn(
150
+ `[delegate] ignoring duplicate parent-model override in ${DELEGATE_CONFIG_SOURCE}: model keys '${previousModel}' and '${parentModel}' both normalize to '${normalizedParentModel}'.`,
151
+ );
152
+ continue;
153
+ }
154
+ seenModels.set(normalizedParentModel, parentModel);
155
+ const inner = normalizeAgentOverrides(overrides);
156
+ out[normalizedParentModel] =
157
+ inner ?? (Object.create(null) as Record<string, AgentOverride>);
158
+ }
159
+ return out;
160
+ }
161
+
11
162
  export interface TelemetryConfig {
12
163
  /** Whether to record delegate calls to the local SQLite store. Default true. */
13
164
  enabled?: boolean;
@@ -23,6 +174,172 @@ function isRecord(value: unknown): value is Record<string, unknown> {
23
174
  return value !== null && typeof value === "object" && !Array.isArray(value);
24
175
  }
25
176
 
177
+ function isPositiveInteger(value: unknown): value is number {
178
+ return (
179
+ typeof value === "number" &&
180
+ Number.isFinite(value) &&
181
+ Number.isInteger(value) &&
182
+ value > 0
183
+ );
184
+ }
185
+
186
+ function isNonNegativeInteger(value: unknown): value is number {
187
+ return (
188
+ typeof value === "number" &&
189
+ Number.isFinite(value) &&
190
+ Number.isInteger(value) &&
191
+ value >= 0
192
+ );
193
+ }
194
+
195
+ /** Validate numeric and nested-object fields before they reach the config
196
+ * singleton. Malformed values in `delegate.json` must not replace a valid
197
+ * snapshot with a half-parsed object that later produces `NaN` concurrency
198
+ * limits and deadlocks the global semaphore. */
199
+ function validateNumericAndNestedFields(
200
+ raw: Record<string, unknown>,
201
+ ): string | null {
202
+ if (
203
+ "allowUnsafeSharedWrites" in raw &&
204
+ typeof raw.allowUnsafeSharedWrites !== "boolean"
205
+ ) {
206
+ return `allowUnsafeSharedWrites must be a boolean; got ${JSON.stringify(
207
+ raw.allowUnsafeSharedWrites,
208
+ )}`;
209
+ }
210
+ if ("agent" in raw && raw.agent !== undefined && !isRecord(raw.agent)) {
211
+ return "agent must be an object";
212
+ }
213
+ if (
214
+ "concurrency" in raw &&
215
+ raw.concurrency !== undefined &&
216
+ !isRecord(raw.concurrency)
217
+ ) {
218
+ return "concurrency must be an object";
219
+ }
220
+ if ("retry" in raw && raw.retry !== undefined && !isRecord(raw.retry)) {
221
+ return "retry must be an object";
222
+ }
223
+ if ("output" in raw && raw.output !== undefined && !isRecord(raw.output)) {
224
+ return "output must be an object";
225
+ }
226
+
227
+ if (
228
+ "maxConcurrent" in raw &&
229
+ raw.maxConcurrent !== undefined &&
230
+ !isPositiveInteger(raw.maxConcurrent)
231
+ ) {
232
+ return `maxConcurrent must be a positive integer; got ${JSON.stringify(
233
+ raw.maxConcurrent,
234
+ )}`;
235
+ }
236
+ if (
237
+ "maxAsyncTickets" in raw &&
238
+ raw.maxAsyncTickets !== undefined &&
239
+ !isPositiveInteger(raw.maxAsyncTickets)
240
+ ) {
241
+ return `maxAsyncTickets must be a positive integer; got ${JSON.stringify(
242
+ raw.maxAsyncTickets,
243
+ )}`;
244
+ }
245
+ if (
246
+ "stallTimeoutMs" in raw &&
247
+ raw.stallTimeoutMs !== undefined &&
248
+ !isNonNegativeInteger(raw.stallTimeoutMs)
249
+ ) {
250
+ return `stallTimeoutMs must be a non-negative integer; got ${JSON.stringify(
251
+ raw.stallTimeoutMs,
252
+ )}`;
253
+ }
254
+
255
+ const concurrency = raw.concurrency;
256
+ if (concurrency) {
257
+ const c = concurrency as Record<string, unknown>;
258
+ if ("default" in c && !isPositiveInteger(c.default)) {
259
+ return `concurrency.default must be a positive integer; got ${JSON.stringify(
260
+ c.default,
261
+ )}`;
262
+ }
263
+ if ("providers" in c) {
264
+ const providers = c.providers;
265
+ if (!isRecord(providers)) {
266
+ return "concurrency.providers must be an object";
267
+ }
268
+ for (const [key, value] of Object.entries(providers)) {
269
+ if (!isPositiveInteger(value)) {
270
+ return `concurrency.providers.${key} must be a positive integer; got ${JSON.stringify(
271
+ value,
272
+ )}`;
273
+ }
274
+ }
275
+ }
276
+ if ("models" in c) {
277
+ const models = c.models;
278
+ if (!isRecord(models)) {
279
+ return "concurrency.models must be an object";
280
+ }
281
+ for (const [key, value] of Object.entries(models)) {
282
+ if (!isPositiveInteger(value)) {
283
+ return `concurrency.models.${key} must be a positive integer; got ${JSON.stringify(
284
+ value,
285
+ )}`;
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ const retry = raw.retry;
292
+ if (retry) {
293
+ const r = retry as Record<string, unknown>;
294
+ if (
295
+ "wholeTaskMaxRetries" in r &&
296
+ !isNonNegativeInteger(r.wholeTaskMaxRetries)
297
+ ) {
298
+ return `retry.wholeTaskMaxRetries must be a non-negative integer; got ${JSON.stringify(
299
+ r.wholeTaskMaxRetries,
300
+ )}`;
301
+ }
302
+ if (
303
+ "wholeTaskBaseDelayMs" in r &&
304
+ !isNonNegativeInteger(r.wholeTaskBaseDelayMs)
305
+ ) {
306
+ return `retry.wholeTaskBaseDelayMs must be a non-negative integer; got ${JSON.stringify(
307
+ r.wholeTaskBaseDelayMs,
308
+ )}`;
309
+ }
310
+ }
311
+
312
+ const output = raw.output;
313
+ if (output) {
314
+ const o = output as Record<string, unknown>;
315
+ if (
316
+ "spillThresholdChars" in o &&
317
+ !isPositiveInteger(o.spillThresholdChars)
318
+ ) {
319
+ return `output.spillThresholdChars must be a positive integer; got ${JSON.stringify(
320
+ o.spillThresholdChars,
321
+ )}`;
322
+ }
323
+ if ("spillTailChars" in o && !isNonNegativeInteger(o.spillTailChars)) {
324
+ return `output.spillTailChars must be a non-negative integer; got ${JSON.stringify(
325
+ o.spillTailChars,
326
+ )}`;
327
+ }
328
+ }
329
+
330
+ return null;
331
+ }
332
+
333
+ /** Return a safe positive integer from a possibly malformed config value. */
334
+ function safePositiveInteger(value: unknown, defaultValue: number): number {
335
+ return isPositiveInteger(value) ? (value as number) : defaultValue;
336
+ }
337
+
338
+ /** Return a safe non-negative integer from a possibly malformed config value. */
339
+ function safeNonNegativeInteger(value: unknown, defaultValue: number): number {
340
+ return isNonNegativeInteger(value) ? (value as number) : defaultValue;
341
+ }
342
+
26
343
  /**
27
344
  * Validate the user-editable telemetry block at its boundary. An explicitly
28
345
  * malformed block disables telemetry rather than silently turning it on with
@@ -61,6 +378,14 @@ export interface DelegateConfig {
61
378
  /** Per-agent-type model overrides. Keys are agent names or "default". */
62
379
  [agentType: string]: string | null | undefined;
63
380
  };
381
+ /** Per-agent model/thinking/tools overrides (agent name → override), from
382
+ * delegate.json. Applies to every non-`default` agent, including the
383
+ * built-ins (`scout`/`coder`/`reviewer`) and custom agents. This is the
384
+ * modern form; the legacy `agent` map above still feeds custom agents. */
385
+ agentOverrides?: Record<string, AgentOverride>;
386
+ /** Parent-model-scoped overrides keyed by the parent's exact
387
+ * `provider/model-id`. Win over `agentOverrides` on a key match. */
388
+ agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
64
389
  /** Per-model and per-provider concurrency limits. */
65
390
  concurrency: {
66
391
  /** Default concurrency limit for unspecified models. */
@@ -76,6 +401,9 @@ export interface DelegateConfig {
76
401
  maxAsyncTickets?: number;
77
402
  /** Maximum inactivity before cooperative stall cancellation is requested (0 disables). */
78
403
  stallTimeoutMs?: number;
404
+ /** Operator-only escape hatch for overlapping shared writers. Not exposed
405
+ * through the model-facing tool schema. Default false. */
406
+ allowUnsafeSharedWrites?: boolean;
79
407
  /** Whole-task transient-error retry settings. */
80
408
  retry?: {
81
409
  /** Max whole-task retries after the initial attempt (0 = no retry). */
@@ -99,8 +427,12 @@ export interface DelegateConfig {
99
427
  };
100
428
  }
101
429
 
102
- const DELEGATE_CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
103
- const DELEGATE_CONFIG_PATH = path.join(DELEGATE_CONFIG_DIR, "delegate.json");
430
+ /** Resolved lazily (not at module scope) so the path follows the live
431
+ * `os.homedir()` the test suite swaps homedir via `mock.module`, and an
432
+ * eagerly-bound path would keep reading the developer's real config. */
433
+ function delegateConfigPath(): string {
434
+ return path.join(os.homedir(), ".pi", "agent", "delegate.json");
435
+ }
104
436
 
105
437
  function normalizeProviderExtensions(
106
438
  raw: unknown,
@@ -158,6 +490,7 @@ const DEFAULT_DELEGATE_CONFIG: DelegateConfig = {
158
490
  concurrency: { default: MAX_CONCURRENCY },
159
491
  maxConcurrent: MAX_CONCURRENCY,
160
492
  stallTimeoutMs: 15 * 60 * 1000,
493
+ allowUnsafeSharedWrites: false,
161
494
  retry: {
162
495
  wholeTaskMaxRetries: 3,
163
496
  wholeTaskBaseDelayMs: 1_000,
@@ -181,6 +514,72 @@ let __delegateConfig: DelegateConfig = {
181
514
  };
182
515
  let stallTimeoutOverrideForTesting: number | undefined;
183
516
 
517
+ type ReadConfigResult =
518
+ | { status: "ok"; config: DelegateConfig }
519
+ | { status: "missing" }
520
+ | { status: "error"; error: unknown };
521
+
522
+ /** Read and normalize delegate.json from disk. Distinguishes a missing file
523
+ * (deliberate deletion) from parse/read errors so reload can keep the prior
524
+ * valid snapshot instead of silently installing defaults. */
525
+ function readDelegateConfigFromDisk(): ReadConfigResult {
526
+ try {
527
+ const raw = fs.readFileSync(delegateConfigPath(), "utf-8");
528
+ const parsed = JSON.parse(raw);
529
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
530
+ return {
531
+ status: "error",
532
+ error: new Error("top-level value is not an object"),
533
+ };
534
+ }
535
+ const numericError = validateNumericAndNestedFields(
536
+ parsed as Record<string, unknown>,
537
+ );
538
+ if (numericError) {
539
+ return {
540
+ status: "error",
541
+ error: new Error(numericError),
542
+ };
543
+ }
544
+ // Merge with defaults so new fields are always present
545
+ return {
546
+ status: "ok",
547
+ config: {
548
+ ...DEFAULT_DELEGATE_CONFIG,
549
+ ...parsed,
550
+ agent: { ...DEFAULT_DELEGATE_CONFIG.agent, ...(parsed.agent ?? {}) },
551
+ concurrency: {
552
+ ...DEFAULT_DELEGATE_CONFIG.concurrency,
553
+ ...(parsed.concurrency ?? {}),
554
+ },
555
+ retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
556
+ providerExtensions: normalizeProviderExtensions(
557
+ parsed.providerExtensions,
558
+ ),
559
+ agentOverrides: normalizeAgentOverrides(parsed.agentOverrides),
560
+ agentOverridesByParentModel: normalizeAgentOverridesByParentModel(
561
+ parsed.agentOverridesByParentModel,
562
+ ),
563
+ telemetry: normalizeTelemetryConfig(parsed.telemetry),
564
+ output: {
565
+ ...DEFAULT_DELEGATE_CONFIG.output,
566
+ ...(parsed.output ?? {}),
567
+ },
568
+ } as DelegateConfig,
569
+ };
570
+ } catch (error) {
571
+ if (
572
+ error &&
573
+ typeof error === "object" &&
574
+ "code" in error &&
575
+ (error as NodeJS.ErrnoException).code === "ENOENT"
576
+ ) {
577
+ return { status: "missing" };
578
+ }
579
+ return { status: "error", error };
580
+ }
581
+ }
582
+
184
583
  /** Read delegate config from disk. Returns defaults if file missing or corrupt.
185
584
  *
186
585
  * The returned `providerExtensions` is the *user-only* view — exactly what the
@@ -191,30 +590,63 @@ let stallTimeoutOverrideForTesting: number | undefined;
191
590
  * distinguish "the user listed this" from "this is a shipped default" by
192
591
  * config presence rather than string identity. */
193
592
  export function loadDelegateConfig(): DelegateConfig {
194
- try {
195
- const raw = fs.readFileSync(DELEGATE_CONFIG_PATH, "utf-8");
196
- const parsed = JSON.parse(raw);
197
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
198
- return structuredClone(DEFAULT_DELEGATE_CONFIG);
199
- // Merge with defaults so new fields are always present
200
- return {
201
- ...DEFAULT_DELEGATE_CONFIG,
202
- ...parsed,
203
- agent: { ...DEFAULT_DELEGATE_CONFIG.agent, ...(parsed.agent ?? {}) },
204
- concurrency: {
205
- ...DEFAULT_DELEGATE_CONFIG.concurrency,
206
- ...(parsed.concurrency ?? {}),
207
- },
208
- retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
209
- providerExtensions: normalizeProviderExtensions(
210
- parsed.providerExtensions,
211
- ),
212
- telemetry: normalizeTelemetryConfig(parsed.telemetry),
213
- output: { ...DEFAULT_DELEGATE_CONFIG.output, ...(parsed.output ?? {}) },
214
- } as DelegateConfig;
215
- } catch {
593
+ const result = readDelegateConfigFromDisk();
594
+ if (result.status === "error") {
595
+ console.warn(
596
+ `[delegate] could not load ${DELEGATE_CONFIG_SOURCE}: ${
597
+ result.error instanceof Error
598
+ ? result.error.message
599
+ : String(result.error)
600
+ }; using defaults.`,
601
+ );
216
602
  return structuredClone(DEFAULT_DELEGATE_CONFIG);
217
603
  }
604
+ if (result.status === "missing")
605
+ return structuredClone(DEFAULT_DELEGATE_CONFIG);
606
+ return result.config;
607
+ }
608
+
609
+ /** Clone a config while retaining the null-prototype override maps.
610
+ *
611
+ * `structuredClone` deliberately preserves data, not object prototypes. That
612
+ * is normally useful, but these maps are a trust boundary: an inherited
613
+ * `constructor` or `toString` must not look like a configured agent.
614
+ */
615
+ function cloneDelegateConfig(config: DelegateConfig): DelegateConfig {
616
+ const clone = structuredClone(config);
617
+
618
+ if (clone.agentOverrides) {
619
+ clone.agentOverrides = cloneNullPrototypeMap(clone.agentOverrides);
620
+ }
621
+ if (clone.agentOverridesByParentModel) {
622
+ const parentModels = cloneNullPrototypeMap(
623
+ clone.agentOverridesByParentModel,
624
+ );
625
+ for (const [parentModel, overrides] of Object.entries(parentModels)) {
626
+ parentModels[parentModel] = cloneNullPrototypeMap(overrides);
627
+ }
628
+ clone.agentOverridesByParentModel = parentModels;
629
+ }
630
+
631
+ return clone;
632
+ }
633
+
634
+ function cloneNullPrototypeMap<T>(map: Record<string, T>): Record<string, T> {
635
+ const clone = Object.create(null) as Record<string, T>;
636
+ for (const [key, value] of Object.entries(map)) {
637
+ clone[key] = value;
638
+ }
639
+ return clone;
640
+ }
641
+
642
+ /** Return an immutable snapshot of the current delegate configuration.
643
+ *
644
+ * Async tickets and long-lived task runners capture this at dispatch time so
645
+ * a later `delegate.json` edit cannot retroactively change an in-flight
646
+ * batch's retry limits, stall timeout, output-spill bounds, or provider
647
+ * extension allowlist. */
648
+ export function getDelegateConfigSnapshot(): DelegateConfig {
649
+ return cloneDelegateConfig(__delegateConfig);
218
650
  }
219
651
 
220
652
  /** Initialize module config from disk. Called once at extension load. */
@@ -257,9 +689,21 @@ export function _setStallTimeoutForTesting(
257
689
  export function _setDelegateConfigForTesting(
258
690
  config: Partial<DelegateConfig> = {},
259
691
  ): void {
692
+ const numericError = validateNumericAndNestedFields(
693
+ config as Record<string, unknown>,
694
+ );
695
+ if (numericError) {
696
+ throw new Error(`[delegate] _setDelegateConfigForTesting: ${numericError}`);
697
+ }
260
698
  __delegateConfig = {
261
699
  ...DEFAULT_DELEGATE_CONFIG,
262
700
  ...config,
701
+ maxConcurrent:
702
+ config.maxConcurrent ?? DEFAULT_DELEGATE_CONFIG.maxConcurrent,
703
+ maxAsyncTickets:
704
+ config.maxAsyncTickets ?? DEFAULT_DELEGATE_CONFIG.maxAsyncTickets,
705
+ stallTimeoutMs:
706
+ config.stallTimeoutMs ?? DEFAULT_DELEGATE_CONFIG.stallTimeoutMs,
263
707
  agent: {
264
708
  ...DEFAULT_DELEGATE_CONFIG.agent,
265
709
  ...(config.agent ?? {}),
@@ -273,6 +717,10 @@ export function _setDelegateConfigForTesting(
273
717
  ...(config.retry ?? {}),
274
718
  },
275
719
  providerExtensions: normalizeProviderExtensions(config.providerExtensions),
720
+ agentOverrides: normalizeAgentOverrides(config.agentOverrides),
721
+ agentOverridesByParentModel: normalizeAgentOverridesByParentModel(
722
+ config.agentOverridesByParentModel,
723
+ ),
276
724
  telemetry: normalizeTelemetryConfig(config.telemetry),
277
725
  output: {
278
726
  ...DEFAULT_DELEGATE_CONFIG.output,
@@ -316,6 +764,33 @@ export function getSubagentProviderExtensionsForProvider(
316
764
  : [];
317
765
  }
318
766
 
767
+ /** Stable signature for the provider-scoped extension allowlist that applies
768
+ * to a given model provider. Used as a pool compatibility key so a
769
+ * `delegate.json` edit that revokes or changes an allowlisted extension does
770
+ * not silently reuse a pooled session whose runtime already loaded the old
771
+ * extension code. */
772
+ export function getProviderExtensionSignature(
773
+ provider: string | undefined,
774
+ config: DelegateConfig = __delegateConfig,
775
+ ): string {
776
+ const sources = getSubagentProviderExtensionSourcesForProvider(
777
+ provider,
778
+ config,
779
+ );
780
+ if (sources.length === 0) return "";
781
+ // Preserve both ordering and provenance. Extension order may affect their
782
+ // initialization, and a user re-listing a shipped source changes it from
783
+ // best-effort to required. Treating either change as pool-compatible could
784
+ // reuse an extension-free session without performing the required checks.
785
+ // The signature crosses the session-pool boundary. Keep it opaque so a
786
+ // credential-bearing Git source can never become model-visible through a
787
+ // later pool mismatch. SHA-256 also keeps collision resistance appropriate
788
+ // for a key that decides whether already-loaded executable code may be reused.
789
+ return `sha256:${createHash("sha256")
790
+ .update(JSON.stringify(sources), "utf8")
791
+ .digest("hex")}`;
792
+ }
793
+
319
794
  /** A provider-extension source together with how it entered the config. */
320
795
  export interface ProviderExtensionSource {
321
796
  /** The normalized source string, as the package manager consumes it. */
@@ -370,6 +845,46 @@ export function getSubagentProviderExtensionSourcesForProvider(
370
845
  }));
371
846
  }
372
847
 
848
+ /** Get the effective per-agent overrides from delegate.json
849
+ * (agent name → override), or undefined when unconfigured. */
850
+ export function getAgentOverrides(
851
+ config: DelegateConfig = __delegateConfig,
852
+ ): Record<string, AgentOverride> | undefined {
853
+ return config.agentOverrides;
854
+ }
855
+
856
+ /** Get the parent-model-scoped overrides (`provider/model-id` → agent →
857
+ * override), or undefined when unconfigured. */
858
+ export function getAgentOverridesByParentModel(
859
+ config: DelegateConfig = __delegateConfig,
860
+ ): Record<string, Record<string, AgentOverride>> | undefined {
861
+ return config.agentOverridesByParentModel;
862
+ }
863
+
864
+ /** Re-read delegate.json from disk into the config singleton.
865
+ *
866
+ * Called at the start of every tool execution so user edits to delegate.json
867
+ * become visible between delegate calls without restarting pi. On parse or
868
+ * read errors the existing snapshot is kept and a warning is emitted; a
869
+ * deliberately deleted file falls back to defaults. */
870
+ export function reloadDelegateConfig(): void {
871
+ const result = readDelegateConfigFromDisk();
872
+ if (result.status === "error") {
873
+ console.warn(
874
+ `[delegate] could not reload ${DELEGATE_CONFIG_SOURCE}; keeping current config: ${
875
+ result.error instanceof Error
876
+ ? result.error.message
877
+ : String(result.error)
878
+ }`,
879
+ );
880
+ return;
881
+ }
882
+ __delegateConfig =
883
+ result.status === "missing"
884
+ ? structuredClone(DEFAULT_DELEGATE_CONFIG)
885
+ : result.config;
886
+ }
887
+
373
888
  // ── Config Getters ───────────────────────────────────────────────────────
374
889
 
375
890
  /**
@@ -385,23 +900,33 @@ export function getConcurrencyLimit(
385
900
  ): number {
386
901
  // 1. Per-model
387
902
  const perModel = config.concurrency.models?.[modelKey];
388
- if (perModel != null) return perModel;
903
+ if (isPositiveInteger(perModel)) return perModel;
389
904
  // 2. Per-provider
390
- const provider = modelKey.split("/")[0];
905
+ const provider = modelKey.split("/")[0] ?? modelKey;
391
906
  const perProvider = config.concurrency.providers?.[provider];
392
- if (perProvider != null) return perProvider;
907
+ if (isPositiveInteger(perProvider)) return perProvider;
393
908
  // 3. Default
394
- return config.concurrency.default;
909
+ return isPositiveInteger(config.concurrency.default)
910
+ ? config.concurrency.default
911
+ : MAX_CONCURRENCY;
395
912
  }
396
913
 
397
914
  /** Get the effective max async tickets limit. */
398
- export function getMaxAsyncTickets(): number {
399
- return __delegateConfig.maxAsyncTickets ?? MAX_ASYNC_TICKETS;
915
+ export function getMaxAsyncTickets(
916
+ config: DelegateConfig = __delegateConfig,
917
+ ): number {
918
+ return isPositiveInteger(config.maxAsyncTickets)
919
+ ? config.maxAsyncTickets
920
+ : MAX_ASYNC_TICKETS;
400
921
  }
401
922
 
402
923
  /** Get the hard ceiling on total concurrent agents. */
403
- export function getMaxConcurrent(): number {
404
- return __delegateConfig.maxConcurrent ?? MAX_CONCURRENCY;
924
+ export function getMaxConcurrent(
925
+ config: DelegateConfig = __delegateConfig,
926
+ ): number {
927
+ return isPositiveInteger(config.maxConcurrent)
928
+ ? config.maxConcurrent
929
+ : MAX_CONCURRENCY;
405
930
  }
406
931
 
407
932
  /** Maximum inactivity before cooperative stall cancellation is requested.
@@ -410,36 +935,45 @@ export function getStallTimeoutMs(
410
935
  config: DelegateConfig = __delegateConfig,
411
936
  ): number {
412
937
  const configured = stallTimeoutOverrideForTesting ?? config.stallTimeoutMs;
413
- if (
414
- typeof configured === "number" &&
415
- Number.isFinite(configured) &&
416
- configured >= 0
417
- ) {
418
- return configured;
419
- }
420
- return DEFAULT_DELEGATE_CONFIG.stallTimeoutMs!;
938
+ return isNonNegativeInteger(configured)
939
+ ? configured
940
+ : DEFAULT_DELEGATE_CONFIG.stallTimeoutMs!;
421
941
  }
422
942
 
423
943
  /** Get the max whole-task retries after the initial attempt. */
424
- export function getWholeTaskMaxRetries(): number {
425
- return __delegateConfig.retry?.wholeTaskMaxRetries ?? 3;
944
+ export function getWholeTaskMaxRetries(
945
+ config: DelegateConfig = __delegateConfig,
946
+ ): number {
947
+ return isNonNegativeInteger(config.retry?.wholeTaskMaxRetries)
948
+ ? config.retry!.wholeTaskMaxRetries
949
+ : 3;
426
950
  }
427
951
 
428
952
  /** Get the base delay (ms) for whole-task retry exponential backoff. */
429
- export function getWholeTaskBaseDelayMs(): number {
430
- return __delegateConfig.retry?.wholeTaskBaseDelayMs ?? 1_000;
953
+ export function getWholeTaskBaseDelayMs(
954
+ config: DelegateConfig = __delegateConfig,
955
+ ): number {
956
+ return isNonNegativeInteger(config.retry?.wholeTaskBaseDelayMs)
957
+ ? config.retry!.wholeTaskBaseDelayMs
958
+ : 1_000;
431
959
  }
432
960
 
433
961
  /** Get the output-spill threshold (chars). Over this, final output is spilled. */
434
- export function getOutputSpillThreshold(): number {
435
- return (
436
- __delegateConfig.output?.spillThresholdChars ?? OUTPUT_SPILL_THRESHOLD_CHARS
437
- );
962
+ export function getOutputSpillThreshold(
963
+ config: DelegateConfig = __delegateConfig,
964
+ ): number {
965
+ return isPositiveInteger(config.output?.spillThresholdChars)
966
+ ? config.output!.spillThresholdChars
967
+ : OUTPUT_SPILL_THRESHOLD_CHARS;
438
968
  }
439
969
 
440
970
  /** Get the output-spill tail length (chars) kept in-context when spilled. */
441
- export function getOutputSpillTail(): number {
442
- return __delegateConfig.output?.spillTailChars ?? OUTPUT_SPILL_TAIL_CHARS;
971
+ export function getOutputSpillTail(
972
+ config: DelegateConfig = __delegateConfig,
973
+ ): number {
974
+ return isNonNegativeInteger(config.output?.spillTailChars)
975
+ ? config.output!.spillTailChars
976
+ : OUTPUT_SPILL_TAIL_CHARS;
443
977
  }
444
978
 
445
979
  /**