pi-subagents 0.82.0 → 0.83.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/bundle/agents.js +4 -4
  4. package/bundle/background-work.js +1 -1
  5. package/bundle/child-tool-plan.js +3 -3
  6. package/bundle/{chunk-7PCKZHR5.js → chunk-4BW55BTL.js} +6 -6
  7. package/bundle/{chunk-7IVSUMQQ.js → chunk-527E3D2J.js} +6 -6
  8. package/bundle/{chunk-NRQ75MGH.js → chunk-5ESXK62X.js} +40 -1076
  9. package/bundle/{chunk-ZQGSDHL6.js → chunk-6B4ITO2W.js} +8 -4
  10. package/bundle/{chunk-CIZQXJ5Q.js → chunk-7F5SK2U3.js} +39 -6
  11. package/bundle/{chunk-OYF6AFDY.js → chunk-BKFWQ642.js} +2 -2
  12. package/bundle/{chunk-ZLINIYTP.js → chunk-CDI3GMNV.js} +48 -32
  13. package/bundle/{chunk-GC4DSPMH.js → chunk-CGXWSNPA.js} +12 -18
  14. package/bundle/{chunk-SIJ4S3EN.js → chunk-CNLDYEH4.js} +12 -12
  15. package/bundle/{chunk-4SDU2BZX.js → chunk-CSSZRTKY.js} +2 -2
  16. package/bundle/{chunk-W2SZ22EM.js → chunk-CZTMUBUI.js} +10 -8
  17. package/bundle/{chunk-WVD4UEL4.js → chunk-HJJAVHFB.js} +28 -24
  18. package/bundle/{chunk-GJECHSAR.js → chunk-HLE22FSH.js} +2 -2
  19. package/bundle/{chunk-M2CTFYT3.js → chunk-HXVKGS33.js} +5 -5
  20. package/bundle/{chunk-5LFLVSTM.js → chunk-JEZOSEWG.js} +2 -0
  21. package/bundle/{chunk-YKEWIB4M.js → chunk-JHZ6DZ5M.js} +1 -1
  22. package/bundle/{chunk-B5EKXDHE.js → chunk-LDMXJIA6.js} +458 -657
  23. package/bundle/{chunk-43SCHIMD.js → chunk-MBDOYZ2V.js} +1 -1
  24. package/bundle/{chunk-XSWOTIA3.js → chunk-MHX22USZ.js} +1 -1
  25. package/bundle/{chunk-VH5OAWVG.js → chunk-OOFW75PV.js} +5 -5
  26. package/bundle/{chunk-RMQFMMAT.js → chunk-P2NCCTZP.js} +231 -10
  27. package/bundle/{chunk-NGGAFDL6.js → chunk-RZJN63GU.js} +12 -5
  28. package/bundle/{chunk-7YMXOYEU.js → chunk-TJQJ3MEA.js} +12 -11
  29. package/bundle/{chunk-5TYMSZX3.js → chunk-VTMNBPCQ.js} +2 -2
  30. package/bundle/{chunk-52UBALJL.js → chunk-WG2OTTAK.js} +2 -2
  31. package/bundle/{chunk-N2BN7BF5.js → chunk-XA3DY6UG.js} +7 -5
  32. package/bundle/{chunk-MHZ4G23A.js → chunk-XXKG3P2W.js} +9 -6
  33. package/bundle/{chunk-PLCCBLPY.js → chunk-Y3I6H6DV.js} +2 -2
  34. package/bundle/control-channel.js +3 -3
  35. package/bundle/index.js +1 -1
  36. package/bundle/intercom-bridge.js +3 -3
  37. package/bundle/preflight.js +9 -9
  38. package/bundle/project-panes.js +4 -4
  39. package/bundle/shared-types.js +1 -1
  40. package/docs/agents.md +6 -7
  41. package/docs/configuration.md +1 -1
  42. package/docs/extension-api.md +19 -4
  43. package/docs/observability.md +1 -0
  44. package/docs/tool-reference.md +1 -1
  45. package/package.json +1 -1
  46. package/skills/council-mode/SKILL.md +1 -4
  47. package/skills/pi-subagents/SKILL.md +130 -107
  48. package/skills/pi-subagents/references/orchestration.md +124 -0
  49. package/skills/pi-subagents/references/prompting-and-roles.md +96 -295
  50. package/src/agents/agent-management.js +8 -14
  51. package/src/agents/agent-serializer.js +1 -2
  52. package/src/agents/agents.js +19 -3
  53. package/src/api/background-work.d.ts +9 -6
  54. package/src/api/background-work.js +14 -7
  55. package/src/extension/index.js +4 -1
  56. package/src/extension/public-execution.d.ts +2 -2
  57. package/src/extension/public-execution.js +3 -3
  58. package/src/extension/reply-workflow-script.js +5 -3
  59. package/src/extension/rpc.js +3 -1
  60. package/src/extension/subagent-guide.js +1 -2
  61. package/src/extension/subagent-options.js +17 -4
  62. package/src/runs/background/async-execution.js +4 -1
  63. package/src/runs/background/async-job-tracker.js +2 -1
  64. package/src/runs/background/async-status.d.ts +2 -0
  65. package/src/runs/background/async-status.js +1 -1
  66. package/src/runs/background/auto-drain.js +2 -3
  67. package/src/runs/background/fleet-view.js +1 -1
  68. package/src/runs/background/run-child-session.js +22 -6
  69. package/src/runs/background/subagent-wait.d.ts +2 -1
  70. package/src/runs/background/subagent-wait.js +5 -2
  71. package/src/runs/background/terminal-run-index.d.ts +1 -0
  72. package/src/runs/background/terminal-run-index.js +40 -1
  73. package/src/runs/foreground/execution.js +22 -6
  74. package/src/runs/foreground/subagent-executor.js +2 -5
  75. package/src/runs/shared/child-launch.js +5 -2
  76. package/src/runs/shared/child-session.d.ts +6 -0
  77. package/src/runs/shared/child-session.js +20 -4
  78. package/src/runs/shared/herdr-machine.d.ts +2 -0
  79. package/src/runs/shared/herdr-machine.js +2 -0
  80. package/src/runs/shared/herdr-placed-run.js +11 -4
  81. package/src/runs/shared/subagent-prompt-runtime.js +11 -17
  82. package/src/shared/disabled-features.d.ts +3 -1
  83. package/src/shared/disabled-features.js +11 -3
  84. package/src/shared/types.d.ts +3 -1
  85. package/src/shared/types.js +2 -0
  86. package/src/tui/fleet.js +1 -0
  87. package/src/workflows/scripted-workflow.js +9 -1070
  88. package/workflow-script-worker.cjs +1067 -0
  89. package/skills/pi-subagents/references/constraints-and-recipes.md +0 -74
  90. package/skills/pi-subagents/references/execution-controls.md +0 -564
  91. package/skills/pi-subagents/references/management-authoring-rpc.md +0 -165
  92. package/skills/pi-subagents/references/multi-lane-orchestration.md +0 -61
  93. package/skills/pi-subagents/references/review-and-validation.md +0 -73
@@ -4,7 +4,7 @@ const MAX_PROVIDER_NAME_LENGTH = 128;
4
4
  const MAX_PROVIDERS = 100;
5
5
  const MAX_ITEM_ID_LENGTH = 256;
6
6
  /**
7
- * A Pi session id is the session file path, which routinely exceeds a short
7
+ * A session can be identified by its file path, which routinely exceeds a short
8
8
  * identity budget in nested worktrees, so it is bounded like the other paths.
9
9
  */
10
10
  const MAX_SESSION_ID_LENGTH = 4_096;
@@ -120,22 +120,29 @@ export function listBackgroundWorkWakeChannels() {
120
120
  }
121
121
  return [...channels];
122
122
  }
123
- /** Reconcile and snapshot active provider work owned by one exact Pi session. */
124
- export function snapshotBackgroundWork(sessionId, nowMs = Date.now()) {
125
- validateString(sessionId, "Background-work snapshot sessionId", MAX_SESSION_ID_LENGTH);
123
+ /**
124
+ * Reconcile and snapshot active provider work owned by one exact Pi session.
125
+ * `sessionIds` lists that session's identity forms; the first is passed to
126
+ * providers as `sessionId`, and items keyed by any of them are kept.
127
+ */
128
+ export function snapshotBackgroundWork(sessionIds, nowMs = Date.now()) {
129
+ const accepted = [...new Set(sessionIds.map((value) => validateString(value, "Background-work snapshot sessionId", MAX_SESSION_ID_LENGTH)))];
130
+ const sessionId = accepted[0];
131
+ if (sessionId === undefined)
132
+ throw new Error("Background-work snapshot needs at least one session id.");
126
133
  const providers = listBackgroundWorkProviders();
127
134
  const items = [];
128
135
  const identities = new Set();
129
136
  for (const provider of providers) {
130
137
  try {
131
- provider.reconcile?.({ sessionId, nowMs });
138
+ provider.reconcile?.({ sessionId, sessionIds: [...accepted], nowMs });
132
139
  }
133
140
  catch (error) {
134
141
  throw new Error(`Background-work provider '${provider.name}' reconcile failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
135
142
  }
136
143
  let active;
137
144
  try {
138
- active = provider.listActiveWork({ sessionId, nowMs });
145
+ active = provider.listActiveWork({ sessionId, sessionIds: [...accepted], nowMs });
139
146
  }
140
147
  catch (error) {
141
148
  throw new Error(`Background-work provider '${provider.name}' listActiveWork failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
@@ -153,7 +160,7 @@ export function snapshotBackgroundWork(sessionId, nowMs = Date.now()) {
153
160
  throw new Error(`Background-work provider '${provider.name}' returned duplicate item '${item.id}' for session '${item.sessionId}'.`);
154
161
  }
155
162
  identities.add(identity);
156
- if (item.sessionId === sessionId)
163
+ if (accepted.includes(item.sessionId))
157
164
  items.push({ provider: provider.name, ...item });
158
165
  });
159
166
  }
@@ -566,8 +566,11 @@ export default function registerSubagentExtension(pi) {
566
566
  // Pi sends custom messages to the model as plain user messages, so every model-visible
567
567
  // pi-subagents message is wrapped in a marker naming its source. The marker depends only on
568
568
  // the message, so history renders the same bytes every turn and cached prefixes stay valid.
569
+ // It runs on context_with_system, not context: Pi treats any change a context handler makes
570
+ // to the message list as a rewritten conversation and folds every mid-conversation system
571
+ // message into the leading one, which misses the prompt cache on every later turn.
569
572
  const markedMessageTypes = new Set([...SUBAGENT_MESSAGE_TYPES, "subagent-compaction-resume", SLASH_RESULT_TYPE, SLASH_TEXT_RESULT_TYPE, ADMIN_MESSAGE_TYPE]);
570
- pi.on("context", (event) => {
573
+ pi.on("context_with_system", (event) => {
571
574
  const messages = [];
572
575
  for (const message of event.messages) {
573
576
  if (message.role !== "custom" || !markedMessageTypes.has(message.customType)) {
@@ -57,8 +57,8 @@ export type PublicSubagentExecutionNormalization<T> = {
57
57
  /** A workflow value containing "/" or "\" is a script path; named workflow resource names contain neither. */
58
58
  export declare function isWorkflowScriptPath(workflow: string): boolean;
59
59
  /**
60
- * Model tool calls supply script text only through workflow: true (the reply block) or a script path.
61
- * workflowScript remains the internal carrier for slash, prompt-workflow, scheduled, RPC, and named-resource launches.
60
+ * Model tool calls supply script text through workflow: true (the reply block), a script path, or options.script,
61
+ * which flattenSubagentToolOptions lowers to workflowScript, the internal carrier for every other launch path too.
62
62
  */
63
63
  export declare function removedModelWorkflowFieldError(params: object): string | undefined;
64
64
  export declare function validateWorkflowCapacityOverrides(params: PublicSubagentExecutionParams): string | undefined;
@@ -7,8 +7,8 @@ export function isWorkflowScriptPath(workflow) {
7
7
  return workflow.includes("/") || workflow.includes("\\");
8
8
  }
9
9
  /**
10
- * Model tool calls supply script text only through workflow: true (the reply block) or a script path.
11
- * workflowScript remains the internal carrier for slash, prompt-workflow, scheduled, RPC, and named-resource launches.
10
+ * Model tool calls supply script text through workflow: true (the reply block), a script path, or options.script,
11
+ * which flattenSubagentToolOptions lowers to workflowScript, the internal carrier for every other launch path too.
12
12
  */
13
13
  export function removedModelWorkflowFieldError(params) {
14
14
  if (Object.hasOwn(params, "workflowScript"))
@@ -56,7 +56,7 @@ export function normalizePublicSubagentExecution(params, options = {}) {
56
56
  return { ok: false, error: "workflow must be true (the ```js workflow block in this reply), a script path containing '/', or a named workflow resource.", mode: "workflow" };
57
57
  }
58
58
  const hasNamedWorkflow = typeof workflow === "string" && !isWorkflowScriptPath(workflow);
59
- // workflowScript is the internal script carrier; model tool calls cannot set it.
59
+ // workflowScript is the internal script carrier; model tool calls set it only through options.script.
60
60
  if (workflow !== undefined && params.workflowScript !== undefined) {
61
61
  return { ok: false, error: "workflow cannot be combined with an internal workflowScript.", mode: "workflow" };
62
62
  }
@@ -2,7 +2,9 @@ const WORKFLOW_FENCE = /^```(?:js|javascript) workflow[ \t]*$/;
2
2
  const PLAIN_JS_FENCE = /^```(?:js|javascript)[ \t]*$/;
3
3
  const OPEN_FENCE = /^(`{3,}|~{3,})/;
4
4
  const CLOSE_FENCE = /^(`{3,}|~{3,})[ \t]*$/;
5
- const FILE_FALLBACK = "Or write the script to a file and pass its path, such as workflow: \"./script.js\".";
5
+ // Through the Claude Agent SDK (pi-claude-bridge#35), text written after a tool call in the same turn reaches the session
6
+ // only as a server summary, which drops fenced blocks; retrying the block fails again. Tool arguments arrive intact.
7
+ const SUMMARY_NOTE = "On some hosts, text written after a tool call in the same turn reaches the session only as a summary without code blocks, so for the rest of this turn pass the script's source as a tool argument: options: { script: \"...\" } in place of workflow: true, with the call's other fields unchanged.";
6
8
  /**
7
9
  * Pi persists the whole assistant message before running its tool calls, so the
8
10
  * message that issued this subagent call is on the branch and carries the script.
@@ -28,7 +30,7 @@ function scriptFromReply(content) {
28
30
  if (replyCalls > 1)
29
31
  return { error: `This reply has ${replyCalls} subagent calls with workflow: true; a reply can carry only one. Pass other scripts as workflow file paths.` };
30
32
  if (!content.some((block) => block.type === "text"))
31
- return { error: "The assistant message that issued this call has no text in the session, so the reply's visible text, including any ```js workflow block, never reached it. Write the script to a file and pass its path, such as workflow: \"./script.js\"." };
33
+ return { error: `The assistant message that issued this call has no text in the session, so the reply's visible text, including any \`\`\`js workflow block, never reached it. ${SUMMARY_NOTE}` };
32
34
  const text = content.flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []).join("\n");
33
35
  const { tagged, plain, unclosed } = workflowBlocks(text);
34
36
  if (unclosed)
@@ -37,7 +39,7 @@ function scriptFromReply(content) {
37
39
  const blocks = tagged.length > 0 ? tagged : plain.length === 1 ? plain : [];
38
40
  if (blocks.length !== 1) {
39
41
  const untagged = tagged.length === 0 && plain.length > 1 ? ` This reply has ${plain.length} untagged js blocks; open the script's block with the line "\`\`\`js workflow".` : "";
40
- return { error: `workflow: true needs one fenced block in the same reply as the tool call, opened with the exact line "\`\`\`js workflow" and closed with "\`\`\`"; found ${tagged.length}.${untagged} ${FILE_FALLBACK}` };
42
+ return { error: `workflow: true needs one fenced block in the same reply as the tool call, opened with the exact line "\`\`\`js workflow" and closed with "\`\`\`"; found ${tagged.length}.${untagged} If the block is in your reply: ${SUMMARY_NOTE}` };
41
43
  }
42
44
  const script = blocks[0];
43
45
  if (!script.trim())
@@ -5,7 +5,7 @@ import { deliverAsyncRunStop } from "../runs/foreground/async-stop-action.js";
5
5
  import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.js";
6
6
  import { resolveCurrentSessionId } from "../shared/session-identity.js";
7
7
  import { ownerSessionIds, ownsRun } from "../runs/shared/run-ownership.js";
8
- import { DIRS, SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_CHILD_STATUS_EVENT, SUBAGENT_PROCESS_TERMINAL_EVENT, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION, } from "../shared/types.js";
8
+ import { DIRS, SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_ASYNC_STARTED_EVENT, SUBAGENT_CHILD_STATUS_EVENT, SUBAGENT_JOBS_CHANGED_EVENT, SUBAGENT_PROCESS_TERMINAL_EVENT, SUBAGENT_LIFECYCLE_ARTIFACT_VERSION, } from "../shared/types.js";
9
9
  import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text.js";
10
10
  import { readStatus } from "../shared/utils.js";
11
11
  import { SubagentFlatParams } from "./schemas.js";
@@ -336,8 +336,10 @@ function pingData(ctx, options) {
336
336
  request: SUBAGENT_RPC_REQUEST_EVENT,
337
337
  replyPrefix: SUBAGENT_RPC_REPLY_EVENT_PREFIX,
338
338
  asyncComplete: SUBAGENT_ASYNC_COMPLETE_EVENT,
339
+ asyncStarted: SUBAGENT_ASYNC_STARTED_EVENT,
339
340
  childStatus: SUBAGENT_CHILD_STATUS_EVENT,
340
341
  processTerminal: SUBAGENT_PROCESS_TERMINAL_EVENT,
342
+ jobsChanged: SUBAGENT_JOBS_CHANGED_EVENT,
341
343
  },
342
344
  session: sessionData(ctx),
343
345
  };
@@ -19,11 +19,10 @@ export const SUBAGENT_GUIDE_TOPICS = [
19
19
  const SUBAGENT_GUIDE_SECTION_MAX_CHARS = 8000;
20
20
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
21
21
  // /council must work under `pi --no-skills`, which drops the package skills from context,
22
- // so this topic serves the council skill together with the references it tells the model to read.
22
+ // so this topic serves the council skill together with the reference it tells the model to read.
23
23
  const COUNCIL_FILES = [
24
24
  "skills/council-mode/SKILL.md",
25
25
  "skills/council-mode/references/pass-contracts.md",
26
- "skills/pi-subagents/references/execution-controls.md",
27
26
  ];
28
27
  function isGuideTopic(value) {
29
28
  return SUBAGENT_GUIDE_TOPICS.includes(value);
@@ -1,5 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { Compile } from "typebox/compile";
3
+ import { Value } from "typebox/value";
3
4
  import { closestMatch } from "../shared/edit-distance.js";
4
5
  import { removedModelWorkflowFieldError } from "./public-execution.js";
5
6
  import { SUBAGENT_OPTION_KEYS, SubagentOptionParams, createSubagentParamsSchema } from "./schemas.js";
@@ -22,6 +23,15 @@ export function flattenSubagentToolOptions(params, disabled) {
22
23
  const removedField = removedModelWorkflowFieldError(options);
23
24
  if (removedField)
24
25
  throw new Error(removedField);
26
+ // options.script carries script text as a tool argument for hosts that drop the reply's ```js block (SUMMARY_NOTE).
27
+ const { script, ...rest } = options;
28
+ if (script !== undefined) {
29
+ if (typeof script !== "string" || !script.trim())
30
+ throw new Error("subagent: options.script must be the workflow script's source text.");
31
+ if (topLevel.workflow !== undefined)
32
+ throw new Error("subagent: options.script replaces workflow; pass only one of them.");
33
+ return { ...flattenSubagentToolOptions({ ...topLevel, options: rest }, disabled), workflowScript: script };
34
+ }
25
35
  const entries = Object.entries(options);
26
36
  const enabledKeys = SUBAGENT_OPTION_KEYS.filter((key) => !disabled?.params.has(key));
27
37
  const unknown = entries.map(([key]) => key).filter((key) => !optionKeys.has(key));
@@ -40,12 +50,15 @@ export function flattenSubagentToolOptions(params, disabled) {
40
50
  const disabledKey = entries.find(([key]) => disabled?.params.has(key))?.[0];
41
51
  if (disabledKey)
42
52
  throw new Error(`subagent option 'options.${disabledKey}' is disabled by config ${disabled.params.get(disabledKey)}.`);
43
- if (!optionsValidator.Check(options)) {
44
- const errors = [...optionsValidator.Errors(options)].slice(0, 4).map((error) => `options${error.instancePath.replaceAll("/", ".")} ${error.message}`);
53
+ // Pi converts only the declared top-level parameters ("true" -> true); `options` is an untyped bag in the
54
+ // tool schema, so its values get the same conversion here. Some providers send nested scalars as strings.
55
+ const converted = Value.Convert(SubagentOptionParams, structuredClone(options));
56
+ if (!optionsValidator.Check(converted)) {
57
+ const errors = [...optionsValidator.Errors(converted)].slice(0, 4).map((error) => `options${error.instancePath.replaceAll("/", ".")} ${error.message}`);
45
58
  throw new Error(`subagent: ${errors.join("; ")}.`);
46
59
  }
47
- const conflicts = entries.filter(([key, value]) => topLevel[key] !== undefined && !isDeepStrictEqual(topLevel[key], value)).map(([key]) => `'${key}'`);
60
+ const conflicts = Object.entries(converted).filter(([key, value]) => topLevel[key] !== undefined && !isDeepStrictEqual(topLevel[key], value)).map(([key]) => `'${key}'`);
48
61
  if (conflicts.length > 0)
49
62
  throw new Error(`subagent: ${conflicts.join(", ")} given both at the top level and in options with different values; give each once.`);
50
- return { ...topLevel, ...options };
63
+ return { ...topLevel, ...converted };
51
64
  }
@@ -13,7 +13,7 @@ import { childCacheRetentionEnv } from "../../shared/child-cache-retention.js";
13
13
  import { buildEffectiveSystemPrompt } from "../shared/effective-system-prompt.js";
14
14
  import { currentCompletionOwnerId } from "../../shared/completion-owner.js";
15
15
  import { planChildLaunch, projectChainOutputSchemas, resolveStepBehavior, suppressProgressForReadOnlyTask } from "../shared/child-launch-plan.js";
16
- import { formatHerdrMachineRunnerUnsupported, resolveMachinePlacement } from "../shared/herdr-machine.js";
16
+ import { formatHerdrMachineRunnerUnsupported, PLACED_RESTORE_UNSUPPORTED, resolveMachinePlacement } from "../shared/herdr-machine.js";
17
17
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/child-tool-plan.js";
18
18
  import { assertClaudeCodeModelScope, isClaudeCodeAdapterId, resolveClaudeCodeOverride } from "../shared/claude-code-adapter.js";
19
19
  import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.js";
@@ -1612,6 +1612,9 @@ export function executeAsyncSingle(id, params) {
1612
1612
  const machineUnsupported = formatHerdrMachineRunnerUnsupported({ machine: requestedMachine, agentName: agentConfig.name, runnerType: agentConfig.runner?.type, adapter: agentConfig.runner?.type === "external-cli" ? agentConfig.runner.adapter : undefined, worktree: params.worktree });
1613
1613
  if (machineUnsupported)
1614
1614
  return formatAsyncStartError("single", machineUnsupported);
1615
+ // Refuse before the runner starts; the placed launch would refuse only after the run exists.
1616
+ if (requestedMachine && params.revivalLease)
1617
+ return formatAsyncStartError("single", PLACED_RESTORE_UNSUPPORTED);
1615
1618
  const resolvedLauncher = resolveAgentRunnerLauncher(agentConfig, ctx.runnerLaunchers, requestedMachine);
1616
1619
  if (resolvedLauncher.error)
1617
1620
  return formatAsyncStartError("single", resolvedLauncher.error);
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { renderWidget, widgetRenderKey } from "../../tui/render.js";
4
4
  import { formatControlNoticeMessage } from "../shared/subagent-control.js";
5
- import { DIRS, SUBAGENT_CHILD_STATUS_EVENT, SUBAGENT_CONTROL_EVENT, SUBAGENT_CONTROL_INTERCOM_EVENT, SUBAGENT_STEERING_NOTICE_EVENT, WIDGET_ANIMATION_INTERVAL_MS, } from "../../shared/types.js";
5
+ import { DIRS, SUBAGENT_CHILD_STATUS_EVENT, SUBAGENT_CONTROL_EVENT, SUBAGENT_CONTROL_INTERCOM_EVENT, SUBAGENT_JOBS_CHANGED_EVENT, SUBAGENT_STEERING_NOTICE_EVENT, WIDGET_ANIMATION_INTERVAL_MS, } from "../../shared/types.js";
6
6
  import { readStatus, resolveWatchPath } from "../../shared/utils.js";
7
7
  import { normalizeParallelGroups } from "./parallel-groups.js";
8
8
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.js";
@@ -63,6 +63,7 @@ export function createAsyncJobTracker(pi, state, asyncDirRoot, options = {}) {
63
63
  };
64
64
  const rerenderWidget = (ctx, jobs = Array.from(state.asyncJobs.values())) => {
65
65
  options.onJobsChanged?.();
66
+ pi.events.emit(SUBAGENT_JOBS_CHANGED_EVENT, { version: 1 });
66
67
  if (state.widgetsSuspended)
67
68
  return;
68
69
  renderWidget(ctx, options.widgetEnabled === false ? [] : jobs, options.widgetCollapsed, options.widgetLayout);
@@ -144,6 +144,8 @@ interface AsyncRunListOptions {
144
144
  repairScan?: boolean;
145
145
  /** This process's completion owner, adopted by an owner-exited workflow repair of a run the session owns; needs `sessionId`. */
146
146
  adoptCompletionOwnerId?: string;
147
+ /** Receives the warning for a skipped corrupt terminal status, so an open TUI can show it as a notice instead of console output. */
148
+ onCorruptStatus?: (warning: string) => void;
147
149
  }
148
150
  type TargetedAsyncRunResolution = {
149
151
  kind: "exact";
@@ -404,7 +404,7 @@ export function listAsyncRuns(asyncDirRoot, options = {}, observeStatus) {
404
404
  }
405
405
  if (wantsTerminal) {
406
406
  for (const sessionId of owners ?? [undefined]) {
407
- for (const entry of readRecentTerminalRunIndex(asyncDirRoot, { sessionId, ...(options.entryLimit !== undefined ? { limit: options.entryLimit } : {}) }))
407
+ for (const entry of readRecentTerminalRunIndex(asyncDirRoot, { sessionId, onCorruptStatus: options.onCorruptStatus, ...(options.entryLimit !== undefined ? { limit: options.entryLimit } : {}) }))
408
408
  indexed.add(entry);
409
409
  }
410
410
  }
@@ -1,6 +1,5 @@
1
- import { snapshotBackgroundWork } from "../../api/background-work.js";
2
1
  import { listAsyncRuns } from "./async-status.js";
3
- import { waitForSubagents, waitRunScopes } from "./subagent-wait.js";
2
+ import { backgroundWorkForSession, waitForSubagents, waitRunScopes } from "./subagent-wait.js";
4
3
  export const DEFAULT_AUTO_DRAIN_TIMEOUT_MS = 30 * 60 * 1000;
5
4
  function resultText(value) {
6
5
  return value.content.map((part) => part.type === "text" ? part.text : "").join(" ").trim();
@@ -14,7 +13,7 @@ function hasOutstandingWork(deps, sessionId, nowMs, observation) {
14
13
  includeNested: false,
15
14
  }, observation?.status));
16
15
  const detachedForeground = [...(deps.state.foregroundRuns?.values() ?? [])].some((run) => run.sessionId === sessionId && run.children.some((child) => child.status === "detached"));
17
- return asyncRuns.length > 0 || snapshotBackgroundWork(sessionId, nowMs).items.length > 0 || detachedForeground;
16
+ return asyncRuns.length > 0 || backgroundWorkForSession(deps, nowMs).items.length > 0 || detachedForeground;
18
17
  }
19
18
  /** Drain all work owned by the current headless session, including work added while draining. */
20
19
  export async function drainOutstandingWork(deps, observation) {
@@ -164,7 +164,7 @@ function sessionMessageParts(record) {
164
164
  }
165
165
  if (entry.type === "toolCall" || entry.type === "tool_call") {
166
166
  const name = typeof entry.name === "string" ? entry.name : typeof entry.toolName === "string" ? entry.toolName : "tool";
167
- parts.push({ role, kind: "toolCall", name, text: `[tool: ${name}${entry.args === undefined ? "" : ` ${stringifyJsonPreview(entry.args)}`}]` });
167
+ parts.push({ role, kind: "toolCall", name, text: `[tool: ${name}${entry.arguments === undefined ? "" : ` ${stringifyJsonPreview(entry.arguments)}`}]` });
168
168
  continue;
169
169
  }
170
170
  if (entry.type === "toolResult" || entry.type === "tool_result") {
@@ -69,6 +69,8 @@ export function runChildSession(input) {
69
69
  let queuedDrainHold = false;
70
70
  let compactionStartedReceived = false;
71
71
  let afterCompactionSettlement = false;
72
+ let terminalAgentEndReceived = false;
73
+ let compactionAfterFinalOpen = false;
72
74
  let finalDrainTimer;
73
75
  let finalHardFinishTimer;
74
76
  let watchdogTailTimer;
@@ -216,7 +218,8 @@ export function runChildSession(input) {
216
218
  finalDrainTimer = setTimeout(() => {
217
219
  if (settled || promptSettled)
218
220
  return;
219
- if (input.launch.capture.finalDrainHeld() || observeQueuedDrainHold()) {
221
+ // Pi runs auto-compaction after the final agent_end and only then emits agent_settled.
222
+ if (input.launch.capture.finalDrainHeld() || observeQueuedDrainHold() || compactionAfterFinalOpen) {
220
223
  finalDrainTimer = undefined;
221
224
  armFinalDrainTimer();
222
225
  return;
@@ -231,16 +234,22 @@ export function runChildSession(input) {
231
234
  return;
232
235
  settle(undefined, true);
233
236
  }, HARD_FINISH_MS);
234
- finalHardFinishTimer.unref?.();
235
237
  }, FINAL_STOP_GRACE_MS);
236
- finalDrainTimer.unref?.();
237
238
  }
238
- const applyChildLifecycle = (action) => {
239
+ const applyChildLifecycle = (action, event) => {
239
240
  if (action === "cancel-drain") {
240
- cleanTerminalAssistantStopReceived = false;
241
+ // After a clean final stop, a non-retrying agent_end may be the child's last event
242
+ // (Pi may not emit agent_settled), so it re-arms the drain instead of cancelling it.
243
+ terminalAgentEndReceived = event?.type === "agent_end" && event.willRetry !== true && cleanTerminalAssistantStopReceived;
241
244
  agentSettledReceived = false;
242
245
  clearFinalDrainTimers();
243
246
  clearWatchdogTailTimer();
247
+ if (terminalAgentEndReceived) {
248
+ startFinalDrain();
249
+ return;
250
+ }
251
+ cleanTerminalAssistantStopReceived = false;
252
+ compactionAfterFinalOpen = false;
244
253
  return;
245
254
  }
246
255
  if (action === "start-drain")
@@ -314,11 +323,18 @@ export function runChildSession(input) {
314
323
  compactionStartedReceived = true;
315
324
  if (agentSettledReceived)
316
325
  afterCompactionSettlement = true;
326
+ if (terminalAgentEndReceived)
327
+ compactionAfterFinalOpen = true;
317
328
  }
318
329
  if (event.type === "compaction_end" && event.willRetry === true) {
319
330
  compactionStartedReceived = false;
320
331
  afterCompactionSettlement = false;
321
332
  }
333
+ if (event.type === "compaction_end" && event.willRetry !== true && compactionAfterFinalOpen) {
334
+ compactionAfterFinalOpen = false;
335
+ clearFinalDrainTimers();
336
+ startFinalDrain();
337
+ }
322
338
  if (event.type === "turn_start" || event.type === "agent_start" || event.type === "auto_retry_start") {
323
339
  queuedDrainHold = false;
324
340
  }
@@ -331,7 +347,7 @@ export function runChildSession(input) {
331
347
  agentSettledReceived = true;
332
348
  afterCompactionSettlement = compactionStartedReceived;
333
349
  }
334
- applyChildLifecycle(lifecycleAction);
350
+ applyChildLifecycle(lifecycleAction, event);
335
351
  if (isChildWatchdogStatusEvent(event)) {
336
352
  if (!input.childWatchdog)
337
353
  return;
@@ -100,7 +100,7 @@ export interface SubagentWaitDeps {
100
100
  };
101
101
  /** Injectable provider protocol surfaces for deterministic tests. */
102
102
  backgroundWork?: {
103
- snapshot(sessionId: string, nowMs: number): BackgroundWorkSnapshot;
103
+ snapshot(sessionIds: readonly string[], nowMs: number): BackgroundWorkSnapshot;
104
104
  wakeChannels(): readonly string[];
105
105
  };
106
106
  /**
@@ -111,6 +111,7 @@ export interface SubagentWaitDeps {
111
111
  */
112
112
  events?: WaitEventBus;
113
113
  }
114
+ export declare function backgroundWorkForSession(deps: Pick<SubagentWaitDeps, "state" | "backgroundWork">, nowMs: number): BackgroundWorkSnapshot;
114
115
  export declare function waitRunScopes(deps: Pick<SubagentWaitDeps, "asyncDirRoot" | "resultsDir" | "nestedRootRunId">): Array<{
115
116
  asyncDirRoot: string;
116
117
  resultsDir: string;
@@ -181,11 +181,14 @@ function hasSupervisorTool(run) {
181
181
  function backgroundWorkIdentity(item) {
182
182
  return `${item.provider}\0${item.sessionId}\0${item.id}`;
183
183
  }
184
- function backgroundWorkForSession(deps, nowMs) {
184
+ export function backgroundWorkForSession(deps, nowMs) {
185
185
  const sessionId = deps.state.currentSessionId;
186
186
  if (!sessionId)
187
187
  throw new Error("bg_wait requires an active session identity to scope background work safely.");
188
- return deps.backgroundWork?.snapshot(sessionId, nowMs) ?? snapshotBackgroundWork(sessionId, nowMs);
188
+ // Providers may key items by the session file path or by getSessionId(); accept both.
189
+ const piSessionId = deps.state.supervisorOwnerSessionId;
190
+ const sessionIds = piSessionId ? [sessionId, piSessionId] : [sessionId];
191
+ return deps.backgroundWork?.snapshot(sessionIds, nowMs) ?? snapshotBackgroundWork(sessionIds, nowMs);
189
192
  }
190
193
  export function waitRunScopes(deps) {
191
194
  return [
@@ -6,4 +6,5 @@ export declare function readRecentTerminalRunIndex(asyncDirRoot: string, options
6
6
  sessionId?: string;
7
7
  limit?: number;
8
8
  endedAfter?: number;
9
+ onCorruptStatus?: (warning: string) => void;
9
10
  }): string[];
@@ -12,6 +12,35 @@ const TOOL_CALL_INDEX_DIR = "@tool-calls";
12
12
  function isTerminalState(state) {
13
13
  return state === "complete" || state === "failed" || state === "partial" || state === "paused" || state === "stopped" || state === "rejected";
14
14
  }
15
+ function isCorruptStatusError(error, statusPath) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return message.startsWith(`Failed to parse async status file '${statusPath}'`)
18
+ || message.startsWith(`Failed to validate async status file '${statusPath}'`);
19
+ }
20
+ // A corrupt indexed status is retried on every read. Emit a warning only when
21
+ // its observed file identity or parse outcome changes, like active-run diagnostics.
22
+ const reportedCorruptTerminalStatus = new Map();
23
+ function warnCorruptTerminalStatus(runId, statusPath, error, onCorruptStatus) {
24
+ let fileSignature = "unavailable";
25
+ try {
26
+ const stat = fs.statSync(statusPath);
27
+ fileSignature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino}`;
28
+ }
29
+ catch (statError) {
30
+ if (statError.code !== "ENOENT")
31
+ throw statError;
32
+ }
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ const signature = `${fileSignature}\n${message}`;
35
+ if (reportedCorruptTerminalStatus.get(statusPath) === signature)
36
+ return;
37
+ reportedCorruptTerminalStatus.set(statusPath, signature);
38
+ const warning = `Skipping indexed terminal run '${runId}' with corrupt status at '${statusPath}': ${message}`;
39
+ if (onCorruptStatus)
40
+ onCorruptStatus(warning);
41
+ else
42
+ console.error(`[pi-subagents] ${warning}`);
43
+ }
15
44
  function indexRoot(asyncDirRoot) {
16
45
  return path.join(asyncDirRoot, TERMINAL_RUN_INDEX_DIR);
17
46
  }
@@ -154,7 +183,17 @@ export function readRecentTerminalRunIndex(asyncDirRoot, options = {}) {
154
183
  continue;
155
184
  }
156
185
  const asyncDir = path.join(asyncDirRoot, entry.runId);
157
- const status = readStatus(asyncDir);
186
+ const statusPath = path.join(asyncDir, "status.json");
187
+ let status;
188
+ try {
189
+ status = readStatus(asyncDir);
190
+ }
191
+ catch (error) {
192
+ if (!isCorruptStatusError(error, statusPath))
193
+ throw error;
194
+ warnCorruptTerminalStatus(entry.runId, statusPath, error, options.onCorruptStatus);
195
+ continue;
196
+ }
158
197
  if (!status || !isTerminalState(status.state) || status.sessionId !== entry.sessionId || (status.runId && status.runId !== entry.runId)) {
159
198
  removeInvalidMarker(marker);
160
199
  continue;
@@ -561,6 +561,8 @@ async function runSingleAttempt(runtimeCwd, agent, task, model, options, shared)
561
561
  let agentSettledReceived = false;
562
562
  let queuedDrainHold = false;
563
563
  let compactionStartedReceived = false;
564
+ let terminalAgentEndReceived = false;
565
+ let compactionAfterFinalOpen = false;
564
566
  let finalDrainTimer;
565
567
  let finalHardFinishTimer;
566
568
  let watchdogTailTimer;
@@ -608,7 +610,8 @@ async function runSingleAttempt(runtimeCwd, agent, task, model, options, shared)
608
610
  finalDrainTimer = setTimeout(() => {
609
611
  if (lifecycleFinished || sessionSettled)
610
612
  return;
611
- if (capture.finalDrainHeld() || observeQueuedDrainHold()) {
613
+ // Pi runs auto-compaction after the final agent_end and only then emits agent_settled.
614
+ if (capture.finalDrainHeld() || observeQueuedDrainHold() || compactionAfterFinalOpen) {
612
615
  finalDrainTimer = undefined;
613
616
  armFinalDrainTimer();
614
617
  return;
@@ -623,9 +626,7 @@ async function runSingleAttempt(runtimeCwd, agent, task, model, options, shared)
623
626
  return;
624
627
  settle(undefined, true);
625
628
  }, HARD_FINISH_MS);
626
- finalHardFinishTimer.unref?.();
627
629
  }, FINAL_STOP_GRACE_MS);
628
- finalDrainTimer.unref?.();
629
630
  };
630
631
  function armWatchdogTail() {
631
632
  if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || lifecycleFinished || sessionSettled)
@@ -644,12 +645,20 @@ async function runSingleAttempt(runtimeCwd, agent, task, model, options, shared)
644
645
  }, childWatchdog?.watchdogTailTimeoutMs ?? 120_000);
645
646
  watchdogTailTimer.unref?.();
646
647
  }
647
- const applyChildLifecycle = (action) => {
648
+ const applyChildLifecycle = (action, event) => {
648
649
  if (action === "cancel-drain") {
649
- cleanTerminalAssistantStopReceived = false;
650
+ // After a clean final stop, a non-retrying agent_end may be the child's last event
651
+ // (Pi may not emit agent_settled), so it re-arms the drain instead of cancelling it.
652
+ terminalAgentEndReceived = event?.type === "agent_end" && event.willRetry !== true && cleanTerminalAssistantStopReceived;
650
653
  agentSettledReceived = false;
651
654
  clearFinalDrainTimers();
652
655
  clearWatchdogTailTimer();
656
+ if (terminalAgentEndReceived) {
657
+ startFinalDrain();
658
+ return;
659
+ }
660
+ cleanTerminalAssistantStopReceived = false;
661
+ compactionAfterFinalOpen = false;
653
662
  return;
654
663
  }
655
664
  if (action === "start-drain")
@@ -925,11 +934,18 @@ async function runSingleAttempt(runtimeCwd, agent, task, model, options, shared)
925
934
  compactionStartedReceived = true;
926
935
  if (agentSettledReceived)
927
936
  afterCompactionSettlement = true;
937
+ if (terminalAgentEndReceived)
938
+ compactionAfterFinalOpen = true;
928
939
  }
929
940
  if (evt.type === "compaction_end" && evt.willRetry === true) {
930
941
  compactionStartedReceived = false;
931
942
  afterCompactionSettlement = false;
932
943
  }
944
+ if (evt.type === "compaction_end" && evt.willRetry !== true && compactionAfterFinalOpen) {
945
+ compactionAfterFinalOpen = false;
946
+ clearFinalDrainTimers();
947
+ startFinalDrain();
948
+ }
933
949
  if (evt.type === "turn_start" || evt.type === "agent_start" || evt.type === "auto_retry_start") {
934
950
  queuedDrainHold = false;
935
951
  }
@@ -956,7 +972,7 @@ async function runSingleAttempt(runtimeCwd, agent, task, model, options, shared)
956
972
  agentSettledReceived = true;
957
973
  afterCompactionSettlement = compactionStartedReceived;
958
974
  }
959
- applyChildLifecycle(lifecycleAction);
975
+ applyChildLifecycle(lifecycleAction, evt);
960
976
  if (isChildWatchdogStatusEvent(evt)) {
961
977
  if (!childWatchdog)
962
978
  return;
@@ -17,7 +17,7 @@ import { buildDoctorReport } from "../../extension/doctor.js";
17
17
  import { readSubagentGuide } from "../../extension/subagent-guide.js";
18
18
  import { isWorkflowScriptPath, normalizePublicSubagentExecution, validateWorkflowCapacityOverrides } from "../../extension/public-execution.js";
19
19
  import { readReplyWorkflowScript } from "../../extension/reply-workflow-script.js";
20
- import { disabledFeatureNotice, disabledFeatureUseError, resolveDisabledFeatureSurface } from "../../shared/disabled-features.js";
20
+ import { disabledFeatureNotice, disabledFeatureSectionNotice, disabledFeatureUseError, resolveDisabledFeatureSurface } from "../../shared/disabled-features.js";
21
21
  import { runSync } from "./execution.js";
22
22
  import { commandAction } from "./command-action.js";
23
23
  import { handleWatchdogToolAction, WATCHDOG_TOOL_ACTIONS } from "../../watchdog/tool-actions.js";
@@ -6689,10 +6689,7 @@ export function createSubagentExecutor(deps) {
6689
6689
  if (action === "guide") {
6690
6690
  try {
6691
6691
  const guide = readSubagentGuide(paramsWithResolvedCwd.topic);
6692
- const [topic, section] = paramsWithResolvedCwd.topic?.split("/") ?? [];
6693
- const fullNotice = topic === "tool-reference" ? disabledFeatureNotice(disabledFeatures) : undefined;
6694
- // Sections are capped at 8,000 chars, so they get a one-line pointer instead of the full list.
6695
- const notice = fullNotice && section !== undefined ? "Some options and actions documented here are disabled by config in this session; guide topic tool-reference lists them." : fullNotice;
6692
+ const notice = paramsWithResolvedCwd.topic?.includes("/") ? disabledFeatureSectionNotice(disabledFeatures) : disabledFeatureNotice(disabledFeatures);
6696
6693
  return {
6697
6694
  content: [{ type: "text", text: notice ? `${notice}\n\n${guide}` : guide }],
6698
6695
  details: { mode: "management", results: [] },
@@ -45,7 +45,7 @@ function escapeXmlAttr(value) {
45
45
  function inheritedCapabilityCeiling(inherited) {
46
46
  return inherited?.capabilityCeiling;
47
47
  }
48
- /** Environment values external child extensions read; only the runner applies them. */
48
+ /** Environment values external child extensions read in the runner, which hosts only children. */
49
49
  function childProcessEnv(input, toolPlan) {
50
50
  const env = {};
51
51
  env[PI_SUBAGENT_EXTENSION_BINDINGS_ENV] = encodeExtensionBindings(input.extensionBindings);
@@ -222,7 +222,10 @@ export function buildInProcessChildLaunch(input) {
222
222
  requiredExtensions: toolPlan.requiredExtensions,
223
223
  ambientExtensions,
224
224
  hooks: capturedHooks.hooks,
225
- ...(input.host === "runner" ? { processEnv: childProcessEnv(input, toolPlan) } : {}),
225
+ // A foreground child gets only its own bindings, so it never sees those of the session that launched it.
226
+ ...(input.host === "runner"
227
+ ? { processEnv: childProcessEnv(input, toolPlan) }
228
+ : { processEnv: { [PI_SUBAGENT_EXTENSION_BINDINGS_ENV]: encodeExtensionBindings(input.extensionBindings) }, restoreProcessEnv: true }),
226
229
  runtime: config,
227
230
  noSkills: !input.inheritSkills,
228
231
  noContextFiles: !input.inheritProjectContext,
@@ -80,6 +80,12 @@ export interface ChildSessionLaunch {
80
80
  * window one at a time. An undefined value removes the variable.
81
81
  */
82
82
  processEnv?: Record<string, string | undefined>;
83
+ /**
84
+ * Restore the hosting process's previous values of `processEnv` once that
85
+ * window ends, so a child hosted in the parent's own process leaves the
86
+ * parent's environment as it found it.
87
+ */
88
+ restoreProcessEnv?: boolean;
83
89
  /** The typed runtime config the hooks were built from; informational for factories. */
84
90
  runtime: ChildRuntimeConfig;
85
91
  onExtensionError?: (error: ChildSessionExtensionError) => void;