pi-subagents 0.53.0 → 0.54.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +31 -4
  2. package/README.md +1 -1
  3. package/docs/configuration.md +3 -3
  4. package/docs/models.md +17 -3
  5. package/index.ts +10 -1
  6. package/package.json +1 -1
  7. package/prompts/council.md +10 -7
  8. package/skills/council-mode/SKILL.md +17 -16
  9. package/skills/pi-subagents/SKILL.md +5 -1
  10. package/skills/pi-subagents/references/execution-controls.md +2 -0
  11. package/skills/pi-subagents/references/prompting-and-roles.md +7 -0
  12. package/src/agents/agent-management.ts +85 -5
  13. package/src/agents/agents.ts +67 -24
  14. package/src/api/preflight.ts +7 -2
  15. package/src/extension/doctor.ts +10 -0
  16. package/src/extension/fanout-child.ts +3 -2
  17. package/src/extension/index.ts +4 -3
  18. package/src/extension/public-execution.ts +1 -1
  19. package/src/extension/tool-result.ts +19 -0
  20. package/src/inspectors/herdr/client.ts +3 -3
  21. package/src/runs/background/async-execution.ts +10 -5
  22. package/src/runs/background/async-retention.ts +1 -1
  23. package/src/runs/background/chain-root-attachment.ts +5 -0
  24. package/src/runs/background/result-watcher.ts +4 -3
  25. package/src/runs/background/stale-run-reconciler.ts +3 -0
  26. package/src/runs/background/subagent-runner.ts +26 -2
  27. package/src/runs/background/wait-completions.ts +2 -0
  28. package/src/runs/background/wait-tool.ts +4 -3
  29. package/src/runs/foreground/execution.ts +29 -1
  30. package/src/runs/foreground/subagent-executor.ts +14 -6
  31. package/src/runs/shared/model-fallback.ts +36 -15
  32. package/src/runs/shared/model-scope.ts +106 -39
  33. package/src/runs/shared/pi-args.ts +34 -1
  34. package/src/runs/shared/subagent-prompt-runtime.ts +36 -9
  35. package/src/shared/types.ts +13 -3
  36. package/src/watchdog/permission-arbiter.ts +59 -51
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
17
17
 
18
- export interface ModelScopeConfig {
18
+ export interface ModelScopeRule {
19
19
  enforce?: boolean;
20
20
  /** Reject inherited and fallback models outside the allowlist instead of warning. */
21
21
  strict?: boolean;
@@ -23,6 +23,19 @@ export interface ModelScopeConfig {
23
23
  allow?: string[];
24
24
  }
25
25
 
26
+ export interface ModelScopeConfig extends ModelScopeRule {
27
+ /** Additional restrictions keyed by canonical agent name. */
28
+ agents?: Record<string, ModelScopeRule>;
29
+ }
30
+
31
+ export interface ModelScopeCheckRule extends ModelScopeRule {
32
+ origin?: string;
33
+ }
34
+
35
+ export interface ResolvedModelScope extends ModelScopeCheckRule {
36
+ origin: string;
37
+ }
38
+
26
39
  /** Where a resolved model originated, deciding enforcement severity. */
27
40
  export type ModelSource = "explicit" | "inherited";
28
41
 
@@ -32,6 +45,7 @@ export interface ModelScopeViolation {
32
45
  severity: "warn" | "error";
33
46
  message: string;
34
47
  allowedPatterns: string[];
48
+ origin: string;
35
49
  }
36
50
 
37
51
  function stripThinkingSuffix(model: string): string {
@@ -61,7 +75,7 @@ export function matchesScopePattern(model: string, pattern: string): boolean {
61
75
  */
62
76
  export function checkModelScope(
63
77
  model: string | undefined,
64
- scope: ModelScopeConfig | undefined,
78
+ scope: ModelScopeCheckRule | undefined,
65
79
  source: ModelSource,
66
80
  ): ModelScopeViolation | undefined {
67
81
  if (!model || !scope?.enforce) return undefined;
@@ -71,16 +85,89 @@ export function checkModelScope(
71
85
 
72
86
  const baseModel = stripThinkingSuffix(model);
73
87
  const severity: ModelScopeViolation["severity"] = source === "explicit" || scope.strict === true ? "error" : "warn";
88
+ const origin = scope.origin ?? "modelScope";
74
89
  return {
75
90
  model: baseModel,
76
91
  severity,
77
92
  allowedPatterns: allow,
93
+ origin,
78
94
  message:
79
- `Model '${baseModel}' is outside the configured subagent model scope. ` +
95
+ `Model '${baseModel}' is outside the configured subagent model scope (${origin}). ` +
80
96
  `Allowed patterns: ${allow.join(", ")}.`,
81
97
  };
82
98
  }
83
99
 
100
+ function expandInheritPattern(pattern: string, parentModel: { provider: string; id: string } | undefined): string {
101
+ return pattern === "inherit" && parentModel ? `${parentModel.provider}/${parentModel.id}` : pattern;
102
+ }
103
+
104
+ function assertRecord(value: unknown, field: string, meta: { filePath: string }, expected = "an object"): Record<string, unknown> {
105
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
106
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected ${expected}.`);
107
+ }
108
+ return value as Record<string, unknown>;
109
+ }
110
+
111
+ function parseAllowList(value: unknown, field: string, meta: { filePath: string }): string[] {
112
+ if (!Array.isArray(value)) {
113
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected an array of strings.`);
114
+ }
115
+ const allow: string[] = [];
116
+ for (const entry of value) {
117
+ if (typeof entry !== "string") {
118
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected an array of strings.`);
119
+ }
120
+ const trimmed = entry.trim();
121
+ if (trimmed) allow.push(trimmed);
122
+ }
123
+ if (allow.length === 0) {
124
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected a non-empty array of patterns.`);
125
+ }
126
+ return allow;
127
+ }
128
+
129
+ function parseScopeRule(input: Record<string, unknown>, field: string, meta: { filePath: string }): ModelScopeRule {
130
+ const rule: ModelScopeRule = {};
131
+ if ("enforce" in input) {
132
+ if (typeof input.enforce !== "boolean") throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.enforce'; expected a boolean.`);
133
+ rule.enforce = input.enforce;
134
+ }
135
+ if ("strict" in input) {
136
+ if (typeof input.strict !== "boolean") throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.strict'; expected a boolean.`);
137
+ rule.strict = input.strict;
138
+ }
139
+ if ("allow" in input) rule.allow = parseAllowList(input.allow, `${field}.allow`, meta);
140
+ return rule;
141
+ }
142
+
143
+ /** Resolve the global and matching agent policies into independent launch-time checks. */
144
+ export function resolveModelScopesForAgent(
145
+ config: ModelScopeConfig | undefined,
146
+ agentName: string,
147
+ parentModel: { provider: string; id: string } | undefined,
148
+ ): ResolvedModelScope[] {
149
+ if (!config) return [];
150
+ const scopes: ResolvedModelScope[] = [];
151
+ if (config.allow) {
152
+ scopes.push({
153
+ ...(config.enforce !== undefined ? { enforce: config.enforce } : {}),
154
+ ...(config.strict !== undefined ? { strict: config.strict } : {}),
155
+ allow: config.allow.map((pattern) => expandInheritPattern(pattern, parentModel)),
156
+ origin: "modelScope",
157
+ });
158
+ }
159
+ const agentScope = config.agents?.[agentName];
160
+ if (agentScope?.allow) {
161
+ scopes.push({
162
+ enforce: agentScope.enforce ?? config.enforce,
163
+ strict: agentScope.strict ?? config.strict,
164
+ allow: agentScope.allow.map((pattern) => expandInheritPattern(pattern, parentModel)),
165
+ origin: `modelScope.agents.${agentName}`,
166
+ });
167
+ }
168
+ return scopes;
169
+ }
170
+
84
171
  /**
85
172
  * Validate and normalize a raw `subagents.modelScope` value from settings.
86
173
  * Throws a descriptive error for malformed configs (matching the surrounding
@@ -91,46 +178,26 @@ export function parseModelScopeConfig(
91
178
  meta: { filePath: string },
92
179
  ): ModelScopeConfig | undefined {
93
180
  if (value === undefined) return undefined;
94
- if (!value || typeof value !== "object" || Array.isArray(value)) {
95
- throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope'; expected an object.`);
96
- }
97
-
98
- const input = value as Record<string, unknown>;
99
- const config: ModelScopeConfig = {};
100
-
101
- if ("enforce" in input) {
102
- if (typeof input.enforce !== "boolean") {
103
- throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.enforce'; expected a boolean.`);
104
- }
105
- config.enforce = input.enforce;
106
- }
107
-
108
- if ("strict" in input) {
109
- if (typeof input.strict !== "boolean") {
110
- throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.strict'; expected a boolean.`);
111
- }
112
- config.strict = input.strict;
113
- }
114
-
115
- if ("allow" in input) {
116
- if (!Array.isArray(input.allow)) {
117
- throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
118
- }
119
- const allow: string[] = [];
120
- for (const entry of input.allow) {
121
- if (typeof entry !== "string") {
122
- throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
181
+ const input = assertRecord(value, "modelScope", meta);
182
+ const config: ModelScopeConfig = parseScopeRule(input, "modelScope", meta);
183
+
184
+ if ("agents" in input) {
185
+ const agents: Record<string, ModelScopeRule> = {};
186
+ for (const [rawName, rawScope] of Object.entries(assertRecord(input.agents, "modelScope.agents", meta, "an object keyed by agent name"))) {
187
+ const name = rawName.trim();
188
+ const field = `modelScope.agents.${name}`;
189
+ if (!name) throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.agents' key; expected a non-empty agent name.`);
190
+ const agentInput = assertRecord(rawScope, field, meta);
191
+ if ("agents" in agentInput) {
192
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.agents'; nested agent scopes are not supported.`);
123
193
  }
124
- const trimmed = entry.trim();
125
- if (trimmed) allow.push(trimmed);
126
- }
127
- if (allow.length === 0) {
128
- throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected a non-empty array of patterns.`);
194
+ agents[name] = parseScopeRule(agentInput, field, meta);
129
195
  }
130
- config.allow = allow;
196
+ config.agents = agents;
131
197
  }
132
198
 
133
- if (config.enforce === true && (!config.allow || config.allow.length === 0)) {
199
+ const hasAnyAllow = Boolean(config.allow?.length) || Object.values(config.agents ?? {}).some((scope) => Boolean(scope.allow?.length));
200
+ if (config.enforce === true && !hasAnyAllow) {
134
201
  throw new Error(`Subagent settings in '${meta.filePath}' set modelScope.enforce without a non-empty 'allow' list; supply allowed model patterns or disable enforcement.`);
135
202
  }
136
203
 
@@ -191,6 +191,7 @@ export interface BuildPiArgsInput {
191
191
  export interface BuildPiArgsResult {
192
192
  args: string[];
193
193
  env: Record<string, string | undefined>;
194
+ warnings: string[];
194
195
  tempDir?: string;
195
196
  toolDiagnosticPath?: string;
196
197
  runtimeAcknowledgedExtensionsPath?: string;
@@ -251,6 +252,7 @@ export interface ResolvePiLaunchToolPlanInput {
251
252
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
252
253
  inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
253
254
  agentName?: string;
255
+ permissionRules?: PermissionRules;
254
256
  }
255
257
 
256
258
  export interface PiLaunchToolPlan {
@@ -271,6 +273,13 @@ export interface PiLaunchToolPlan {
271
273
  extensionArgs: string[];
272
274
  disableAmbientExtensions: boolean;
273
275
  capabilityAudit?: SubagentCapabilityAudit;
276
+ /**
277
+ * Non-fatal footguns surfaced during plan resolution (currently: an agent
278
+ * `extensions: []` override, which disables ALL ambient extensions for the
279
+ * child rather than "adding nothing" — see the empty-extensions-override
280
+ * warning below). Callers may log these; they never change behavior.
281
+ */
282
+ warnings: string[];
274
283
  }
275
284
 
276
285
  function extensionIdentifier(value: string): string {
@@ -288,6 +297,10 @@ function boundedExtensionIdentifiers(values: string[]): {
288
297
  };
289
298
  }
290
299
 
300
+ function hasPermissionRules(rules: PermissionRules | undefined): boolean {
301
+ return rules !== undefined && Object.keys(rules).length > 0;
302
+ }
303
+
291
304
  export function projectLaunchResolvedChildExtensions(
292
305
  toolPlan: Pick<
293
306
  PiLaunchToolPlan,
@@ -451,7 +464,9 @@ export function resolvePiLaunchToolPlan(
451
464
  : [];
452
465
  const permSystemExt = capabilityCeiling?.denyExtensions
453
466
  ? undefined
454
- : resolvePermissionSystemExtension();
467
+ : hasPermissionRules(input.permissionRules)
468
+ ? resolvePermissionSystemExtension()
469
+ : undefined;
455
470
  const runtimeExtensions = [
456
471
  PROMPT_RUNTIME_EXTENSION_PATH,
457
472
  ...(fanoutAuthorized ? [FANOUT_CHILD_EXTENSION_PATH] : []),
@@ -460,6 +475,21 @@ export function resolvePiLaunchToolPlan(
460
475
  const disableAmbientExtensions =
461
476
  capabilityCeiling?.denyExtensions === true ||
462
477
  input.extensions !== undefined;
478
+ const warnings: string[] = [];
479
+ // An agent override that sets `extensions: []` reads like "add nothing", but
480
+ // any *defined* extensions list (including an empty one) disables every
481
+ // ambient extension for this child — not just skips adding extras. That
482
+ // silently strips load-bearing ambient extensions (e.g. a model provider
483
+ // extension), so a provider-qualified model pin becomes unresolvable in the
484
+ // child with no error pointing back at the override that caused it.
485
+ if (capabilityCeiling?.denyExtensions !== true && Array.isArray(input.extensions) && input.extensions.length === 0) {
486
+ const agentLabel = input.agentName ? ` for agent '${input.agentName}'` : "";
487
+ warnings.push(
488
+ `extensions: [] override${agentLabel} disables ALL ambient extensions for this child (not just "adds nothing"), `
489
+ + "including any model-provider extension needed to resolve a provider-qualified model. "
490
+ + "List the extensions this child actually needs instead of an empty array.",
491
+ );
492
+ }
463
493
  const configuredExtensions = capabilityCeiling?.denyExtensions
464
494
  ? []
465
495
  : [
@@ -540,6 +570,7 @@ export function resolvePiLaunchToolPlan(
540
570
  configuredExtensions,
541
571
  extensionArgs,
542
572
  disableAmbientExtensions,
573
+ warnings,
543
574
  ...(capabilityAudit ? { capabilityAudit } : {}),
544
575
  };
545
576
  }
@@ -587,6 +618,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
587
618
  process.env[SUBAGENT_CAPABILITY_CEILING_ENV],
588
619
  ),
589
620
  agentName: input.childAgentName,
621
+ permissionRules: input.permissionRules,
590
622
  });
591
623
  if (toolPlan.explicitToolAllowlist) {
592
624
  args.push(
@@ -829,6 +861,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
829
861
  return {
830
862
  args,
831
863
  env,
864
+ warnings: toolPlan.warnings,
832
865
  tempDir,
833
866
  toolDiagnosticPath,
834
867
  runtimeAcknowledgedExtensionsPath,
@@ -21,7 +21,7 @@ import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../
21
21
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
22
22
  import { resolveWatchPath } from "../../shared/utils.ts";
23
23
  import { registerChildWatchdog } from "../../watchdog/register-child.ts";
24
- import { CHILD_WATCHDOG_CONFIG_ENV } from "../../watchdog/child-status.ts";
24
+ import { CHILD_WATCHDOG_CONFIG_ENV, decodeChildWatchdogConfig } from "../../watchdog/child-status.ts";
25
25
  import { requestWatchdogPermission, type WatchdogPermissionRequest, type WatchdogPermissionResult } from "../../watchdog/permission-arbiter.ts";
26
26
  import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
27
27
  import { resolveWaitToolConfig } from "../background/wait-config.ts";
@@ -296,14 +296,41 @@ export function registerPermissionGate(
296
296
  const decision = permissionDecision(rules, toolName);
297
297
  if (decision === "allow") return undefined;
298
298
  if (decision === "deny") return { block: true, reason: `Blocked by pi-subagents permission rule: '${toolName}' is denied.` };
299
- const result = await requestPermission({
300
- ctx,
301
- toolName,
302
- args: event.input ?? {},
303
- rawWatchdogConfig: process.env[CHILD_WATCHDOG_CONFIG_ENV],
304
- auditPath: process.env[PERMISSION_AUDIT_PATH_ENV],
305
- ...(ctx.signal ? { signal: ctx.signal } : {}),
306
- });
299
+ const rawWatchdogConfig = process.env[CHILD_WATCHDOG_CONFIG_ENV];
300
+ let timeoutMs = 30_000;
301
+ try {
302
+ timeoutMs = decodeChildWatchdogConfig(rawWatchdogConfig)?.agentEndTimeoutMs ?? timeoutMs;
303
+ } catch {
304
+ // The arbiter reports invalid configuration with the concrete decode error.
305
+ }
306
+ if (ctx.signal?.aborted) return { block: true, reason: "Blocked by pi-subagents permission rule: Watchdog permission decision was cancelled." };
307
+ let timeout: ReturnType<typeof setTimeout> | undefined;
308
+ let abort: (() => void) | undefined;
309
+ let result: WatchdogPermissionResult;
310
+ try {
311
+ result = await Promise.race([
312
+ requestPermission({
313
+ ctx,
314
+ toolName,
315
+ args: event.input ?? {},
316
+ rawWatchdogConfig,
317
+ auditPath: process.env[PERMISSION_AUDIT_PATH_ENV],
318
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
319
+ }),
320
+ new Promise<WatchdogPermissionResult>((resolve) => {
321
+ if (!ctx.signal) return;
322
+ abort = () => resolve({ approved: false, reason: "Watchdog permission decision was cancelled.", source: "watchdog" });
323
+ ctx.signal.addEventListener("abort", abort, { once: true });
324
+ }),
325
+ new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error(`Watchdog permission decision timed out after ${timeoutMs}ms.`)), timeoutMs); }),
326
+ ]);
327
+ } catch (error) {
328
+ const reason = error instanceof Error ? error.message : String(error);
329
+ return { block: true, reason: `Blocked by pi-subagents permission rule: Watchdog permission arbiter failed closed: ${reason}` };
330
+ } finally {
331
+ if (timeout) clearTimeout(timeout);
332
+ if (abort) ctx.signal?.removeEventListener("abort", abort);
333
+ }
307
334
  if (result.approved) return undefined;
308
335
  return { block: true, reason: `Blocked by pi-subagents permission rule: ${result.reason}` };
309
336
  });
@@ -8,7 +8,7 @@ import type { Message } from "@earendil-works/pi-ai";
8
8
  import type { AgentConfig } from "../agents/agents.ts";
9
9
  import type { FSWatcher } from "node:fs";
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import type { ModelScopeConfig } from "../runs/shared/model-scope.ts";
11
+ import type { ModelScopeRule } from "../runs/shared/model-scope.ts";
12
12
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../runs/shared/capability-ceiling.ts";
13
13
  import type { AuthorityPolicyConfig } from "../policy/authority.ts";
14
14
  import type { GlobalMissionIndexRecord, MissionRecord, MissionStoreConfig } from "../missions/types.ts";
@@ -957,6 +957,13 @@ export interface SingleResult {
957
957
  modelAttempts?: ModelAttempt[];
958
958
  controlEvents?: ControlEvent[];
959
959
  error?: string;
960
+ /**
961
+ * True when the dispatch failed because the input exceeded the model's
962
+ * context window. The model fallback loop stops immediately (retrying the
963
+ * same input on another model cannot succeed). Callers should treat this as
964
+ * a signal to reduce input size or re-decompose the task.
965
+ */
966
+ contextOverflow?: boolean;
960
967
  protocolError?: ProtocolOutputLimit;
961
968
  sessionFile?: string;
962
969
  skills?: string[];
@@ -1022,6 +1029,7 @@ export interface WaitCompletionChild {
1022
1029
  outputState?: SubagentOutputState;
1023
1030
  error?: string;
1024
1031
  model?: string;
1032
+ contextOverflow?: boolean;
1025
1033
  artifactPaths?: Partial<ArtifactPaths>;
1026
1034
  }
1027
1035
 
@@ -1496,6 +1504,8 @@ export interface AsyncStatus {
1496
1504
  thinking?: string;
1497
1505
  attemptedModels?: string[];
1498
1506
  modelAttempts?: ModelAttempt[];
1507
+ /** True when the child input exceeded the model context window. */
1508
+ contextOverflow?: boolean;
1499
1509
  totalCost?: CostSummary;
1500
1510
  steering?: SteeringStatus;
1501
1511
  error?: string;
@@ -1890,7 +1900,7 @@ export interface RunSyncOptions {
1890
1900
  /** Current parent-session provider to prefer for ambiguous bare model ids */
1891
1901
  preferredModelProvider?: string;
1892
1902
  /** Optional subagent model-scope enforcement for fallback candidates */
1893
- modelScope?: ModelScopeConfig;
1903
+ modelScope?: ModelScopeRule | ModelScopeRule[];
1894
1904
  /** Skills to make available (overrides agent default if provided) */
1895
1905
  skills?: string[];
1896
1906
  structuredOutput?: {
@@ -2054,7 +2064,7 @@ export interface ExtensionConfig {
2054
2064
  /** Artifact cleanup retention. Set cleanupDays to 0 to disable cleanup. */
2055
2065
  artifactConfig?: Pick<ArtifactConfig, "cleanupDays">;
2056
2066
  intercomBridge?: IntercomBridgeConfig;
2057
- /** Control how slow result-index scans are logged. Defaults to \"all\".
2067
+ /** Control how slow result-index scans are logged. Defaults to \"activity\".
2058
2068
  * - \"all\": log every slow scan, including scans that find nothing.
2059
2069
  * - \"activity\": log only slow scans that found or scheduled work. Silences
2060
2070
  * the periodic healthy rescan that inspects zero files while no async runs
@@ -45,7 +45,11 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
45
45
  const createdAt = Date.now();
46
46
  const auditBase = { type: "permission.request", createdAt, toolName: request.toolName, preview, matchedRule: "ask", decisionSource: "watchdog" };
47
47
  appendPermissionAudit(request.auditPath, auditBase);
48
+ let completed = false;
48
49
  const finish = (approved: boolean, reason: string, decision: string): WatchdogPermissionResult => {
50
+ const concise = conciseReason(reason);
51
+ if (completed) return { approved, reason: concise, source: "watchdog" };
52
+ completed = true;
49
53
  appendPermissionAudit(request.auditPath, {
50
54
  type: "permission.decision",
51
55
  createdAt: Date.now(),
@@ -54,9 +58,9 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
54
58
  decision,
55
59
  approved,
56
60
  decisionSource: "watchdog",
57
- reason: conciseReason(reason),
61
+ reason: concise,
58
62
  });
59
- return { approved, reason: conciseReason(reason), source: "watchdog" };
63
+ return { approved, reason: concise, source: "watchdog" };
60
64
  };
61
65
 
62
66
  let childConfig;
@@ -84,61 +88,65 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
84
88
 
85
89
  let timeout: ReturnType<typeof setTimeout> | undefined;
86
90
  let agent: Agent | undefined;
91
+ let abort: (() => void) | undefined;
87
92
  try {
88
- const config = childResolvedConfig(childConfig);
89
- const selection = await resolveWatchdogReviewModel(request.ctx, config);
90
- const auth = selection.auth;
91
- const registeredProvider = (request.ctx.modelRegistry as {
92
- getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
93
- }).getRegisteredProviderConfig?.(selection.model.provider);
94
- const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
95
- ? registeredProvider.streamSimple
96
- : streamSimple);
97
- const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
98
- ...streamOptions,
99
- ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
100
- env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
101
- headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
102
- });
103
- agent = new Agent({
104
- initialState: {
105
- systemPrompt: [
106
- "You are the pi-subagents watchdog permission arbiter.",
107
- "Decide only whether this exact non-bash child tool call should proceed.",
108
- "Call watchdog_permission_decision exactly once with approve or deny and a concise reason.",
109
- "Deny when uncertain. Do not produce freeform advice or ask the parent orchestrator.",
110
- ].join("\n"),
111
- model: selection.model,
112
- thinkingLevel: selection.thinkingLevel,
113
- tools: [tool],
114
- },
115
- convertToLlm,
116
- ...agentStreamOptions(streamFn),
117
- getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
118
- beforeToolCall: async ({ toolCall }) => toolCall.name === tool.name ? undefined : { block: true, reason: `Permission arbiter tool '${toolCall.name}' is not allowed.` },
119
- toolExecution: "sequential",
120
- });
121
- const abort = () => agent?.abort();
122
- request.signal?.addEventListener("abort", abort, { once: true });
123
- request.ctx.signal?.addEventListener("abort", abort, { once: true });
124
- try {
125
- const prompt = `Tool: ${request.toolName}\nRedacted arguments: ${preview}`;
126
- await Promise.race([
127
- agent.prompt(prompt),
128
- new Promise<never>((_, reject) => { timeout = setTimeout(() => { agent?.abort(); reject(new Error("Watchdog permission decision timed out.")); }, childConfig.agentEndTimeoutMs); }),
129
- ]);
130
- } finally {
131
- request.signal?.removeEventListener("abort", abort);
132
- request.ctx.signal?.removeEventListener("abort", abort);
133
- }
134
- if (!decision) return finish(false, "Watchdog permission arbiter returned no decision.", "malformed");
135
- const approved = decision.decision === "approve";
136
- return finish(approved, decision.reason, decision.decision);
93
+ const run = async (): Promise<WatchdogPermissionResult> => {
94
+ const config = childResolvedConfig(childConfig);
95
+ const selection = await resolveWatchdogReviewModel(request.ctx, config);
96
+ const auth = selection.auth;
97
+ const registeredProvider = (request.ctx.modelRegistry as {
98
+ getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
99
+ }).getRegisteredProviderConfig?.(selection.model.provider);
100
+ const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
101
+ ? registeredProvider.streamSimple
102
+ : streamSimple);
103
+ const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
104
+ ...streamOptions,
105
+ ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
106
+ env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
107
+ headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
108
+ });
109
+ agent = new Agent({
110
+ initialState: {
111
+ systemPrompt: [
112
+ "You are the pi-subagents watchdog permission arbiter.",
113
+ "Decide only whether this exact non-bash child tool call should proceed.",
114
+ "Call watchdog_permission_decision exactly once with approve or deny and a concise reason.",
115
+ "Deny when uncertain. Do not produce freeform advice or ask the parent orchestrator.",
116
+ ].join("\n"),
117
+ model: selection.model,
118
+ thinkingLevel: selection.thinkingLevel,
119
+ tools: [tool],
120
+ },
121
+ convertToLlm,
122
+ ...agentStreamOptions(streamFn),
123
+ getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
124
+ beforeToolCall: async ({ toolCall }) => toolCall.name === tool.name ? undefined : { block: true, reason: `Permission arbiter tool '${toolCall.name}' is not allowed.` },
125
+ toolExecution: "sequential",
126
+ });
127
+ await agent.prompt(`Tool: ${request.toolName}\nRedacted arguments: ${preview}`);
128
+ if (!decision) return finish(false, "Watchdog permission arbiter returned no decision.", "malformed");
129
+ const approved = decision.decision === "approve";
130
+ return finish(approved, decision.reason, decision.decision);
131
+ };
132
+ return await Promise.race([
133
+ run(),
134
+ new Promise<WatchdogPermissionResult>((resolve) => { timeout = setTimeout(() => { agent?.abort(); resolve(finish(false, "Watchdog permission decision timed out.", "timeout")); }, childConfig.agentEndTimeoutMs); }),
135
+ new Promise<WatchdogPermissionResult>((resolve) => {
136
+ abort = () => { agent?.abort(); resolve(finish(false, "Watchdog permission decision was cancelled.", "cancelled")); };
137
+ request.signal?.addEventListener("abort", abort, { once: true });
138
+ request.ctx.signal?.addEventListener("abort", abort, { once: true });
139
+ }),
140
+ ]);
137
141
  } catch (error) {
138
142
  const reason = error instanceof Error ? error.message : String(error);
139
143
  return finish(false, `Watchdog permission arbiter failed closed: ${reason}`, reason.includes("timed out") ? "timeout" : "error");
140
144
  } finally {
141
145
  if (timeout) clearTimeout(timeout);
146
+ if (abort) {
147
+ request.signal?.removeEventListener("abort", abort);
148
+ request.ctx.signal?.removeEventListener("abort", abort);
149
+ }
142
150
  }
143
151
  };
144
152
  }