pi-subagents 0.53.0 → 0.55.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 (76) hide show
  1. package/CHANGELOG.md +66 -5
  2. package/README.md +1 -1
  3. package/agents/reviewer.md +12 -2
  4. package/docs/agents.md +4 -4
  5. package/docs/configuration.md +6 -6
  6. package/docs/extension-api.md +14 -4
  7. package/docs/models.md +41 -6
  8. package/docs/observability.md +14 -3
  9. package/docs/tool-reference.md +21 -6
  10. package/docs/workflows.md +7 -1
  11. package/index.ts +10 -1
  12. package/package.json +1 -1
  13. package/prompts/council.md +19 -7
  14. package/prompts/parallel-review.md +5 -1
  15. package/prompts/review-loop.md +9 -5
  16. package/skills/council-mode/SKILL.md +50 -26
  17. package/skills/pi-subagents/SKILL.md +5 -1
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
  19. package/skills/pi-subagents/references/execution-controls.md +13 -4
  20. package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
  21. package/skills/pi-subagents/references/prompting-and-roles.md +14 -7
  22. package/src/agents/agent-management.ts +115 -14
  23. package/src/agents/agent-serializer.ts +2 -2
  24. package/src/agents/agents.ts +195 -47
  25. package/src/api/external-job-provider.ts +10 -1
  26. package/src/api/preflight.ts +33 -19
  27. package/src/api/project-panes.ts +2 -0
  28. package/src/extension/doctor.ts +10 -0
  29. package/src/extension/fanout-child.ts +3 -2
  30. package/src/extension/index.ts +24 -4
  31. package/src/extension/public-execution.ts +12 -9
  32. package/src/extension/rpc.ts +77 -3
  33. package/src/extension/schemas.ts +2 -1
  34. package/src/extension/tool-description.ts +9 -6
  35. package/src/extension/tool-result.ts +19 -0
  36. package/src/inspectors/herdr/client.ts +3 -3
  37. package/src/inspectors/herdr/focus.ts +55 -0
  38. package/src/inspectors/herdr/project-panes.ts +228 -44
  39. package/src/integrations/herdr-status.ts +26 -4
  40. package/src/runs/background/async-execution.ts +112 -13
  41. package/src/runs/background/async-job-tracker.ts +33 -14
  42. package/src/runs/background/async-resume.ts +20 -2
  43. package/src/runs/background/async-retention.ts +1 -1
  44. package/src/runs/background/async-status.ts +10 -0
  45. package/src/runs/background/chain-root-attachment.ts +5 -0
  46. package/src/runs/background/control-channel.ts +98 -10
  47. package/src/runs/background/notify.ts +62 -1
  48. package/src/runs/background/result-watcher.ts +4 -3
  49. package/src/runs/background/run-status.ts +15 -1
  50. package/src/runs/background/stale-run-reconciler.ts +3 -0
  51. package/src/runs/background/subagent-runner.ts +251 -38
  52. package/src/runs/background/wait-completions.ts +2 -0
  53. package/src/runs/background/wait-tool.ts +4 -3
  54. package/src/runs/foreground/async-stop-action.ts +23 -2
  55. package/src/runs/foreground/execution.ts +110 -8
  56. package/src/runs/foreground/subagent-executor.ts +361 -45
  57. package/src/runs/foreground/workflow-detach-reconcile.ts +3 -0
  58. package/src/runs/shared/acceptance.ts +1 -0
  59. package/src/runs/shared/child-identity.ts +36 -0
  60. package/src/runs/shared/completion-guard.ts +50 -1
  61. package/src/runs/shared/external-job-bridge.ts +53 -37
  62. package/src/runs/shared/external-job-runner.ts +126 -23
  63. package/src/runs/shared/model-fallback.ts +46 -15
  64. package/src/runs/shared/model-scope.ts +106 -39
  65. package/src/runs/shared/orca-progress-tabs.ts +71 -9
  66. package/src/runs/shared/parallel-utils.ts +12 -0
  67. package/src/runs/shared/pi-args.ts +31 -1
  68. package/src/runs/shared/subagent-prompt-runtime.ts +39 -10
  69. package/src/runs/shared/tool-availability.ts +1 -3
  70. package/src/shared/launch-contract.ts +6 -5
  71. package/src/shared/thinking-ceiling.ts +52 -0
  72. package/src/shared/types.ts +72 -5
  73. package/src/tui/fleet-status.ts +66 -13
  74. package/src/tui/render.ts +5 -4
  75. package/src/watchdog/permission-arbiter.ts +59 -51
  76. package/src/workflows/scripted-workflow.ts +107 -10
@@ -1,7 +1,7 @@
1
1
  import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
3
  import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
- import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
4
+ import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
5
5
 
6
6
  export type { AvailableModelInfo };
7
7
 
@@ -22,6 +22,16 @@ export function splitThinkingSuffix(model: string): { baseModel: string; thinkin
22
22
  };
23
23
  }
24
24
 
25
+ export function formatSubagentModelVerificationError(expectedModel: string, observedModel: string, availableModels: AvailableModelInfo[] | undefined): string | undefined {
26
+ if (!availableModels || availableModels.length === 0) return undefined;
27
+ const expectedBase = splitThinkingSuffix(expectedModel).baseModel;
28
+ const observedBase = splitThinkingSuffix(observedModel).baseModel;
29
+ if (expectedBase === observedBase) return undefined;
30
+ const expectedEntry = availableModels.find((entry) => entry.fullId === expectedBase);
31
+ if (expectedEntry && expectedEntry.id === observedBase) return undefined;
32
+ return `model_verification_failed: child reported a different model than the launch candidate. Expected '${expectedModel}' but observed '${observedModel}'.`;
33
+ }
34
+
25
35
  /** Sentinel model value requesting that a subagent inherit the parent session's model. */
26
36
  export const INHERIT_MODEL = "inherit";
27
37
 
@@ -236,7 +246,7 @@ function resolveRequiredSubagentModelCandidate(
236
246
 
237
247
  export interface ResolveSubagentModelOverrideOptions {
238
248
  /** When set with `enforce: true`, out-of-scope models are rejected. */
239
- scope?: ModelScopeConfig;
249
+ scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
240
250
  /** Origin of the requested model: explicit caller-supplied (hard error) vs inherited (warn). Defaults to `"inherited"`. */
241
251
  source?: ModelSource;
242
252
  /** Called for warn-severity violations instead of `console.warn`. */
@@ -247,6 +257,32 @@ function defaultScopeWarn(violation: ModelScopeViolation): void {
247
257
  console.warn(`[pi-subagents] ${violation.message}`);
248
258
  }
249
259
 
260
+ function configuredScopes(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined): ModelScopeCheckRule[] {
261
+ return scope ? (Array.isArray(scope) ? scope : [scope]) : [];
262
+ }
263
+
264
+ function throwForUnresolvedEnforcedInheritScope(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined, includeMixed = false): void {
265
+ const unresolvedInheritScope = configuredScopes(scope)
266
+ .find((entry) => entry.enforce === true && (includeMixed ? entry.allow?.includes(INHERIT_MODEL) : entry.allow?.length === 1 && entry.allow[0] === INHERIT_MODEL));
267
+ if (!unresolvedInheritScope) return;
268
+ const origin = unresolvedInheritScope.origin ?? "modelScope";
269
+ throw new Error(`Cannot enforce subagent model scope (${origin}): 'inherit' requires a current parent session model.`);
270
+ }
271
+
272
+ function enforceModelScopes(
273
+ model: string,
274
+ scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined,
275
+ source: ModelSource,
276
+ onWarn: ((violation: ModelScopeViolation) => void) | undefined,
277
+ ): void {
278
+ const violations = configuredScopes(scope)
279
+ .map((entry) => checkModelScope(model, entry, source))
280
+ .filter((violation): violation is ModelScopeViolation => violation !== undefined);
281
+ const error = violations.find((violation) => violation.severity === "error");
282
+ if (error) throw new Error(error.message);
283
+ for (const violation of violations) (onWarn ?? defaultScopeWarn)(violation);
284
+ }
285
+
250
286
  /**
251
287
  * Resolve the `--model` override passed to a spawned subagent.
252
288
  *
@@ -274,19 +310,16 @@ export function resolveSubagentModelOverride(
274
310
  ): string | undefined {
275
311
  const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
276
312
  const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
313
+ if (!parentModel) throwForUnresolvedEnforcedInheritScope(options?.scope, explicit === undefined || options?.source === "inherited");
277
314
  let resolved: string | undefined;
278
315
  if (explicit === undefined) {
279
316
  resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
280
317
  } else {
281
318
  resolved = resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
282
319
  }
283
- if (resolved && options?.scope?.enforce) {
320
+ if (resolved && options?.scope) {
284
321
  const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
285
- const violation = checkModelScope(resolved, options.scope, source);
286
- if (violation) {
287
- if (violation.severity === "error") throw new Error(violation.message);
288
- (options.onWarn ?? defaultScopeWarn)(violation);
289
- }
322
+ enforceModelScopes(resolved, options.scope, source, options.onWarn);
290
323
  }
291
324
  return resolved;
292
325
  }
@@ -318,7 +351,7 @@ export function resolveEffectiveSubagentModel(
318
351
 
319
352
  export interface BuildModelCandidatesOptions {
320
353
  /** Fallback models warn by default and throw when strict scope enforcement is enabled. */
321
- scope?: ModelScopeConfig;
354
+ scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
322
355
  onWarn?: (violation: ModelScopeViolation) => void;
323
356
  /** The primary model came from the running parent session, not configuration. */
324
357
  primaryModelFromParent?: boolean;
@@ -341,6 +374,7 @@ export function buildModelCandidates(
341
374
  preferredProvider?: string,
342
375
  options?: BuildModelCandidatesOptions,
343
376
  ): string[] {
377
+ if (!primaryModel) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
344
378
  const seen = new Set<string>();
345
379
  const candidates: string[] = [];
346
380
  const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
@@ -358,12 +392,9 @@ export function buildModelCandidates(
358
392
  continue;
359
393
  }
360
394
  if (seen.has(normalized)) continue;
361
- if ((index > 0 || options?.scope?.strict === true) && options?.scope?.enforce) {
362
- const violation = checkModelScope(normalized, options.scope, "inherited");
363
- if (violation) {
364
- if (violation.severity === "error") throw new Error(violation.message);
365
- (options.onWarn ?? defaultScopeWarn)(violation);
366
- }
395
+ const scopes = configuredScopes(options?.scope);
396
+ if (index > 0 || scopes.some((scope) => scope.enforce === true && scope.strict === true)) {
397
+ enforceModelScopes(normalized, scopes, "inherited", options?.onWarn);
367
398
  }
368
399
  seen.add(normalized);
369
400
  candidates.push(normalized);
@@ -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
 
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import type { Message } from "@earendil-works/pi-ai";
6
6
  import { resolveNodeExecutable } from "../../shared/node-executable.ts";
7
+ import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
7
8
  import { TEMP_ROOT_DIR, type OrcaProgressTabsConfig } from "../../shared/types.ts";
8
9
  import { extractTextFromContent, extractToolArgsPreview, getAgentDir } from "../../shared/utils.ts";
9
10
 
@@ -23,20 +24,22 @@ const ORCA_CREATE_WATCHDOG_SCRIPT = [
23
24
  "const {spawn}=require('node:child_process');",
24
25
  "const fs=require('node:fs');",
25
26
  "const timeout=Number(process.argv[1]),grace=Number(process.argv[2]),waitTimeout=Number(process.argv[3]);",
26
- "const previous=process.argv[4],done=process.argv[5],command=process.argv[6],args=process.argv.slice(7);",
27
+ "const previous=process.argv[4],done=process.argv[5],manifest=process.argv[6],command=process.argv[7],args=process.argv.slice(8);",
27
28
  "function mark(){try{fs.writeFileSync(done.replace(/\\.pending$/,'.ready'),'')}catch{}}",
28
29
  "function exists(file){try{return fs.existsSync(file)}catch{return false}}",
29
30
  "function keepQueued(){try{const now=new Date();fs.utimesSync(done,now,now)}catch{}}",
30
31
  "function predecessorReady(){if(previous==='-')return true;if(exists(previous.replace(/\\.pending$/,'.ready')))return true;try{return Date.now()-fs.statSync(previous).mtimeMs>=waitTimeout}catch{return true}}",
32
+ "function updateManifest(state,stdout=''){if(manifest==='-')return;try{const payload=JSON.parse(fs.readFileSync(manifest,'utf8'));payload.state=state;payload.updatedAt=new Date().toISOString();const raw=stdout.trim().split(/\\r?\\n/).filter(Boolean).at(-1);if(raw){try{payload.orca=JSON.parse(raw)}catch{payload.orcaRaw=raw.slice(0,4096)}}fs.writeFileSync(manifest,JSON.stringify(payload,null,2)+'\\n')}catch{}}",
31
33
  "function start(){",
32
34
  " try{",
33
- " const child=spawn(command,args,{stdio:'ignore',windowsHide:true});",
34
- " let hardKill;",
35
+ " const child=spawn(command,args,{stdio:['ignore','pipe','ignore'],windowsHide:true});",
36
+ " let hardKill,stdout='';",
37
+ " if(child.stdout)child.stdout.on('data',chunk=>{stdout+=String(chunk);if(stdout.length>65536)stdout=stdout.slice(-65536)});",
35
38
  " const timer=setTimeout(()=>{child.kill('SIGTERM');hardKill=setTimeout(()=>child.kill('SIGKILL'),grace)},timeout);",
36
39
  " const clear=()=>{clearTimeout(timer);if(hardKill)clearTimeout(hardKill);mark()};",
37
- " child.once('error',()=>{clear();process.exitCode=1});",
38
- " child.once('close',code=>{clear();process.exitCode=code===0?0:1});",
39
- " }catch{mark();process.exitCode=1}",
40
+ " child.once('error',()=>{updateManifest('failed');clear();process.exitCode=1});",
41
+ " child.once('close',code=>{updateManifest(code===0?'open':'failed',stdout);clear();process.exitCode=code===0?0:1});",
42
+ " }catch{updateManifest('failed');mark();process.exitCode=1}",
40
43
  "}",
41
44
  "if(predecessorReady())start();",
42
45
  "else{(function waitPrev(){keepQueued();if(predecessorReady())return start();setTimeout(waitPrev,20)})();}",
@@ -51,6 +54,7 @@ const ORCA_CLEANUP_WATCHDOG_SCRIPT = [
51
54
 
52
55
  export interface OrcaProgressTab {
53
56
  append(text: string): void;
57
+ section(input: { agent: string; index: number; count: number }): void;
54
58
  event(event: { type?: string; message?: Message; toolName?: string; args?: unknown }): void;
55
59
  finish(status: "completed" | "failed" | "stopped", sessionFile?: string): void;
56
60
  }
@@ -143,6 +147,41 @@ function safeSegment(value: unknown, fallback = "subagent"): string {
143
147
  return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || fallback;
144
148
  }
145
149
 
150
+ interface OrcaObserverManifest {
151
+ schemaVersion: 1;
152
+ kind: "orca-observer-view";
153
+ observer: "orca";
154
+ role: "run";
155
+ runId: string;
156
+ title: string;
157
+ worktree: string;
158
+ state: "opening" | "open" | "failed";
159
+ createdAt: string;
160
+ updatedAt?: string;
161
+ logPath: string;
162
+ orca?: unknown;
163
+ orcaRaw?: string;
164
+ }
165
+
166
+ function observerManifestPath(cwd: string, stem: string): string | undefined {
167
+ try {
168
+ const dir = path.join(getProjectSubagentsDir(resolveSequenceScope(cwd)), "views", "orca");
169
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
170
+ return path.join(dir, `${stem}.json`);
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ function writeObserverManifest(manifestPath: string | undefined, manifest: OrcaObserverManifest): void {
177
+ if (!manifestPath) return;
178
+ try {
179
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
180
+ } catch {
181
+ // The Orca observer stays display-only when project-local manifests cannot be written.
182
+ }
183
+ }
184
+
146
185
  function sleepSync(ms: number): void {
147
186
  const signal = new Int32Array(new SharedArrayBuffer(4));
148
187
  Atomics.wait(signal, 0, 0, ms);
@@ -280,6 +319,7 @@ export function createOrcaProgressTab(input: {
280
319
  runId: string;
281
320
  agent: string;
282
321
  index: number;
322
+ stepCount?: number;
283
323
  config?: OrcaProgressTabsConfig;
284
324
  env?: NodeJS.ProcessEnv;
285
325
  command?: string;
@@ -305,14 +345,28 @@ export function createOrcaProgressTab(input: {
305
345
  if (reservation === undefined) return undefined;
306
346
  const tabSequence = reservation.sequence;
307
347
  const stem = `${runId}-${index}-${randomUUID()}`;
348
+ const stepCount = typeof input.stepCount === "number" && Number.isInteger(input.stepCount) && input.stepCount > 0 ? input.stepCount : 1;
308
349
  const logPath = path.join(root, `${stem}.log`);
309
350
  const donePath = path.join(root, `${stem}.done`);
310
- const title = `subagent · ${agent} · ${tabSequence}`;
351
+ const title = `subagents · ${agent} · ${tabSequence}`;
311
352
  const markCreateReady = () => {
312
353
  try { fs.writeFileSync(reservation.createDone.replace(/\.pending$/, ".ready"), ""); } catch { /* unblock later tabs */ }
313
354
  };
355
+ const manifestPath = observerManifestPath(cwd, stem);
356
+ writeObserverManifest(manifestPath, {
357
+ schemaVersion: 1,
358
+ kind: "orca-observer-view",
359
+ observer: "orca",
360
+ role: "run",
361
+ runId,
362
+ title,
363
+ worktree: path.resolve(cwd),
364
+ state: "opening",
365
+ createdAt: new Date().toISOString(),
366
+ logPath,
367
+ });
314
368
  try {
315
- fs.writeFileSync(logPath, `pi-subagents / ${agent}\nrun ${runId} · child ${index + 1}\n${"─".repeat(48)}\n`, { encoding: "utf-8", mode: 0o600 });
369
+ fs.writeFileSync(logPath, `pi-subagents / ${agent}\nrun ${runId} · ${stepCount === 1 ? "1 child" : `${stepCount} children`}\n${"─".repeat(48)}\n`, { encoding: "utf-8", mode: 0o600 });
316
370
  fs.rmSync(donePath, { force: true });
317
371
  } catch {
318
372
  markCreateReady();
@@ -361,6 +415,7 @@ export function createOrcaProgressTab(input: {
361
415
  String(ORCA_CREATE_WAIT_TIMEOUT_MS),
362
416
  reservation.previousCreateDone ?? "-",
363
417
  reservation.createDone,
418
+ manifestPath ?? "-",
364
419
  command,
365
420
  "terminal", "create",
366
421
  "--worktree", `path:${path.resolve(cwd)}`,
@@ -398,6 +453,13 @@ export function createOrcaProgressTab(input: {
398
453
  if (finished) return;
399
454
  writeProgress(text);
400
455
  },
456
+ section(section) {
457
+ if (finished) return;
458
+ const label = safeSegment(section.agent, "subagent");
459
+ const count = Number.isInteger(section.count) && section.count > 0 ? section.count : 1;
460
+ const index = Number.isInteger(section.index) && section.index >= 0 ? section.index : 0;
461
+ writeProgress(`\n${"─".repeat(16)} child ${index + 1}/${count} · ${label} ${"─".repeat(16)}\n`);
462
+ },
401
463
  event(event) {
402
464
  if (!available || finished) return;
403
465
  if (event.type === "tool_execution_start" && event.toolName) {
@@ -421,7 +483,7 @@ export function createOrcaProgressTab(input: {
421
483
  try { verifiedSessionFile = fs.realpathSync(sessionFile); } catch { /* the session is no longer available */ }
422
484
  }
423
485
  const terminalMessage = verifiedSessionFile
424
- ? `completed. To remove the Pi session of this subagent, run rm -- ${shellQuote(verifiedSessionFile)}`
486
+ ? `completed. To remove the Pi session for this run, run rm -- ${shellQuote(verifiedSessionFile)}`
425
487
  : status;
426
488
  const truncation = truncated ? `\n[progress mirror truncated at ${MAX_MIRROR_BYTES} bytes]\n` : "";
427
489
  let footer = `${truncation}\n${"─".repeat(48)}\n${terminalMessage}\n`;
@@ -8,6 +8,13 @@ export interface RunnerSubagentStep {
8
8
  agent: string;
9
9
  task: string;
10
10
  runner?: ResolvedRunnerConfig;
11
+ externalJobFollowUp?: {
12
+ sourceRunId: string;
13
+ sourceStepIndex: number;
14
+ parentProviderJobId: string;
15
+ requestId: string;
16
+ requestDigest: string;
17
+ };
11
18
  /** Resolved launch context for this child. */
12
19
  context?: "fresh" | "fork";
13
20
  importAsyncRoot?: {
@@ -23,7 +30,11 @@ export interface RunnerSubagentStep {
23
30
  cwd?: string;
24
31
  model?: string;
25
32
  thinking?: string;
33
+ thinkingCeiling?: import("../../shared/model-info.ts").ThinkingLevel;
26
34
  modelCandidates?: string[];
35
+ /** The primary model is inherited from the parent session and should not be verified against the child-reported active registry model. */
36
+ skipPrimaryModelVerification?: boolean;
37
+ modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>;
27
38
  tools?: string[];
28
39
  extensions?: string[];
29
40
  subagentOnlyExtensions?: string[];
@@ -93,6 +104,7 @@ export interface DynamicRunnerGroup {
93
104
  gateOn?: import("../../shared/types.ts").ChainGateLayer;
94
105
  capabilityCeiling?: import("./capability-ceiling.ts").ResolvedSubagentCapabilityCeiling;
95
106
  capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
107
+ thinkingCeiling?: import("../../shared/model-info.ts").ThinkingLevel;
96
108
  }
97
109
 
98
110
  export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
@@ -26,6 +26,7 @@ import {
26
26
  type RunFanoutBudgetDescriptor,
27
27
  } from "../../shared/types.ts";
28
28
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
29
+ import { decodeThinkingCeiling, intersectThinkingCeilings, SUBAGENT_THINKING_CEILING_ENV } from "../../shared/thinking-ceiling.ts";
29
30
  import { encodeRunFanoutBudgetDescriptor, RUN_FANOUT_BUDGET_ENV } from "./run-fanout-budget.ts";
30
31
  import {
31
32
  TOOL_BUDGET_ENV,
@@ -186,11 +187,13 @@ export interface BuildPiArgsInput {
186
187
  taskDelivery?: SubagentTaskDelivery;
187
188
  waitToolEnabled?: boolean;
188
189
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
190
+ thinkingCeiling?: import("../../shared/model-info.ts").ThinkingLevel;
189
191
  }
190
192
 
191
193
  export interface BuildPiArgsResult {
192
194
  args: string[];
193
195
  env: Record<string, string | undefined>;
196
+ warnings: string[];
194
197
  tempDir?: string;
195
198
  toolDiagnosticPath?: string;
196
199
  runtimeAcknowledgedExtensionsPath?: string;
@@ -251,6 +254,7 @@ export interface ResolvePiLaunchToolPlanInput {
251
254
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
252
255
  inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
253
256
  agentName?: string;
257
+ permissionRules?: PermissionRules;
254
258
  }
255
259
 
256
260
  export interface PiLaunchToolPlan {
@@ -271,6 +275,8 @@ export interface PiLaunchToolPlan {
271
275
  extensionArgs: string[];
272
276
  disableAmbientExtensions: boolean;
273
277
  capabilityAudit?: SubagentCapabilityAudit;
278
+ /** Non-fatal launch warnings; they do not change behavior. */
279
+ warnings: string[];
274
280
  }
275
281
 
276
282
  function extensionIdentifier(value: string): string {
@@ -288,6 +294,10 @@ function boundedExtensionIdentifiers(values: string[]): {
288
294
  };
289
295
  }
290
296
 
297
+ function hasPermissionRules(rules: PermissionRules | undefined): boolean {
298
+ return rules !== undefined && Object.keys(rules).length > 0;
299
+ }
300
+
291
301
  export function projectLaunchResolvedChildExtensions(
292
302
  toolPlan: Pick<
293
303
  PiLaunchToolPlan,
@@ -451,7 +461,9 @@ export function resolvePiLaunchToolPlan(
451
461
  : [];
452
462
  const permSystemExt = capabilityCeiling?.denyExtensions
453
463
  ? undefined
454
- : resolvePermissionSystemExtension();
464
+ : hasPermissionRules(input.permissionRules)
465
+ ? resolvePermissionSystemExtension()
466
+ : undefined;
455
467
  const runtimeExtensions = [
456
468
  PROMPT_RUNTIME_EXTENSION_PATH,
457
469
  ...(fanoutAuthorized ? [FANOUT_CHILD_EXTENSION_PATH] : []),
@@ -460,6 +472,16 @@ export function resolvePiLaunchToolPlan(
460
472
  const disableAmbientExtensions =
461
473
  capabilityCeiling?.denyExtensions === true ||
462
474
  input.extensions !== undefined;
475
+ const warnings: string[] = [];
476
+ // An explicit empty list disables ambient extensions, including model providers.
477
+ if (capabilityCeiling?.denyExtensions !== true && Array.isArray(input.extensions) && input.extensions.length === 0) {
478
+ const agentLabel = input.agentName ? ` for agent '${input.agentName}'` : "";
479
+ warnings.push(
480
+ `extensions: [] override${agentLabel} disables ALL ambient extensions for this child (not just "adds nothing"), `
481
+ + "including any model-provider extension needed to resolve a provider-qualified model. "
482
+ + "List the extensions this child actually needs instead of an empty array.",
483
+ );
484
+ }
463
485
  const configuredExtensions = capabilityCeiling?.denyExtensions
464
486
  ? []
465
487
  : [
@@ -540,6 +562,7 @@ export function resolvePiLaunchToolPlan(
540
562
  configuredExtensions,
541
563
  extensionArgs,
542
564
  disableAmbientExtensions,
565
+ warnings,
543
566
  ...(capabilityAudit ? { capabilityAudit } : {}),
544
567
  };
545
568
  }
@@ -587,6 +610,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
587
610
  process.env[SUBAGENT_CAPABILITY_CEILING_ENV],
588
611
  ),
589
612
  agentName: input.childAgentName,
613
+ permissionRules: input.permissionRules,
590
614
  });
591
615
  if (toolPlan.explicitToolAllowlist) {
592
616
  args.push(
@@ -802,6 +826,11 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
802
826
  const encodedCapabilityCeiling = encodeSubagentCapabilityCeiling(
803
827
  toolPlan.capabilityCeiling,
804
828
  );
829
+ const thinkingCeiling = intersectThinkingCeilings(
830
+ input.thinkingCeiling,
831
+ decodeThinkingCeiling(process.env[SUBAGENT_THINKING_CEILING_ENV]),
832
+ );
833
+ if (thinkingCeiling) env[SUBAGENT_THINKING_CEILING_ENV] = thinkingCeiling;
805
834
  if (encodedCapabilityCeiling)
806
835
  env[SUBAGENT_CAPABILITY_CEILING_ENV] = encodedCapabilityCeiling;
807
836
  if (encodedPermissionRules)
@@ -829,6 +858,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
829
858
  return {
830
859
  args,
831
860
  env,
861
+ warnings: toolPlan.warnings,
832
862
  tempDir,
833
863
  toolDiagnosticPath,
834
864
  runtimeAcknowledgedExtensionsPath,