pi-subagents 0.52.1 → 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 (67) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +4 -0
  3. package/docs/configuration.md +12 -2
  4. package/docs/extension-api.md +3 -1
  5. package/docs/models.md +17 -3
  6. package/docs/workflows.md +2 -0
  7. package/index.ts +10 -1
  8. package/package.json +2 -1
  9. package/prompts/council.md +51 -0
  10. package/skills/council-mode/SKILL.md +231 -0
  11. package/skills/pi-subagents/SKILL.md +5 -1
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
  13. package/skills/pi-subagents/references/execution-controls.md +13 -0
  14. package/skills/pi-subagents/references/prompting-and-roles.md +7 -0
  15. package/src/agents/agent-management.ts +107 -8
  16. package/src/agents/agent-serializer.ts +2 -0
  17. package/src/agents/agents.ts +96 -37
  18. package/src/agents/builtin-names.ts +9 -0
  19. package/src/agents/runtime-agent-registry.ts +418 -0
  20. package/src/api/agents.ts +7 -0
  21. package/src/api/preflight.ts +8 -3
  22. package/src/extension/config.ts +3 -0
  23. package/src/extension/doctor.ts +11 -0
  24. package/src/extension/fanout-child.ts +3 -2
  25. package/src/extension/index.ts +20 -4
  26. package/src/extension/public-execution.ts +1 -1
  27. package/src/extension/rpc.ts +41 -1
  28. package/src/extension/schemas.ts +6 -3
  29. package/src/extension/tool-description.ts +2 -2
  30. package/src/extension/tool-result.ts +19 -0
  31. package/src/inspectors/herdr/client.ts +3 -3
  32. package/src/runs/background/async-execution.ts +12 -6
  33. package/src/runs/background/async-job-tracker.ts +4 -3
  34. package/src/runs/background/async-resume.ts +2 -1
  35. package/src/runs/background/async-retention.ts +1 -1
  36. package/src/runs/background/async-status-snapshot.ts +14 -5
  37. package/src/runs/background/auto-drain.ts +1 -0
  38. package/src/runs/background/chain-root-attachment.ts +5 -0
  39. package/src/runs/background/result-watcher.ts +9 -0
  40. package/src/runs/background/stale-run-reconciler.ts +3 -0
  41. package/src/runs/background/subagent-runner.ts +32 -5
  42. package/src/runs/background/subagent-wait.ts +9 -5
  43. package/src/runs/background/terminal-run-index.ts +15 -6
  44. package/src/runs/background/wait-completions.ts +2 -0
  45. package/src/runs/background/wait-tool.ts +5 -3
  46. package/src/runs/foreground/execution.ts +34 -2
  47. package/src/runs/foreground/subagent-executor.ts +196 -52
  48. package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
  49. package/src/runs/shared/acceptance.ts +44 -1
  50. package/src/runs/shared/model-exclusions.ts +242 -0
  51. package/src/runs/shared/model-fallback.ts +72 -16
  52. package/src/runs/shared/model-scope.ts +106 -39
  53. package/src/runs/shared/pi-args.ts +34 -1
  54. package/src/runs/shared/subagent-control.ts +25 -3
  55. package/src/runs/shared/subagent-prompt-runtime.ts +36 -9
  56. package/src/shared/fork-context.ts +17 -1
  57. package/src/shared/model-info.ts +20 -0
  58. package/src/shared/settings.ts +2 -2
  59. package/src/shared/types.ts +47 -2
  60. package/src/slash/slash-commands.ts +20 -6
  61. package/src/slash/slash-live-state.ts +3 -3
  62. package/src/tui/fleet-status.ts +86 -1
  63. package/src/tui/fleet.ts +55 -2
  64. package/src/tui/render.ts +73 -3
  65. package/src/watchdog/permission-arbiter.ts +59 -51
  66. package/src/workflows/scripted-workflow.ts +100 -12
  67. package/src/workflows/workflow-receipt.ts +140 -0
@@ -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,
@@ -17,6 +17,7 @@ const DEFAULT_NOTIFY_ON: ControlEventType[] = ["active_long_running", "needs_att
17
17
  export const DEFAULT_CONTROL_CONFIG: ResolvedControlConfig = {
18
18
  enabled: true,
19
19
  needsAttentionAfterMs: 60_000,
20
+ needsAttentionAfterMsIsExplicit: false,
20
21
  activeNoticeAfterMs: 240_000,
21
22
  failedToolAttemptsBeforeAttention: 3,
22
23
  notifyOn: DEFAULT_NOTIFY_ON,
@@ -42,9 +43,13 @@ export function resolveControlConfig(
42
43
  override?: ControlConfig,
43
44
  ): ResolvedControlConfig {
44
45
  const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
45
- const needsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs)
46
- ?? parsePositiveInt(globalConfig?.needsAttentionAfterMs)
46
+ const overrideNeedsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs);
47
+ const globalNeedsAttentionAfterMs = parsePositiveInt(globalConfig?.needsAttentionAfterMs);
48
+ const needsAttentionAfterMs = overrideNeedsAttentionAfterMs
49
+ ?? globalNeedsAttentionAfterMs
47
50
  ?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
51
+ const needsAttentionAfterMsIsExplicit = overrideNeedsAttentionAfterMs !== undefined
52
+ || globalNeedsAttentionAfterMs !== undefined;
48
53
  const activeNoticeAfterMs = parsePositiveInt(override?.activeNoticeAfterMs)
49
54
  ?? parsePositiveInt(globalConfig?.activeNoticeAfterMs)
50
55
  ?? DEFAULT_CONTROL_CONFIG.activeNoticeAfterMs;
@@ -64,6 +69,7 @@ export function resolveControlConfig(
64
69
  return {
65
70
  enabled,
66
71
  needsAttentionAfterMs,
72
+ needsAttentionAfterMsIsExplicit,
67
73
  activeNoticeAfterMs,
68
74
  activeNoticeAfterTurns,
69
75
  activeNoticeAfterTokens,
@@ -73,18 +79,34 @@ export function resolveControlConfig(
73
79
  };
74
80
  }
75
81
 
82
+ function scaledNeedsAttentionAfterMs(config: ResolvedControlConfig, thinking?: string | false): number {
83
+ if (config.needsAttentionAfterMsIsExplicit !== false) return config.needsAttentionAfterMs;
84
+ switch (thinking) {
85
+ case "medium":
86
+ return config.needsAttentionAfterMs * 2;
87
+ case "high":
88
+ return config.needsAttentionAfterMs * 5;
89
+ case "xhigh":
90
+ case "max":
91
+ return config.needsAttentionAfterMs * 10;
92
+ default:
93
+ return config.needsAttentionAfterMs;
94
+ }
95
+ }
96
+
76
97
  export function deriveActivityState(input: {
77
98
  config: ResolvedControlConfig;
78
99
  startedAt: number;
79
100
  lastActivityAt?: number;
80
101
  currentTool?: string;
102
+ thinking?: string | false;
81
103
  now?: number;
82
104
  }): ActivityState | undefined {
83
105
  if (!input.config.enabled || input.currentTool) return undefined;
84
106
  const now = input.now ?? Date.now();
85
107
  const lastActivity = input.lastActivityAt ?? input.startedAt;
86
108
  const ageMs = Math.max(0, now - lastActivity);
87
- return ageMs > input.config.needsAttentionAfterMs ? "needs_attention" : undefined;
109
+ return ageMs > scaledNeedsAttentionAfterMs(input.config, input.thinking) ? "needs_attention" : undefined;
88
110
  }
89
111
 
90
112
  export function shouldEmitOpenToolAttention(input: {
@@ -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
  });
@@ -208,7 +208,23 @@ export function createForkContextResolver(
208
208
  const openSession = options.openSession
209
209
  ?? sessionManager.openSession
210
210
  ?? ((file: string, dir?: string) => SessionManager.open(file, dir));
211
- const sessionDir = sessionManager.getSessionDir?.();
211
+ // Fork files must not land in the parent's top-level session directory.
212
+ // Pi's recent-session discovery (`pi -c` → findMostRecentSession) is
213
+ // non-recursive and picks the largest-mtime *.jsonl in that directory, so
214
+ // a still-running forked subagent — which keeps appending after the parent
215
+ // went idle — would hijack the next `pi -c` away from the conversation the
216
+ // user actually left. Nesting fork sessions in a per-parent directory keeps
217
+ // them invisible to that discovery; the `parentSession` header still
218
+ // records the tree relationship. The directory mirrors
219
+ // getSubagentSessionRoot() plus a "forks" level so fork files never sit
220
+ // loose next to run-N/ result directories. Derived from the file path
221
+ // rather than getSessionDir() so it also works when the manager cannot
222
+ // report its directory.
223
+ const sessionDir = path.join(
224
+ path.dirname(parentSessionFile),
225
+ path.basename(parentSessionFile, ".jsonl"),
226
+ "forks",
227
+ );
212
228
  const cachedResolutions = new Map<number, ForkContextResolution>();
213
229
 
214
230
  const resolveFork = (index = 0): ForkContextResolution => {
@@ -1,3 +1,5 @@
1
+ import type { ModelCost } from "@earendil-works/pi-ai";
2
+
1
3
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
2
4
  export type ThinkingLevel = typeof THINKING_LEVELS[number];
3
5
  export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
@@ -9,6 +11,14 @@ export interface ModelInfo {
9
11
  api?: string;
10
12
  reasoning?: boolean;
11
13
  thinkingLevelMap?: ThinkingLevelMap;
14
+ /** Context window in tokens, when the host model registry reports one. */
15
+ contextWindow?: number;
16
+ /** Maximum output tokens, when the host model registry reports one. */
17
+ maxTokens?: number;
18
+ /** Input modalities reported by the registry, e.g. ["text", "image"]. */
19
+ input?: string[];
20
+ /** Per-token pricing from the registry (USD per 1M tokens). */
21
+ cost?: ModelCost;
12
22
  }
13
23
 
14
24
  interface RegistryModelLike {
@@ -17,6 +27,10 @@ interface RegistryModelLike {
17
27
  api?: string;
18
28
  reasoning?: boolean;
19
29
  thinkingLevelMap?: ThinkingLevelMap;
30
+ contextWindow?: number;
31
+ maxTokens?: number;
32
+ input?: string[];
33
+ cost?: ModelCost;
20
34
  }
21
35
 
22
36
  export function toModelInfo(model: RegistryModelLike): ModelInfo {
@@ -27,6 +41,12 @@ export function toModelInfo(model: RegistryModelLike): ModelInfo {
27
41
  api: model.api,
28
42
  reasoning: model.reasoning,
29
43
  thinkingLevelMap: model.thinkingLevelMap,
44
+ ...(typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
45
+ ...(typeof model.maxTokens === "number" && Number.isFinite(model.maxTokens) && model.maxTokens > 0 ? { maxTokens: model.maxTokens } : {}),
46
+ ...(Array.isArray(model.input) && model.input.length > 0 ? { input: [...model.input] } : {}),
47
+ ...(model.cost && Number.isFinite(model.cost.input) && Number.isFinite(model.cost.output)
48
+ ? { cost: { ...model.cost, ...(model.cost.tiers ? { tiers: model.cost.tiers.map((tier) => ({ ...tier })) } : {}) } }
49
+ : {}),
30
50
  };
31
51
  }
32
52
 
@@ -282,7 +282,7 @@ export function resolveStepBehavior(
282
282
  }
283
283
  }
284
284
 
285
- const outputMode = stepOverrides.outputMode ?? "inline";
285
+ const outputMode = stepOverrides.outputMode ?? agentConfig.outputMode ?? "inline";
286
286
  const model = stepOverrides.model ?? agentConfig.model;
287
287
  return { output, outputMode, reads, progress, skills, model };
288
288
  }
@@ -465,7 +465,7 @@ export function resolveParallelBehaviors(
465
465
  }
466
466
  }
467
467
 
468
- const outputMode = task.outputMode ?? "inline";
468
+ const outputMode = task.outputMode ?? config.outputMode ?? "inline";
469
469
  const model = task.model ?? config.model;
470
470
  return { output, outputMode, reads, progress, skills, model };
471
471
  });
@@ -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";
@@ -71,6 +71,29 @@ export interface WorkflowGraphSnapshot {
71
71
  currentNodeId?: string;
72
72
  }
73
73
 
74
+ export type WorkflowReceiptState = "complete" | "failed" | "paused" | "stopped";
75
+
76
+ type WorkflowReceiptEntryResumability =
77
+ | { latestRunId: string; resumability: { state: "resumable" } }
78
+ | { latestRunId?: string; resumability: { state: "not-resumable"; reason: string } };
79
+
80
+ export type WorkflowReceiptEntry = WorkflowReceiptEntryResumability & {
81
+ key: string;
82
+ agent?: string;
83
+ requestedContext?: "fresh" | "fork";
84
+ resolvedContext?: "fresh" | "fork" | "mixed";
85
+ outputReference?: string;
86
+ continuation: { runIds: string[] };
87
+ };
88
+
89
+ export interface WorkflowReceipt {
90
+ version: 1;
91
+ workflowRunId: string;
92
+ state: WorkflowReceiptState;
93
+ createdAt: number;
94
+ entries: Record<string, WorkflowReceiptEntry>;
95
+ }
96
+
74
97
  export interface SavedOutputReference {
75
98
  path: string;
76
99
  bytes: number;
@@ -161,6 +184,7 @@ export interface ControlConfig {
161
184
  export interface ResolvedControlConfig {
162
185
  enabled: boolean;
163
186
  needsAttentionAfterMs: number;
187
+ needsAttentionAfterMsIsExplicit?: boolean;
164
188
  activeNoticeAfterMs: number;
165
189
  activeNoticeAfterTurns?: number;
166
190
  activeNoticeAfterTokens?: number;
@@ -529,6 +553,8 @@ export interface SteeringRecoveryDescriptor {
529
553
  structuredOutputSchema?: JsonSchemaObject;
530
554
  acceptance?: AcceptanceInput;
531
555
  controlConfig?: ResolvedControlConfig;
556
+ /** Resolved launch context for this async child. */
557
+ context?: "fresh" | "fork";
532
558
  /** Raw per-run bridge override. Omitted descriptors continue to use global config. */
533
559
  intercomBridge?: IntercomBridgeConfig;
534
560
  absoluteDeadlineAt?: number;
@@ -931,6 +957,13 @@ export interface SingleResult {
931
957
  modelAttempts?: ModelAttempt[];
932
958
  controlEvents?: ControlEvent[];
933
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;
934
967
  protocolError?: ProtocolOutputLimit;
935
968
  sessionFile?: string;
936
969
  skills?: string[];
@@ -996,6 +1029,7 @@ export interface WaitCompletionChild {
996
1029
  outputState?: SubagentOutputState;
997
1030
  error?: string;
998
1031
  model?: string;
1032
+ contextOverflow?: boolean;
999
1033
  artifactPaths?: Partial<ArtifactPaths>;
1000
1034
  }
1001
1035
 
@@ -1099,6 +1133,8 @@ export interface Details {
1099
1133
  }>;
1100
1134
  emits: unknown[];
1101
1135
  console: Array<{ level: "log" | "info" | "warn" | "error"; text: string }>;
1136
+ /** Terminal keyed child receipt. Async workflows also persist it beside status.json. */
1137
+ receipt?: WorkflowReceipt;
1102
1138
  };
1103
1139
  chatProgress?: {
1104
1140
  mode: "off" | "live-card";
@@ -1468,6 +1504,8 @@ export interface AsyncStatus {
1468
1504
  thinking?: string;
1469
1505
  attemptedModels?: string[];
1470
1506
  modelAttempts?: ModelAttempt[];
1507
+ /** True when the child input exceeded the model context window. */
1508
+ contextOverflow?: boolean;
1471
1509
  totalCost?: CostSummary;
1472
1510
  steering?: SteeringStatus;
1473
1511
  error?: string;
@@ -1862,7 +1900,7 @@ export interface RunSyncOptions {
1862
1900
  /** Current parent-session provider to prefer for ambiguous bare model ids */
1863
1901
  preferredModelProvider?: string;
1864
1902
  /** Optional subagent model-scope enforcement for fallback candidates */
1865
- modelScope?: ModelScopeConfig;
1903
+ modelScope?: ModelScopeRule | ModelScopeRule[];
1866
1904
  /** Skills to make available (overrides agent default if provided) */
1867
1905
  skills?: string[];
1868
1906
  structuredOutput?: {
@@ -2026,6 +2064,13 @@ export interface ExtensionConfig {
2026
2064
  /** Artifact cleanup retention. Set cleanupDays to 0 to disable cleanup. */
2027
2065
  artifactConfig?: Pick<ArtifactConfig, "cleanupDays">;
2028
2066
  intercomBridge?: IntercomBridgeConfig;
2067
+ /** Control how slow result-index scans are logged. Defaults to \"activity\".
2068
+ * - \"all\": log every slow scan, including scans that find nothing.
2069
+ * - \"activity\": log only slow scans that found or scheduled work. Silences
2070
+ * the periodic healthy rescan that inspects zero files while no async runs
2071
+ * are pending, which otherwise spams the session transcript.
2072
+ * - \"off\": never log slow result-index scans. */
2073
+ resultScanLogging?: "all" | "activity" | "off";
2029
2074
  proactiveSkillSubagents?: ProactiveSkillSubagentsConfig | false;
2030
2075
  scheduledRuns?: ScheduledRunsConfig;
2031
2076
  /** Durable mission behavior. Missions are automatic by default; set enabled:false to disable auto-create. Explicit mission actions/fields still work. */