pi-subagents 0.59.0 → 0.61.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 (72) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/docs/agents.md +2 -2
  3. package/docs/configuration.md +9 -5
  4. package/docs/extension-api.md +14 -7
  5. package/docs/models.md +1 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +15 -3
  8. package/docs/workflows.md +15 -14
  9. package/install.mjs +2 -1
  10. package/package.json +1 -1
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +89 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +30 -234
  15. package/skills/pi-subagents/references/execution-controls.md +79 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +35 -28
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +256 -88
  21. package/src/agents/agents.ts +527 -221
  22. package/src/api/background-work.ts +7 -2
  23. package/src/api/external-runs.ts +67 -4
  24. package/src/api/preflight.ts +13 -8
  25. package/src/api/shared-types.ts +3 -0
  26. package/src/extension/index.ts +7 -4
  27. package/src/extension/public-execution.ts +48 -4
  28. package/src/extension/rpc.ts +62 -4
  29. package/src/extension/schemas.ts +12 -7
  30. package/src/extension/tool-description.ts +16 -16
  31. package/src/runs/background/async-execution.ts +53 -37
  32. package/src/runs/background/async-job-tracker.ts +65 -3
  33. package/src/runs/background/async-resume.ts +3 -1
  34. package/src/runs/background/async-status.ts +104 -11
  35. package/src/runs/background/auto-drain.ts +1 -1
  36. package/src/runs/background/control-channel.ts +3 -2
  37. package/src/runs/background/fleet-view.ts +1 -1
  38. package/src/runs/background/result-watcher.ts +1 -1
  39. package/src/runs/background/resume-guidance.ts +1 -1
  40. package/src/runs/background/run-status.ts +15 -4
  41. package/src/runs/background/subagent-runner.ts +6 -3
  42. package/src/runs/background/subagent-wait.ts +30 -23
  43. package/src/runs/background/wait-completions.ts +4 -1
  44. package/src/runs/background/wait-tool.ts +24 -18
  45. package/src/runs/foreground/execution.ts +72 -4
  46. package/src/runs/foreground/subagent-executor.ts +276 -59
  47. package/src/runs/shared/acceptance.ts +43 -18
  48. package/src/runs/shared/async-status-projection.ts +138 -4
  49. package/src/runs/shared/background-process-options.ts +9 -0
  50. package/src/runs/shared/host-step-status.ts +1 -0
  51. package/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
  52. package/src/runs/shared/model-fallback.ts +61 -17
  53. package/src/runs/shared/mutation-evidence.ts +52 -3
  54. package/src/runs/shared/permissions.ts +1 -1
  55. package/src/runs/shared/pi-args.ts +47 -1
  56. package/src/runs/shared/single-output.ts +45 -18
  57. package/src/runs/shared/subagent-prompt-runtime.ts +20 -2
  58. package/src/runs/shared/tool-timeout.ts +1 -1
  59. package/src/runs/shared/workflow-graph.ts +16 -0
  60. package/src/shared/types.ts +74 -4
  61. package/src/shared/workflow-child-permit.ts +91 -0
  62. package/src/slash/prompt-template-bridge.ts +37 -1
  63. package/src/slash/slash-commands.ts +18 -26
  64. package/src/tui/fleet-status.ts +11 -3
  65. package/src/tui/render-helpers.ts +31 -0
  66. package/src/tui/render.ts +673 -145
  67. package/src/watchdog/change-signature.ts +40 -1
  68. package/src/workflows/host-command.ts +6 -1
  69. package/src/workflows/scripted-workflow.ts +206 -6
  70. package/src/workflows/workflow-child-summary.ts +1 -1
  71. package/src/workflows/workflow-receipt.ts +41 -4
  72. package/src/workflows/workflow-resources.ts +150 -0
@@ -9,31 +9,26 @@ const EXTERNAL_CLI_RUNNER_GUIDANCE = "External CLI agents (codex-exec, codex-exe
9
9
  const WORKFLOW_RESUME_KEY_GUIDANCE = "Each workflow key identifies one result lane: use a new stable workflow key for every distinct retained resume pass; same-key calls are reused only when launch parameters are identical, and incompatible parameters are rejected.";
10
10
  const WORKFLOW_OUTPUT_BINDING_GUIDANCE = "For durable workflow child files, set output on runs.run/runs.all; task filename prose is not an output declaration, and return the child's outputReference, outputPathMapping, or artifactPaths instead of inventing a literal path.";
11
11
  const WORKFLOW_LANES_GUIDANCE = "For bounded parallel sequential chains, use runs.lanes([{key,stages:[{key,agent,task},{key,resume:'previous',task},...]}]); first stages run together, later stages sequence per lane, and the bounded board reports lane-local failures. Only an explicit structuredOutput.verdict === 'blocked' blocks a successful stage; reviewer prose is not parsed.";
12
- const WORKFLOW_HOST_GUIDANCE = "For one non-interactive operator-owned command, await runs.host(key,{kind:'command',command,timeoutMs,output?,role?,provider?}). v1 supports only command steps; output is bounded and command failure fails the workflow.";
12
+ const WORKFLOW_SCRIPT_PORTABILITY_GUIDANCE = "workflowScript rejects nested async function, arrow, and method helpers; use top-level await, plain helper functions that return runs.run(...), or explicit Promise chains instead.";
13
+ const WORKFLOW_RESOURCE_GUIDANCE = "For permission/policy-extension interoperability, use an extension-owned named resource such as {workflow:'review',args:{task:'...'}} or {workflow:'run-ci',args:{command:'npm test'}}. The host resolves the script and authority internally so policy can distinguish it from raw workflowScript/workflowScriptPath; args are bounded plain data, and do not combine workflow with agent, task, workflowScript, or workflowScriptPath.";
14
+ const WORKFLOW_HOST_GUIDANCE = "For permission-sensitive host calls, use an extension-owned resource such as {workflow:'run-ci',args:{command:'npm test'}}; raw workflowScript/workflowScriptPath have unknown resource provenance and cannot use runs.host. In a resource that grants it, await runs.host(key,{kind:'command',command,timeoutMs,output?,role?,provider?}). runs.host has no per-step cwd: commands and relative output paths use the workflow cwd; set cwd on the outer subagent request instead (for example, {cwd:'/path/to/worktree',workflowScript:'...'}), or put a trusted directory change in the command (for example, 'cd /path/to/worktree && npm test'). v1 supports only command steps; output is bounded and command failure fails the workflow.";
13
15
 
14
- export const DEFAULT_SUBAGENT_TOOL_DESCRIPTION = `Delegate to configured subagents. For execution, omit action and use {agent, task?} for one child, workflowScript for inline orchestration, or workflowScriptPath to load a script from the request cwd. The script inputs are mutually exclusive. Use action:'validate' with either script input to check it without launching children. For multi-step or parallel work, make exactly one top-level subagent call with async:true; launch children only inside that workflow and do not make another top-level call for them. Use runs.run('key',{agent,task}) for one child, await runs.all([{key:'a',agent:'reviewer',task:'...'},{key:'b',agent:'reviewer',task:'...'}]) for ordinary parallel children, and read its ordered array result with indexes, destructuring, or .map(...), not by key property. ${WORKFLOW_LANES_GUIDANCE} ${WORKFLOW_HOST_GUIDANCE} ${EXTERNAL_CLI_RUNNER_GUIDANCE} Use action only for management/control. Use guide or the pi-subagents skill for advanced workflow details.`;
16
+ export const DEFAULT_SUBAGENT_TOOL_DESCRIPTION = `Delegate to configured subagents. For execution, omit action and use {agent, task?} for one child, workflowScript for inline orchestration, workflowScriptPath to load a script from the request cwd, or a named workflow resource for permission/policy-aware execution. ${WORKFLOW_RESOURCE_GUIDANCE} The script inputs are mutually exclusive. Use action:'validate' with either script input to check it without launching children. For multi-step or parallel work, make exactly one top-level subagent call with async:true; launch children only inside that workflow and do not make another top-level call for them. Use runs.run('key',{agent,task}) for one child, await runs.all([{key:'a',agent:'reviewer',task:'...'},{key:'b',agent:'reviewer',task:'...'}]) for ordinary parallel children, and read its ordered array result with indexes, destructuring, or .map(...), not by key property. ${WORKFLOW_SCRIPT_PORTABILITY_GUIDANCE} ${WORKFLOW_LANES_GUIDANCE} ${WORKFLOW_HOST_GUIDANCE} ${EXTERNAL_CLI_RUNNER_GUIDANCE} Use action only for management/control. Use guide or the pi-subagents skill for advanced workflow details.`;
15
17
 
16
18
  export const SUBAGENT_TOOL_PROMPT_SNIPPET = "Delegate to subagents; orchestrate in one workflowScript call.";
17
19
 
18
20
  export const SUBAGENT_TOOL_PROMPT_GUIDELINES = [
19
- "Use subagent only when delegation is needed. Before executing, call { action: \"list\" } and run only executable, non-disabled agents.",
20
- "Omit action for execution. Use { agent, task? } only for one child; use workflowScript for multi-step or parallel work.",
21
- "workflowScript means exactly one top-level subagent tool call with async:true. Inside it, use runs.run/runs.all to launch children; do not make another top-level subagent call for those children.",
22
- WORKFLOW_LANES_GUIDANCE,
23
- WORKFLOW_HOST_GUIDANCE,
24
- WORKFLOW_RESUME_KEY_GUIDANCE,
25
- WORKFLOW_OUTPUT_BINDING_GUIDANCE,
26
- "For ordinary parallel work, use await runs.all([{key,agent,task}, ...]); it resolves to an ordered array, not a key map, so use results[0], destructuring, or results.map(...), not results.<key>. Do not read .output from unawaited runs.run launches. Stored runs.run promises are only for advanced rolling fanout and each must later be observed with direct await, Promise.race, or Promise.all.",
27
- "Keep one writer per cwd/worktree unless writers run in isolated worktrees.",
28
- "To pass an explicit model to a child, first call { action: \"models\" } and copy an exact provider/id (e.g. openai-codex/gpt-5.6-sol); bare ids resolve only when unique in the registry, and agent names (gpt-pro, advisor) are not model ids. Set per-run thinking with a suffix on the model string (e.g. openai-codex/gpt-5.6-sol:high; off/minimal/low/medium/high/xhigh/max); the suffix wins over the agent's thinking default. The thinking field only applies to action='watchdog.configure' and is ignored on dispatch.",
29
- EXTERNAL_CLI_RUNNER_GUIDANCE,
30
- "Use guide or the pi-subagents skill for advanced scheduling, missions, steering, and retention.",
21
+ 'Use subagent only when delegation is needed. Before execution, call { action: "list" } and run only executable, non-disabled agents.',
22
+ 'Omit action for execution; use { agent, task? } for one child. For multi-step or parallel work, make exactly one top-level { workflowScript, async: true } call and launch children only inside it. Use action only for management/control.',
23
+ "workflowScript rejects nested async function, arrow, and method helpers; use top-level await, plain helper functions, or explicit Promise chains.",
24
+ "Inside workflowScript, use runs.run/runs.all and await their results. runs.all returns an ordered array, not a key map; stored runs.run promises must later be observed with direct await, Promise.race, or Promise.all.",
25
+ 'Keep one writer per cwd/worktree; isolate concurrent writers. For durable files, set output on runs.run/runs.all and return the child\'s outputReference, outputPathMapping, or artifactPaths. For advanced workflows, read the bundled pi-subagents skill or call { action: "guide", topic: "workflows" }.',
31
26
  ];
32
27
 
33
28
  export const SUBAGENT_SAFETY_GUIDANCE = `SAFETY-CRITICAL SUBAGENT GUIDANCE:
34
29
  • Use { action: "list" } before execution and only run executable/non-disabled agents.
35
30
  • Keep execution and management separate: omit action for structured single-child or workflowScript execution; use action only for management/control.
36
- • Async/background runs are the normal default unless config sets asyncByDefault:false; set async:true explicitly when async behavior matters. Use async:false only when the parent must block until completion. Async mode still shows progress. Final reviews and gate checks stay async; needing a result is not a blocking reason. After an async launch, continue independent work only until its next dependency barrier; consume the result before work that depends on it. Do not sleep or poll status just to wait; use subagent_wait only when the current request must finish in this turn.
31
+ • Async/background runs are the normal default unless config sets asyncByDefault:false; set async:true explicitly when async behavior matters. Use async:false only when the parent must block until completion. Async mode still shows progress. Final reviews and gate checks stay async; needing a result is not a blocking reason. After an async launch, continue independent work only until its next dependency barrier; consume the result before work that depends on it. Ordinary async subagents notify this session natively, so return control and do not call bg_wait merely to get a completion wake. Do not sleep or poll status just to wait; use bg_wait only for provider, detached, or other background work without a native notification when this turn must receive its result.
37
32
  • ${WORKFLOW_RESUME_KEY_GUIDANCE}
38
33
  • ${WORKFLOW_OUTPUT_BINDING_GUIDANCE}
39
34
  • ${WORKFLOW_HOST_GUIDANCE}
@@ -44,6 +39,8 @@ export const SUBAGENT_SAFETY_GUIDANCE = `SAFETY-CRITICAL SUBAGENT GUIDANCE:
44
39
 
45
40
  export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for inline orchestration or { workflowScriptPath } to load it from the request cwd. The script inputs are mutually exclusive. Omit action for execution. Use action only for management/control actions.
46
41
 
42
+ ${WORKFLOW_RESOURCE_GUIDANCE}
43
+
47
44
  EXECUTION:
48
45
  • ${EXTERNAL_CLI_RUNNER_GUIDANCE}
49
46
  • Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
@@ -66,6 +63,8 @@ ${SUBAGENT_SAFETY_GUIDANCE}`;
66
63
 
67
64
  export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for inline orchestration or { workflowScriptPath } to load it from the request cwd. The script inputs are mutually exclusive. Omit action for execution. Use action only for management/control actions.
68
65
 
66
+ ${WORKFLOW_RESOURCE_GUIDANCE}
67
+
69
68
  EXECUTE:
70
69
  • ${EXTERNAL_CLI_RUNNER_GUIDANCE}
71
70
  • Call { action:"list" } first and use only executable/non-disabled agents.
@@ -82,9 +81,10 @@ MANAGE / CONTROL:
82
81
  • A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
83
82
 
84
83
  ASYNC / SAFETY:
85
- • Omitted async follows asyncByDefault config; set async:true explicitly when async behavior matters. Continue independent work only until its next dependency barrier; consume the result before work that depends on it. Do not sleep or poll merely to wait; use subagent_wait only when this turn must receive results.
84
+ • Omitted async follows asyncByDefault config; set async:true explicitly when async behavior matters. Continue independent work only until its next dependency barrier; consume the result before work that depends on it. Ordinary async subagents notify this session natively, so return control and do not call bg_wait merely to get a completion wake. Do not sleep or poll merely to wait; use bg_wait only for provider, detached, or other background work without a native notification when this turn must receive its result.
86
85
  • ${WORKFLOW_RESUME_KEY_GUIDANCE}
87
86
  • ${WORKFLOW_OUTPUT_BINDING_GUIDANCE}
87
+ • ${WORKFLOW_HOST_GUIDANCE}
88
88
  • Ordinary children are not orchestrators. Keep one writer per cwd/worktree and use fresh read-only reviewers for independent checks.
89
89
  • Oracle/advisor consultations use available supervisor dialogue for material unknowns; request one-shot when desired.
90
90
  • Status and artifacts live under asyncId/asyncDir with status.json, events.jsonl, output logs, and {action:"status",id:"..."}.`;
@@ -22,10 +22,11 @@ import type { ContextMode } from "../shared/context-mode.ts";
22
22
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
23
23
  import { preflightLaunchCwd } from "../shared/launch-cwd.ts";
24
24
  import { resolveNodeExecutable } from "../../shared/node-executable.ts";
25
+ import { backgroundProcessOptions } from "../shared/background-process-options.ts";
25
26
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
26
27
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
27
28
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, PROMPT_REDACTED, resolveChildCwd } from "../../shared/utils.ts";
28
- import { buildModelCandidates, inheritsParentModel, resolveEffectiveSubagentModel, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
29
+ import { buildModelCandidates, resolveEffectiveSubagentModel, resolveModelOrigin, resolveSubagentModelOverride, type AvailableModelInfo, type ModelOrigin, type ParentModel } from "../shared/model-fallback.ts";
29
30
  import { resolveToolTimeoutMs, toolTimeoutFromEnv } from "../shared/tool-timeout.ts";
30
31
  import { resolveModelScopesForAgent, type ModelScopeConfig } from "../shared/model-scope.ts";
31
32
  import { findModelInfo, resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -234,6 +235,7 @@ interface AsyncSingleParams {
234
235
  structuredOutputSchema?: JsonSchemaObject;
235
236
  modelOverride?: string;
236
237
  modelOverrideFromParent?: boolean;
238
+ modelOrigin?: ModelOrigin;
237
239
  fast?: boolean;
238
240
  thinkingOverride?: AgentConfig["thinking"];
239
241
  availableModels?: AvailableModelInfo[];
@@ -338,15 +340,15 @@ export function formatAsyncStartedMessage(headline: string, interactive: boolean
338
340
  const guidance = interactive
339
341
  ? [
340
342
  "The async run is detached and running in the background.",
341
- "You are in an interactive session. By default, return control to the user now; Pi will wake you on completion when the run finishes or needs attention. Do NOT call subagent_wait() merely to wait, and do not run sleep/polling loops to wait for it.",
342
- "When you need an explicit wake for one known run but do not need same-turn results, call subagent_wait({ id: \"...\", nonBlocking: true }) to arm a subscription and return immediately.",
343
- "Override the default and call blocking subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
343
+ "You are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.",
344
+ "Use bg_wait only for provider, detached, or other background work that lacks a native completion notification.",
345
+ "If the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.",
344
346
  "Otherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.",
345
347
  ]
346
348
  : [
347
349
  "The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
348
- "This is a non-interactive run: Pi auto-drains current-session background work at agent_end so detached children are not abandoned; call subagent_wait() when this turn must receive the run's results before it ends, otherwise let the headless auto-drain finish the work.",
349
- "Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run. To block until completion, use subagent_wait() — do not poll in a loop.",
350
+ "This is a non-interactive run: Pi auto-drains current-session subagent work at agent_end so detached children are not abandoned. Use bg_wait only when this turn must receive provider, detached, or other background-work results that have no native completion notification.",
351
+ "Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run; do not poll in a loop.",
350
352
  ];
351
353
  return [headline, "", ...guidance].join("\n");
352
354
  }
@@ -562,9 +564,8 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, initialStatus: Om
562
564
  }
563
565
  const proc = spawn(nodeCommand, [jitiCliPath, runner, cfgPath], {
564
566
  cwd,
565
- detached: true,
567
+ ...backgroundProcessOptions(),
566
568
  stdio: ["ignore", stdoutFd ?? "ignore", stderrFd ?? "ignore"],
567
- windowsHide: true,
568
569
  env: {
569
570
  ...omitExtensionBindingsEnv(process.env),
570
571
  ...(piPackageRoot ? { [PI_CODING_AGENT_PACKAGE_ROOT_ENV]: piPackageRoot } : {}),
@@ -857,14 +858,15 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
857
858
  const task = namespaceOutputPath ? taskText : injectSingleOutputInstruction(taskText, outputPath, a);
858
859
 
859
860
  const modelScopes = resolveModelScopesForAgent(ctx.modelScope, a.name, ctx.currentModel);
860
- const primaryModelFromParent = inheritsParentModel(s.model, a.model, ctx.currentModel);
861
+ const modelOrigin = resolveModelOrigin({ explicitModel: s.model, agentModel: a.model, parentModel: ctx.currentModel });
862
+ const primaryModelFromParent = modelOrigin === "inherited";
861
863
  const primaryModel = externalRunner ? undefined : resolveEffectiveSubagentModel(
862
864
  s.model,
863
865
  a.model,
864
866
  ctx.currentModel,
865
867
  availableModels,
866
868
  a.modelProvider ?? ctx.currentModelProvider,
867
- { scope: modelScopes },
869
+ { scope: modelScopes, source: modelOrigin === "explicit" ? "explicit" : "inherited" },
868
870
  );
869
871
  const thinkingOverride = flatIndex === undefined ? undefined : thinkingOverridesByFlatIndex?.[flatIndex];
870
872
  const effectiveThinking = externalRunner ? undefined : thinkingOverride ?? a.thinking;
@@ -884,15 +886,17 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
884
886
  }
885
887
  const agentContract = s.agentContract ?? params.agentContract;
886
888
  const permissionRules = resolvePermissionRules(ctx.permissions, a.permissions);
887
- const modelCandidates = externalRunner ? [] : buildModelCandidates(primaryModel, a.fallbackModels, availableModels, a.modelProvider ?? ctx.currentModelProvider, {
888
- scope: modelScopes,
889
- primaryModelFromParent,
890
- }).flatMap((candidate) => {
891
- const resolved = applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined);
892
- return resolved ? [resolved] : [];
893
- });
889
+ let modelCandidates: string[] = [];
894
890
  if (!externalRunner) {
895
891
  try {
892
+ modelCandidates = buildModelCandidates(primaryModel, a.fallbackModels, availableModels, a.modelProvider ?? ctx.currentModelProvider, {
893
+ scope: modelScopes,
894
+ primaryModelFromParent,
895
+ origin: modelOrigin,
896
+ }).flatMap((candidate) => {
897
+ const resolved = applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined);
898
+ return resolved ? [resolved] : [];
899
+ });
896
900
  for (const candidate of modelCandidates) assertThinkingWithinCeiling({ model: candidate, configThinking: effectiveThinking, ceiling: thinkingCeiling, agent: a.name, runId: id });
897
901
  } catch (error) {
898
902
  throw new AsyncStartValidationError(error instanceof Error ? error.message : String(error));
@@ -1588,15 +1592,27 @@ export function executeAsyncSingle(
1588
1592
  : "";
1589
1593
  const taskText = readsInstruction + taskWithOutputInstruction;
1590
1594
  const modelScopes = resolveModelScopesForAgent(ctx.modelScope, agentConfig.name, ctx.currentModel);
1591
- const primaryModel = externalRunner ? undefined : params.modelOverrideFromParent
1592
- ? params.modelOverride
1593
- : resolveSubagentModelOverride(
1594
- params.modelOverride ?? agentConfig.model,
1595
- ctx.currentModel,
1596
- availableModels,
1597
- ctx.currentModelProvider,
1598
- { scope: modelScopes },
1599
- );
1595
+ const modelOrigin = resolveModelOrigin({
1596
+ fromParent: params.modelOverrideFromParent,
1597
+ storedOrigin: params.modelOrigin,
1598
+ explicitModel: params.modelOverrideFromParent ? undefined : params.modelOverride,
1599
+ agentModel: agentConfig.model,
1600
+ parentModel: ctx.currentModel,
1601
+ });
1602
+ let primaryModel: string | undefined;
1603
+ try {
1604
+ primaryModel = externalRunner ? undefined : modelOrigin === "inherited"
1605
+ ? params.modelOverride ?? (ctx.currentModel ? `${ctx.currentModel.provider}/${ctx.currentModel.id}` : undefined)
1606
+ : resolveSubagentModelOverride(
1607
+ params.modelOverride ?? agentConfig.model,
1608
+ ctx.currentModel,
1609
+ availableModels,
1610
+ ctx.currentModelProvider,
1611
+ { scope: modelScopes, source: modelOrigin === "explicit" ? "explicit" : "inherited" },
1612
+ );
1613
+ } catch (error) {
1614
+ return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
1615
+ }
1600
1616
  const effectiveThinking = externalRunner ? undefined : params.thinkingOverride ?? agentConfig.thinking;
1601
1617
  const model = externalRunner ? undefined : applyThinkingSuffix(primaryModel, effectiveThinking, params.thinkingOverride !== undefined);
1602
1618
  const contextLimit = model ? findModelInfo(model, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider)?.contextWindow : undefined;
@@ -1633,18 +1649,17 @@ export function executeAsyncSingle(
1633
1649
  const structuredOutput = params.structuredOutputSchema
1634
1650
  ? createStructuredOutputRuntime(params.structuredOutputSchema, path.join(asyncDir, "structured-output"), { captureAcceptanceReport: params.acceptance !== false })
1635
1651
  : undefined;
1636
- const modelCandidates = externalRunner
1637
- ? []
1638
- : buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider, {
1639
- scope: modelScopes,
1640
- primaryModelFromParent: params.modelOverrideFromParent,
1641
- })
1642
- .flatMap((candidate) => {
1652
+ let modelCandidates: string[] = [];
1653
+ if (!externalRunner) {
1654
+ try {
1655
+ modelCandidates = buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider, {
1656
+ scope: modelScopes,
1657
+ primaryModelFromParent: modelOrigin === "inherited",
1658
+ origin: modelOrigin,
1659
+ }).flatMap((candidate) => {
1643
1660
  const resolved = applyThinkingSuffix(candidate, effectiveThinking, params.thinkingOverride !== undefined);
1644
1661
  return resolved ? [resolved] : [];
1645
1662
  });
1646
- if (!externalRunner) {
1647
- try {
1648
1663
  for (const candidate of modelCandidates) assertThinkingWithinCeiling({ model: candidate, configThinking: effectiveThinking, ceiling: thinkingCeiling, agent: agentConfig.name, runId: id });
1649
1664
  } catch (error) {
1650
1665
  return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
@@ -1730,7 +1745,8 @@ export function executeAsyncSingle(
1730
1745
  ...(model ? { model } : {}),
1731
1746
  ...(params.fast ?? recoveryAgentConfig.fast ? { fast: params.fast ?? recoveryAgentConfig.fast } : {}),
1732
1747
  ...(recoveryAgentConfig.modelProvider ? { modelProvider: recoveryAgentConfig.modelProvider } : {}),
1733
- ...(params.modelOverrideFromParent ? { modelOverrideFromParent: true } : {}),
1748
+ ...(modelOrigin === "inherited" ? { modelOverrideFromParent: true } : {}),
1749
+ modelOrigin,
1734
1750
  ...(recoveryAgentConfig.fallbackModels ? { fallbackModels: [...recoveryAgentConfig.fallbackModels] } : {}),
1735
1751
  ...(effectiveThinking ? { thinking: resolveEffectiveThinking(model, effectiveThinking) } : {}),
1736
1752
  ...(thinkingCeiling ? { thinkingCeiling } : {}),
@@ -1799,7 +1815,7 @@ export function executeAsyncSingle(
1799
1815
  thinking: resolveEffectiveThinking(model, effectiveThinking),
1800
1816
  ...(thinkingCeiling ? { thinkingCeiling } : {}),
1801
1817
  modelCandidates,
1802
- ...(params.modelOverrideFromParent ? { skipPrimaryModelVerification: true } : {}),
1818
+ ...(modelOrigin === "inherited" ? { skipPrimaryModelVerification: true } : {}),
1803
1819
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
1804
1820
  tools: agentConfig.tools,
1805
1821
  allowNestedSubagents: agentConfig.allowNestedSubagents,
@@ -73,7 +73,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
73
73
  const steeringNoticeSeen = new Map<string, number>();
74
74
  const jobWatchers = new Map<string, { watchers: Map<string, fs.FSWatcher>; retryTimer?: ReturnType<typeof setTimeout> }>();
75
75
  const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
76
+ let widgetRerenderTimer: ReturnType<typeof setTimeout> | undefined;
76
77
  const runningJobIds = new Set<string>();
78
+ const externalJobBridgeRuns = new Set<string>();
79
+ const externalJobBridgeEligibility = (steps: AsyncJobState["steps"]): "required" | "not-required" | "unknown" => {
80
+ if (!Array.isArray(steps)) return "unknown";
81
+ for (const step of steps) {
82
+ const runner = (step as { runner?: unknown } | null)?.runner;
83
+ if (runner === undefined) continue;
84
+ if (!runner || typeof runner !== "object" || Array.isArray(runner)) return "unknown";
85
+ const runnerType = (runner as { type?: unknown }).type;
86
+ if (typeof runnerType !== "string") return "unknown";
87
+ if (runnerType === "external-job") return "required";
88
+ if (runnerType !== "pi" && runnerType !== "external-cli") return "unknown";
89
+ }
90
+ return "not-required";
91
+ };
77
92
  let rootWatcher: fs.FSWatcher | undefined;
78
93
  let nextLivenessAt = Date.now() + livenessIntervalMs;
79
94
  let nextWidgetAnimationAt = Date.now() + WIDGET_ANIMATION_INTERVAL_MS;
@@ -94,9 +109,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
94
109
  const rerenderLastWidget = (jobs = Array.from(state.asyncJobs.values())) => {
95
110
  withLastUiContext((ctx) => rerenderWidget(ctx, jobs));
96
111
  };
112
+ const scheduleLastWidgetRerender = () => {
113
+ if (widgetRerenderTimer) return;
114
+ widgetRerenderTimer = setTimeout(() => {
115
+ widgetRerenderTimer = undefined;
116
+ rerenderLastWidget();
117
+ }, EVENT_REFRESH_DEBOUNCE_MS);
118
+ widgetRerenderTimer.unref?.();
119
+ };
97
120
  const requestLastWidgetRender = () => {
98
121
  if (options.widgetEnabled === false) return;
99
- rerenderLastWidget();
122
+ withLastUiContext((ctx) => {
123
+ if (state.widgetsSuspended) return;
124
+ const requestRender = (ctx.ui as { requestRender?: () => void }).requestRender;
125
+ if (requestRender) requestRender.call(ctx.ui);
126
+ else renderWidget(ctx, Array.from(state.asyncJobs.values()));
127
+ });
100
128
  };
101
129
  const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
102
130
  const restoredControlEventCursor = (asyncDir: string) => {
@@ -146,6 +174,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
146
174
  chainStepCount: run.chainStepCount,
147
175
  parallelGroups: groups,
148
176
  hostSteps: run.hostSteps,
177
+ ...(run.mode === "workflow" && run.workflowGraph ? { workflowGraph: run.workflowGraph } : {}),
149
178
  preflight: run.preflight,
150
179
  steps: visibleSteps,
151
180
  stepsTotal: visibleSteps.length,
@@ -339,6 +368,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
339
368
  if (timer) clearTimeout(timer);
340
369
  refreshTimers.delete(asyncId);
341
370
  runningJobIds.delete(asyncId);
371
+ externalJobBridgeRuns.delete(asyncId);
342
372
  };
343
373
 
344
374
  const refreshJob = (job: AsyncJobState): boolean => {
@@ -353,9 +383,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
353
383
  console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
354
384
  }
355
385
  };
386
+ let bridgeSweepAttempted = false;
356
387
  try {
357
388
  emitNewControlEvents(job);
358
- serviceExternalJobBridgeRequests(job.asyncDir);
389
+ const bridgeAlreadyRequired = externalJobBridgeRuns.has(job.asyncId) || externalJobBridgeEligibility(job.steps) === "required";
390
+ if (bridgeAlreadyRequired) {
391
+ externalJobBridgeRuns.add(job.asyncId);
392
+ bridgeSweepAttempted = true;
393
+ serviceExternalJobBridgeRequests(job.asyncDir);
394
+ }
359
395
  try {
360
396
  if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
361
397
  } catch (error) {
@@ -381,6 +417,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
381
417
  },
382
418
  });
383
419
  const status = reconciliation.status ?? readStatus(job.asyncDir);
420
+ if (!bridgeAlreadyRequired) {
421
+ const bridgeEligibility = externalJobBridgeEligibility(status?.steps);
422
+ if (bridgeEligibility === "required") externalJobBridgeRuns.add(job.asyncId);
423
+ // Missing or ambiguous status retains the legacy sweep for recovery races.
424
+ if (bridgeEligibility !== "not-required") {
425
+ bridgeSweepAttempted = true;
426
+ serviceExternalJobBridgeRequests(job.asyncDir);
427
+ }
428
+ }
384
429
  if (status) {
385
430
  const previousStatus = job.status;
386
431
  job.status = status.state;
@@ -401,6 +446,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
401
446
  job.workflowKey = status.workflowKey ?? job.workflowKey;
402
447
  job.lane = status.lane ?? job.lane;
403
448
  job.workflow = status.workflow ?? job.workflow;
449
+ if (status.mode === "workflow") job.workflowGraph = status.workflowGraph ?? job.workflowGraph;
404
450
  job.hostSteps = validHostStepNodes(status.workflowGraph);
405
451
  const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
406
452
  if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error("workflowChildren.workflowRunId does not match async status runId.");
@@ -454,6 +500,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
454
500
  runningJobIds.add(job.asyncId);
455
501
  }
456
502
  } catch (error) {
503
+ if (!bridgeSweepAttempted) {
504
+ try {
505
+ bridgeSweepAttempted = true;
506
+ serviceExternalJobBridgeRequests(job.asyncDir);
507
+ } catch (bridgeError) {
508
+ console.error(`Failed to service external job bridge for '${job.asyncDir}':`, bridgeError);
509
+ }
510
+ }
457
511
  if (job.status !== "failed") {
458
512
  console.error(`Failed to read async status for '${job.asyncDir}':`, error);
459
513
  job.status = "failed";
@@ -471,7 +525,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
471
525
  const timer = setTimeout(() => {
472
526
  refreshTimers.delete(asyncId);
473
527
  const job = state.asyncJobs.get(asyncId);
474
- if (job && refreshJob(job)) rerenderLastWidget();
528
+ if (job && refreshJob(job)) scheduleLastWidgetRerender();
475
529
  }, delayMs);
476
530
  timer.unref?.();
477
531
  refreshTimers.set(asyncId, timer);
@@ -602,6 +656,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
602
656
  : rawAgents;
603
657
  const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
604
658
  state.liveAsyncSessionRoots?.delete(info.id);
659
+ externalJobBridgeRuns.delete(info.id);
605
660
  state.asyncJobs.set(info.id, {
606
661
  asyncId: info.id,
607
662
  asyncDir,
@@ -627,6 +682,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
627
682
  turnBudget: info.turnBudget,
628
683
  parentWorkflowRunId: info.parentWorkflowRunId,
629
684
  workflowKey: info.workflowKey,
685
+ ...(info.mode === "workflow" && info.workflowGraph ? { workflowGraph: info.workflowGraph } : {}),
630
686
  controlEventCursor: 0,
631
687
  });
632
688
  const job = state.asyncJobs.get(info.id)!;
@@ -674,11 +730,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
674
730
  for (const asyncId of jobWatchers.keys()) closeJobWatcher(asyncId);
675
731
  for (const timer of refreshTimers.values()) clearTimeout(timer);
676
732
  refreshTimers.clear();
733
+ if (widgetRerenderTimer) clearTimeout(widgetRerenderTimer);
734
+ widgetRerenderTimer = undefined;
677
735
  runningJobIds.clear();
736
+ externalJobBridgeRuns.clear();
678
737
  };
679
738
 
680
739
  const resetJobs = (ctx?: ExtensionContext) => {
681
740
  dispose();
741
+ state.statusProjectionSessionId = null;
682
742
  for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
683
743
  state.cleanupTimers.clear();
684
744
  state.asyncJobs.clear();
@@ -695,6 +755,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
695
755
 
696
756
  const restoreActiveJobs = (ctx?: ExtensionContext) => {
697
757
  if (ctx?.hasUI) state.lastUiContext = ctx;
758
+ state.statusProjectionSessionId = null;
698
759
  if (!state.currentSessionId) return;
699
760
  let runs: AsyncRunSummary[];
700
761
  try {
@@ -710,6 +771,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
710
771
  rememberFleetJob(state, job);
711
772
  watchJob(job);
712
773
  }
774
+ state.statusProjectionSessionId = state.currentSessionId;
713
775
  if (runs.length === 0) return;
714
776
  ensurePoller();
715
777
  rerenderLastWidget();
@@ -314,7 +314,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
314
314
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
315
315
  const parsed = value as Record<string, unknown>;
316
316
  const allowedFields = new Set([
317
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
317
+ "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
318
318
  "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
319
319
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
320
320
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
@@ -347,6 +347,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
347
347
  if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
348
348
  if (parsed.context !== undefined && parsed.context !== "fresh" && parsed.context !== "fork") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': context is invalid.`);
349
349
  if (parsed.modelOverrideFromParent !== undefined && typeof parsed.modelOverrideFromParent !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOverrideFromParent must be a boolean.`);
350
+ if (parsed.modelOrigin !== undefined && parsed.modelOrigin !== "explicit" && parsed.modelOrigin !== "inherited" && parsed.modelOrigin !== "configured") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOrigin must be 'explicit', 'inherited', or 'configured'.`);
351
+ if (parsed.modelOrigin === undefined && parsed.model !== undefined) parsed.modelOrigin = parsed.modelOverrideFromParent ? "inherited" : "configured";
350
352
  for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
351
353
  if (typeof parsed[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a boolean.`);
352
354
  }