pi-subagents 0.83.0 → 0.84.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 (73) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/bundle/agents.js +4 -4
  3. package/bundle/capability-ceiling.js +1 -1
  4. package/bundle/child-tool-plan.js +3 -3
  5. package/bundle/{chunk-HJJAVHFB.js → chunk-235AWJUQ.js} +35 -32
  6. package/bundle/{chunk-P2NCCTZP.js → chunk-2D52JFLF.js} +1 -1
  7. package/bundle/{chunk-WG2OTTAK.js → chunk-2DHZTCOL.js} +2 -2
  8. package/bundle/{chunk-MBDOYZ2V.js → chunk-2S4K53MV.js} +1 -1
  9. package/bundle/{chunk-527E3D2J.js → chunk-4E7BKDTX.js} +5 -5
  10. package/bundle/{chunk-CZTMUBUI.js → chunk-7BWGNDNK.js} +4 -4
  11. package/bundle/{chunk-HXVKGS33.js → chunk-AKOIVLA5.js} +4 -4
  12. package/bundle/{chunk-MHX22USZ.js → chunk-C5P5CHYK.js} +1 -1
  13. package/bundle/{chunk-5ESXK62X.js → chunk-CGN2TVE7.js} +417 -389
  14. package/bundle/{chunk-VTMNBPCQ.js → chunk-D3JAFGMO.js} +1 -1
  15. package/bundle/{chunk-CDI3GMNV.js → chunk-DZDETGRJ.js} +18 -15
  16. package/bundle/{chunk-BKFWQ642.js → chunk-F4ASL6OV.js} +1 -1
  17. package/bundle/{chunk-JHZ6DZ5M.js → chunk-G2Z6ZH26.js} +1 -1
  18. package/bundle/{chunk-XXKG3P2W.js → chunk-J5YL7LYR.js} +4 -4
  19. package/bundle/{chunk-LDMXJIA6.js → chunk-JG4QKI5M.js} +58 -67
  20. package/bundle/{chunk-OOFW75PV.js → chunk-L3WFKBEI.js} +15 -11
  21. package/bundle/{chunk-Y3I6H6DV.js → chunk-LFDBD4PW.js} +2 -2
  22. package/bundle/{chunk-7F5SK2U3.js → chunk-LNRJCP2Y.js} +17 -14
  23. package/bundle/{chunk-4BW55BTL.js → chunk-NENDSQUY.js} +4 -4
  24. package/bundle/{chunk-6B4ITO2W.js → chunk-NKQ6JNAS.js} +1 -1
  25. package/bundle/{chunk-RZJN63GU.js → chunk-PALJGGYC.js} +2 -2
  26. package/bundle/{chunk-TJQJ3MEA.js → chunk-PV63UFUR.js} +10 -10
  27. package/bundle/{chunk-Z7KVHQHL.js → chunk-QXASAHIA.js} +2 -2
  28. package/bundle/{chunk-HLE22FSH.js → chunk-SSG7IEP5.js} +2 -2
  29. package/bundle/{chunk-CSSZRTKY.js → chunk-V2BZYLEP.js} +2 -2
  30. package/bundle/{chunk-CNLDYEH4.js → chunk-VQVVHSY6.js} +11 -11
  31. package/bundle/{chunk-CGXWSNPA.js → chunk-XDQORLZ5.js} +4 -4
  32. package/bundle/control-channel.js +3 -3
  33. package/bundle/index.js +1 -1
  34. package/bundle/intercom-bridge.js +3 -3
  35. package/bundle/preflight.js +9 -9
  36. package/bundle/project-panes.js +4 -4
  37. package/docs/configuration.md +12 -1
  38. package/docs/extension-api.md +2 -0
  39. package/docs/missions.md +2 -0
  40. package/docs/tool-reference.md +1 -1
  41. package/package.json +1 -1
  42. package/src/agents/agent-management.d.ts +1 -1
  43. package/src/agents/agent-management.js +2 -2
  44. package/src/agents/agents.js +1 -2
  45. package/src/agents/frontmatter.js +22 -18
  46. package/src/extension/index.js +2 -2
  47. package/src/missions/actions.js +7 -2
  48. package/src/missions/lifecycle.d.ts +11 -0
  49. package/src/missions/lifecycle.js +37 -6
  50. package/src/missions/store.js +1 -0
  51. package/src/missions/types.d.ts +2 -0
  52. package/src/runs/background/async-execution.d.ts +4 -1
  53. package/src/runs/background/async-execution.js +18 -3
  54. package/src/runs/background/async-resume.js +3 -2
  55. package/src/runs/background/async-retention.d.ts +1 -0
  56. package/src/runs/background/async-retention.js +16 -10
  57. package/src/runs/background/retained-children.js +1 -1
  58. package/src/runs/background/run-child-session.d.ts +2 -0
  59. package/src/runs/background/run-child-session.js +9 -1
  60. package/src/runs/background/subagent-runner.js +1 -0
  61. package/src/runs/foreground/subagent-executor.js +29 -51
  62. package/src/runs/shared/capability-ceiling.d.ts +2 -1
  63. package/src/runs/shared/capability-ceiling.js +3 -2
  64. package/src/runs/shared/child-session.d.ts +2 -0
  65. package/src/runs/shared/child-session.js +1 -0
  66. package/src/runs/shared/model-resolution-diagnostic.d.ts +11 -0
  67. package/src/runs/shared/model-resolution-diagnostic.js +25 -0
  68. package/src/runs/shared/parallel-handoff.d.ts +1 -1
  69. package/src/runs/shared/parallel-handoff.js +10 -6
  70. package/src/runs/shared/parallel-utils.d.ts +2 -0
  71. package/src/shared/types.d.ts +1 -1
  72. package/src/workflows/scripted-workflow.js +6 -1
  73. package/workflow-script-worker.cjs +450 -175
@@ -97,22 +97,15 @@ export function parseFrontmatter(content) {
97
97
  // neither end a pending list block nor become part of its value.
98
98
  if (trimmed.startsWith("#") && !currentFolded && !currentLiteral)
99
99
  continue;
100
- if (currentKey !== null && currentBlockLines !== null && (indent > (currentIndent ?? 0) || ((currentFolded || currentLiteral) && trimmed === ""))) {
100
+ // Exactly-empty lines belong to any pending block, not only to | and > scalars.
101
+ if (currentKey !== null && currentBlockLines !== null && (indent > (currentIndent ?? 0) || trimmed === "")) {
101
102
  // This line is part of the current block value
102
103
  currentBlockLines.push(line);
103
104
  continue;
104
105
  }
105
106
  // Flush any pending block value
106
107
  if (currentKey !== null && currentBlockLines !== null) {
107
- // Strip the common leading whitespace from the block so the
108
- // serializer can add its own indentation level.
109
- const rawBlock = currentBlockLines.join("\n");
110
- const leadingSpaces = rawBlock.match(/^[ \t]+(?=\S)/m);
111
- const prefix = leadingSpaces?.[0] ?? "";
112
- const stripped = prefix
113
- ? rawBlock.replace(new RegExp(`^${escapeRegex(prefix)}`, "gm"), "").replace(/^\n/, "")
114
- : rawBlock;
115
- frontmatter[currentKey] = currentFolded ? foldBlock(stripped) : stripped;
108
+ frontmatter[currentKey] = flushBlockLines(currentBlockLines, currentFolded, currentLiteral);
116
109
  currentKey = null;
117
110
  currentBlockLines = null;
118
111
  currentIndent = null;
@@ -146,17 +139,28 @@ export function parseFrontmatter(content) {
146
139
  frontmatter[key] = value;
147
140
  }
148
141
  }
149
- // Lines that don't match a key pattern (e.g., comments, empty lines) are ignored
150
142
  }
151
143
  // Flush final block value
152
144
  if (currentKey !== null && currentBlockLines !== null) {
153
- const rawBlock = currentBlockLines.join("\n");
154
- const leadingSpaces = rawBlock.match(/^[ \t]+(?=\S)/m);
155
- const prefix = leadingSpaces?.[0] ?? "";
156
- const stripped = prefix
157
- ? rawBlock.replace(new RegExp(`^${escapeRegex(prefix)}`, "gm"), "").replace(/^\n/, "")
158
- : rawBlock;
159
- frontmatter[currentKey] = currentFolded ? foldBlock(stripped) : stripped;
145
+ frontmatter[currentKey] = flushBlockLines(currentBlockLines, currentFolded, currentLiteral);
160
146
  }
161
147
  return { frontmatter, body };
162
148
  }
149
+ function flushBlockLines(blockLines, folded, literal) {
150
+ let lines = blockLines;
151
+ if (!folded && !literal) {
152
+ // Drop exactly-empty edge lines, and every line from the first exactly-empty one after
153
+ // the last content line, so nested values without inner blank lines parse as before.
154
+ const end = lines.indexOf("", lines.findLastIndex((line) => line.trim() !== "") + 1);
155
+ lines = lines.slice(lines.findIndex((line) => line !== ""), end < 0 ? lines.length : end);
156
+ }
157
+ // Strip the common leading whitespace from the block so the
158
+ // serializer can add its own indentation level.
159
+ const rawBlock = lines.join("\n");
160
+ const leadingSpaces = rawBlock.match(/^[ \t]+(?=\S)/m);
161
+ const prefix = leadingSpaces?.[0] ?? "";
162
+ const stripped = prefix
163
+ ? rawBlock.replace(new RegExp(`^${escapeRegex(prefix)}`, "gm"), "").replace(/^\n/, "")
164
+ : rawBlock;
165
+ return folded ? foldBlock(stripped) : stripped;
166
+ }
@@ -735,7 +735,7 @@ export default function registerSubagentExtension(pi) {
735
735
  event.systemPromptOptions.sections.active_subagents = activeRuns;
736
736
  if (!event.systemPromptOptions.selectedTools.includes("subagent"))
737
737
  return;
738
- const catalog = buildAdvertisedAgentCatalog(advertisedAgents, resolveCurrentSubagentCapabilityCeiling(sessionId), recordedPromptSection(pi, "advertised_subagents"));
738
+ const catalog = buildAdvertisedAgentCatalog(advertisedAgents, resolveCurrentSubagentCapabilityCeiling(ctx.sessionManager.getSessionId() ?? undefined), recordedPromptSection(pi, "advertised_subagents"));
739
739
  if (catalog)
740
740
  event.systemPromptOptions.sections.advertised_subagents = catalog;
741
741
  });
@@ -1207,7 +1207,7 @@ export default function registerSubagentExtension(pi) {
1207
1207
  hasPendingSupervisorRequest: () => [...supervisorChannel.pending.values()].some((request) => request.expectsReply),
1208
1208
  advertisedPrompt: async () => {
1209
1209
  await waitForAdvertisement();
1210
- return buildAdvertisedAgentPrompt(advertisedAgents, resolveCurrentSubagentCapabilityCeiling(state.currentSessionId ?? undefined));
1210
+ return buildAdvertisedAgentPrompt(advertisedAgents, resolveCurrentSubagentCapabilityCeiling(state.supervisorOwnerSessionId ?? undefined));
1211
1211
  },
1212
1212
  });
1213
1213
  }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { MISSION_STATUSES, } from "./types.js";
4
4
  import { missionStatePath } from "./workflow-state.js";
5
+ import { finishedMissionStatus, missionAcceptanceStatus, readMissionBinding } from "./lifecycle.js";
5
6
  import { createMission, listGlobalMissions, listMissions, missionRecordPath, readMission, resolveMissionStoreLocation, updateMission, validateMissionId, } from "./store.js";
6
7
  export const MISSION_ACTIONS = [
7
8
  "mission.create",
@@ -184,6 +185,7 @@ function validateMissionUpdate(value) {
184
185
  }
185
186
  function refreshLinkedRunStatus(location, record) {
186
187
  const warnings = [];
188
+ const runAcceptance = [];
187
189
  const updates = record.runs.flatMap((run) => {
188
190
  if (!run.asyncDir)
189
191
  return [];
@@ -207,6 +209,7 @@ function refreshLinkedRunStatus(location, record) {
207
209
  warnings.push(`Linked run status '${statusPath}' is missing state`);
208
210
  return [];
209
211
  }
212
+ runAcceptance.push({ runId: run.runId, status: missionAcceptanceStatus(status.steps) });
210
213
  if (state === run.status)
211
214
  return [];
212
215
  return [{
@@ -232,7 +235,8 @@ function refreshLinkedRunStatus(location, record) {
232
235
  : states.some((state) => state === "stopped" || state === "rejected" || state === "cancelled")
233
236
  ? "cancelled"
234
237
  : states.length > 0 && states.every((state) => state === "complete" || state === "completed")
235
- ? "completed"
238
+ // A run without a binding counts as auto-created.
239
+ ? finishedMissionStatus(record, !merged.some((run) => run.asyncDir && readMissionBinding(run.asyncDir)?.autoCreated === false), runAcceptance)
236
240
  : record.status;
237
241
  return { record: updateMission(location, record.id, { status, addRuns: updates }), warnings };
238
242
  }
@@ -261,7 +265,8 @@ function formatMission(record) {
261
265
  const identity = child.runId ? `${child.key} (${child.runId})` : child.key;
262
266
  const heartbeat = child.heartbeat ? `; heartbeat ${child.heartbeat.status ?? child.status}${child.heartbeat.phase ? `/${child.heartbeat.phase}` : ""} at ${child.heartbeat.updatedAt}` : "";
263
267
  const recovery = child.sessionPath ? `; session ${child.sessionPath}` : child.artifactPaths.length ? `; artifacts ${child.artifactPaths.length}` : "";
264
- lines.push(` ${identity}: ${child.status}${child.agent ? ` — ${child.agent}` : ""}${child.phase ? ` [${child.phase}]` : ""}; updated ${child.updatedAt}${heartbeat}${recovery}`);
268
+ const acceptance = child.acceptanceStatus ? `; acceptance ${child.acceptanceStatus}` : "";
269
+ lines.push(` ${identity}: ${child.status}${child.agent ? ` — ${child.agent}` : ""}${child.phase ? ` [${child.phase}]` : ""}${acceptance}; updated ${child.updatedAt}${heartbeat}${recovery}`);
265
270
  }
266
271
  }
267
272
  if (record.decisions.length) {
@@ -21,6 +21,7 @@ export interface MissionLaunchParams {
21
21
  export interface MissionLaunchBinding {
22
22
  missionId: string;
23
23
  location: MissionStoreLocation;
24
+ /** True when the launch made the mission without a `mission` or `missionId` request. */
24
25
  autoCreated: boolean;
25
26
  announceInContent?: boolean;
26
27
  }
@@ -30,6 +31,16 @@ export declare function prepareMissionLaunch(input: {
30
31
  config?: MissionStoreConfig;
31
32
  ownerSessionId?: string;
32
33
  }): MissionLaunchBinding | undefined;
34
+ /** The first unaccepted acceptance status among child results, else the first status found. */
35
+ export declare function missionAcceptanceStatus(results: unknown): string | undefined;
36
+ /**
37
+ * The status of a mission whose runs all finished successfully. Every path that
38
+ * completes a mission from run state goes through here.
39
+ */
40
+ export declare function finishedMissionStatus(record: MissionRecord, autoCreated: boolean, runAcceptance: ReadonlyArray<{
41
+ runId: string;
42
+ status: string | undefined;
43
+ }>): "completed" | "waiting";
33
44
  export declare function writeMissionAsyncBinding(asyncDir: string, binding: MissionLaunchBinding): void;
34
45
  export declare function attachMissionToLaunchResult(input: {
35
46
  binding: MissionLaunchBinding;
@@ -50,7 +50,7 @@ export function prepareMissionLaunch(input) {
50
50
  ...(mission?.labels ? { labels: mission.labels } : {}),
51
51
  ...(input.ownerSessionId ? { ownerSessionId: input.ownerSessionId } : {}),
52
52
  }, new Date(), input.config?.retainTerminal);
53
- return { missionId: record.id, location, autoCreated: true, announceInContent: true };
53
+ return { missionId: record.id, location, autoCreated: input.params.mission === undefined, announceInContent: true };
54
54
  }
55
55
  function toolResultIsError(result) {
56
56
  return "isError" in result && result.isError === true;
@@ -67,7 +67,30 @@ function runStatusForResult(result) {
67
67
  return "failed";
68
68
  return "completed";
69
69
  }
70
- function missionStatusForRun(record, runId, runStatus) {
70
+ /** Acceptance statuses that mean nobody accepted the child's result. */
71
+ const UNACCEPTED_ACCEPTANCE_STATUSES = new Set(["pending", "claimed", "review-required", "rejected"]);
72
+ /** The first unaccepted acceptance status among child results, else the first status found. */
73
+ export function missionAcceptanceStatus(results) {
74
+ const statuses = (Array.isArray(results) ? results : [])
75
+ .map((result) => result?.acceptance?.status)
76
+ .filter((status) => typeof status === "string" && status.trim() !== "");
77
+ return statuses.find((status) => UNACCEPTED_ACCEPTANCE_STATUSES.has(status)) ?? statuses[0];
78
+ }
79
+ /**
80
+ * The status of a mission whose runs all finished successfully. Every path that
81
+ * completes a mission from run state goes through here.
82
+ */
83
+ export function finishedMissionStatus(record, autoCreated, runAcceptance) {
84
+ // Nobody is expected to close a mission made by default for an ordinary launch.
85
+ if (autoCreated)
86
+ return "completed";
87
+ if (runAcceptance.some((run) => UNACCEPTED_ACCEPTANCE_STATUSES.has(run.status)))
88
+ return "waiting";
89
+ // A run reported here supersedes its older workflow child record.
90
+ const reported = new Set(runAcceptance.map((run) => run.runId));
91
+ return record.workflowChildren.some((child) => !reported.has(child.runId) && UNACCEPTED_ACCEPTANCE_STATUSES.has(child.acceptanceStatus)) ? "waiting" : "completed";
92
+ }
93
+ function missionStatusForRun(record, runId, runStatus, acceptanceStatus, autoCreated) {
71
94
  if (record.status === "completed" || record.status === "failed" || record.status === "cancelled")
72
95
  return record.status;
73
96
  if (runStatus === "active" || runStatus === "queued" || runStatus === "running")
@@ -80,7 +103,7 @@ function missionStatusForRun(record, runId, runStatus) {
80
103
  if (otherActive)
81
104
  return "active";
82
105
  if (runStatus === "completed" || runStatus === "complete")
83
- return "completed";
106
+ return finishedMissionStatus(record, autoCreated, [{ runId, status: acceptanceStatus }]);
84
107
  if (runStatus === "stopped" || runStatus === "rejected" || runStatus === "cancelled")
85
108
  return "cancelled";
86
109
  return "failed";
@@ -129,6 +152,7 @@ function persistedBinding(binding) {
129
152
  globalIndexDir: binding.location.globalIndexDir,
130
153
  writeGlobalIndex: binding.location.writeGlobalIndex,
131
154
  ...(binding.location.retainTerminal !== undefined ? { retainTerminal: binding.location.retainTerminal } : {}),
155
+ autoCreated: binding.autoCreated,
132
156
  };
133
157
  }
134
158
  export function writeMissionAsyncBinding(asyncDir, binding) {
@@ -170,7 +194,7 @@ export function attachMissionToLaunchResult(input) {
170
194
  ...(usage ? { usage } : {}),
171
195
  };
172
196
  let mission = updateMission(input.binding.location, input.binding.missionId, {
173
- status: missionStatusForRun(current, runId, runStatus),
197
+ status: missionStatusForRun(current, runId, runStatus, missionAcceptanceStatus(input.result.details.results), input.binding.autoCreated),
174
198
  addRuns: [run],
175
199
  addArtifacts: artifactsForResult(input.result),
176
200
  ...(firstText(input.result) && runStatus !== "active" ? { summary: firstText(input.result) } : {}),
@@ -188,6 +212,8 @@ export function attachMissionToLaunchResult(input) {
188
212
  asyncDir: input.result.details.asyncDir,
189
213
  mode: input.result.details.mode,
190
214
  state: status.state,
215
+ // A run that finished before this launch result was attached keeps its acceptance in the status steps.
216
+ ...(Array.isArray(status.steps) ? { results: status.steps.map((step) => ({ acceptance: step?.acceptance })) } : {}),
191
217
  ...(firstText(input.result) ? { summary: firstText(input.result) } : {}),
192
218
  }) ?? mission;
193
219
  }
@@ -237,9 +263,12 @@ function parsePersistedBinding(value, source) {
237
263
  throw new Error(`${source}.writeGlobalIndex must be boolean`);
238
264
  if (input.retainTerminal !== undefined && (!Number.isInteger(input.retainTerminal) || input.retainTerminal < 1))
239
265
  throw new Error(`${source}.retainTerminal must be a positive integer`);
266
+ if (input.autoCreated !== undefined && typeof input.autoCreated !== "boolean")
267
+ throw new Error(`${source}.autoCreated must be boolean`);
240
268
  return {
241
269
  missionId: validateMissionId(input.missionId, `${source}.missionId`),
242
- autoCreated: false,
270
+ // Bindings saved before this field existed belong to runs that completed their mission without an acceptance check.
271
+ autoCreated: input.autoCreated !== false,
243
272
  location: {
244
273
  projectRoot: input.projectRoot,
245
274
  missionDir: input.missionDir,
@@ -339,8 +368,9 @@ export function syncMissionFromAsyncCompletion(value) {
339
368
  ? "stopped"
340
369
  : "failed";
341
370
  const workflowChildTerminal = !["running", "queued", "active", "paused"].includes(workflowChildStatus);
371
+ const acceptanceStatus = missionAcceptanceStatus(event.results);
342
372
  return updateMission(binding.location, binding.missionId, {
343
- status: missionStatusForRun(current, runId, runStatus),
373
+ status: missionStatusForRun(current, runId, runStatus, acceptanceStatus, binding.autoCreated),
344
374
  addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain", "workflow"].includes(event.mode) ? event.mode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
345
375
  addArtifacts: artifacts,
346
376
  ...(workflowRunId && workflowKey ? { upsertWorkflowChildren: [{
@@ -350,6 +380,7 @@ export function syncMissionFromAsyncCompletion(value) {
350
380
  status: workflowChildStatus,
351
381
  artifactPaths: artifacts.map((artifact) => artifact.path),
352
382
  ...(workflowChildTerminal ? { completedAt } : {}),
383
+ ...(acceptanceStatus ? { acceptanceStatus } : {}),
353
384
  heartbeat: { status: workflowChildStatus, ...(summary ? { message: summary } : {}) },
354
385
  }] } : {}),
355
386
  ...(summary ? { summary } : {}),
@@ -133,6 +133,7 @@ function parseWorkflowChild(value, label) {
133
133
  ...(optionalString(input.phase, `${label}.phase`) ? { phase: input.phase } : {}),
134
134
  ...(input.completedAt !== undefined ? { completedAt: timestamp(input.completedAt, `${label}.completedAt`) } : {}),
135
135
  ...(optionalString(input.sessionPath, `${label}.sessionPath`) ? { sessionPath: input.sessionPath } : {}),
136
+ ...(optionalString(input.acceptanceStatus, `${label}.acceptanceStatus`) ? { acceptanceStatus: input.acceptanceStatus } : {}),
136
137
  ...(heartbeat ? { heartbeat: {
137
138
  updatedAt: timestamp(heartbeat.updatedAt, `${label}.heartbeat.updatedAt`),
138
139
  ...(optionalString(heartbeat.status, `${label}.heartbeat.status`) ? { status: heartbeat.status } : {}),
@@ -57,6 +57,8 @@ export interface MissionWorkflowChild {
57
57
  sessionPath?: string;
58
58
  artifactPaths: string[];
59
59
  heartbeat?: MissionChildHeartbeat;
60
+ /** Latest acceptance ledger status reported for the child. */
61
+ acceptanceStatus?: string;
60
62
  }
61
63
  export type MissionWorkflowChildUpdate = Pick<MissionWorkflowChild, "workflowRunId" | "key" | "status"> & Partial<Omit<MissionWorkflowChild, "workflowRunId" | "key" | "status" | "startedAt" | "updatedAt" | "artifactPaths" | "heartbeat">> & {
62
64
  startedAt?: string;
@@ -2,6 +2,7 @@
2
2
  * Async execution logic for subagent tool
3
3
  */
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import type { ParentProviderRegistry } from "../shared/child-session.ts";
5
6
  import { type AgentConfig, type UnknownAgentDiagnosticContext } from "../../agents/agents.ts";
6
7
  import { type ClaudeCodeOverride } from "../shared/claude-code-adapter.ts";
7
8
  import { type ChainStep } from "../../shared/settings.ts";
@@ -25,7 +26,7 @@ interface AsyncExecutionContext {
25
26
  cwd: string;
26
27
  currentSessionId: string;
27
28
  completionOwnerId?: string;
28
- /** Parent session id used by permission-system ask forwarding. */
29
+ /** Parent Pi session id (`getSessionId()`): keys permission-system ask forwarding and capability ceilings. */
29
30
  parentSessionId?: string;
30
31
  /** Parent session file, recorded as each new child session's `parentSession` header. */
31
32
  parentSessionFile?: string;
@@ -44,6 +45,8 @@ interface AsyncExecutionContext {
44
45
  childRuntime?: ChildRuntimeConfig;
45
46
  /** The launching session's project trust; undefined when the host has no trust concept. */
46
47
  projectTrusted?: boolean;
48
+ /** The parent's model registry, read at launch to name a provider an extension registered there. */
49
+ parentProviderRegistry?: Pick<ParentProviderRegistry, "getRegisteredProviderIds">;
47
50
  }
48
51
  export declare const DEFAULT_ASYNC_TIMEOUT_MS: number;
49
52
  interface AsyncChainParams {
@@ -135,6 +135,17 @@ function supportsNativeRunner(nodeExecutable) {
135
135
  && nodeExecutable === process.execPath
136
136
  && !sourceUnderNodeModules();
137
137
  }
138
+ /**
139
+ * A runner child without the ambient extensions does not load the parent extension that
140
+ * registered its model's provider, and still resolves the model when a built-in provider
141
+ * has the same id. Name that provider so a failed run can say so.
142
+ */
143
+ function parentExtensionProviderForChild(ctx, disableAmbientExtensions, model, availableModels, preferredProvider) {
144
+ if (!disableAmbientExtensions || !ctx.parentProviderRegistry)
145
+ return undefined;
146
+ const provider = findModelInfo(model, availableModels, preferredProvider)?.provider;
147
+ return provider && ctx.parentProviderRegistry.getRegisteredProviderIds().includes(provider) ? provider : undefined;
148
+ }
138
149
  export const DEFAULT_ASYNC_TIMEOUT_MS = 30 * 60 * 1000;
139
150
  export function formatAsyncStartedMessage(headline, interactive) {
140
151
  const guidance = interactive
@@ -997,6 +1008,7 @@ export function buildAsyncRunnerSteps(id, params) {
997
1008
  runtimeSnapshotHost: ctx.pi,
998
1009
  });
999
1010
  const launchResolvedExtensions = externalRunner ? undefined : projectLaunchResolvedChildExtensions(toolPlan);
1011
+ const parentExtensionProvider = externalRunner || machine ? undefined : parentExtensionProviderForChild(ctx, toolPlan.disableAmbientExtensions, selectedModel, availableModels, a.modelProvider ?? ctx.currentModelProvider);
1000
1012
  if (externalRunner && permissionRules) {
1001
1013
  throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='${externalRunnerType}', which cannot enforce native Pi child permission rules.`);
1002
1014
  }
@@ -1026,6 +1038,7 @@ export function buildAsyncRunnerSteps(id, params) {
1026
1038
  thinking: resolveEffectiveThinking(selectedModel, effectiveThinking),
1027
1039
  ...(thinkingCeiling ? { thinkingCeiling } : {}),
1028
1040
  launchResolvedExtensions,
1041
+ ...(parentExtensionProvider ? { parentExtensionProvider } : {}),
1029
1042
  ...(requestedModel ? { requestedModel } : {}),
1030
1043
  ...(primaryModelFromParent ? { skipPrimaryModelVerification: true } : {}),
1031
1044
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
@@ -1233,7 +1246,8 @@ export function executeAsyncChain(id, params) {
1233
1246
  .find((name) => name !== undefined && !lookupRunnerLauncher(ctx.runnerLaunchers, name));
1234
1247
  if (undefinedLauncher !== undefined)
1235
1248
  return formatAsyncStartError(resultMode, `Launcher '${undefinedLauncher}' used by this ${resultMode} is not defined in runnerLaunchers in the user subagent config.`);
1236
- const capabilityCeiling = params.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(ctx.currentSessionId);
1249
+ const launchParentSessionId = ctx.parentSessionId ?? ctx.currentSessionId;
1250
+ const capabilityCeiling = params.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(launchParentSessionId);
1237
1251
  const inheritedNestedRoute = inheritedNestedRouteOf(ctx.childRuntime);
1238
1252
  const nestedAddress = inheritedNestedRoute ? inheritedNestedParentAddressOf(ctx.childRuntime) : undefined;
1239
1253
  const asyncDir = inheritedNestedRoute
@@ -1336,7 +1350,6 @@ export function executeAsyncChain(id, params) {
1336
1350
  const initialStatusAt = Date.now();
1337
1351
  const initialCompletionOwnerId = ctx.completionOwnerId ?? currentCompletionOwnerId();
1338
1352
  const completionOwnerProcess = currentRecordedPid();
1339
- const launchParentSessionId = ctx.parentSessionId ?? ctx.currentSessionId;
1340
1353
  try {
1341
1354
  writeRetainedRequiredChildExtensions(asyncDir, params.requiredExtensions ?? ctx.childRuntime?.requiredExtensions ?? resolveRequiredChildExtensions(launchParentSessionId ?? undefined));
1342
1355
  }
@@ -1600,7 +1613,7 @@ export function executeAsyncSingle(id, params) {
1600
1613
  if (unsupported.length > 0)
1601
1614
  return formatAsyncStartError("single", `Agent '${agentConfig.name}' uses runner.type='${externalRunnerType}' and does not support: ${unsupported.join(", ")}.`);
1602
1615
  }
1603
- const capabilityCeiling = intersectSubagentCapabilityCeilings(params.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(ctx.currentSessionId), ctx.childRuntime?.capabilityCeiling);
1616
+ const capabilityCeiling = intersectSubagentCapabilityCeilings(params.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(ctx.parentSessionId ?? ctx.currentSessionId), ctx.childRuntime?.capabilityCeiling);
1604
1617
  try {
1605
1618
  assertAgentAllowedByCapabilityCeiling(agentConfig.name, capabilityCeiling);
1606
1619
  }
@@ -1811,6 +1824,7 @@ export function executeAsyncSingle(id, params) {
1811
1824
  runtimeSnapshotHost: ctx.pi,
1812
1825
  });
1813
1826
  const launchResolvedExtensions = externalRunner ? undefined : projectLaunchResolvedChildExtensions(toolPlan);
1827
+ const parentExtensionProvider = externalRunner || machine ? undefined : parentExtensionProviderForChild(ctx, toolPlan.disableAmbientExtensions, selectedModel, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider);
1814
1828
  const fast = params.fast ?? agentConfig.fast;
1815
1829
  const launchThinking = resolveEffectiveThinking(selectedModel, effectiveThinking);
1816
1830
  const { definitionDigest, launchContractDigest } = resolveLaunchBinding({
@@ -1933,6 +1947,7 @@ export function executeAsyncSingle(id, params) {
1933
1947
  ...(params.fast ?? agentConfig.fast ? { fast: params.fast ?? agentConfig.fast } : {}),
1934
1948
  thinking: resolveEffectiveThinking(selectedModel, effectiveThinking),
1935
1949
  ...(thinkingCeiling ? { thinkingCeiling } : {}),
1950
+ ...(parentExtensionProvider ? { parentExtensionProvider } : {}),
1936
1951
  ...(requestedModel ? { requestedModel } : {}),
1937
1952
  ...(modelOrigin === "inherited" ? { skipPrimaryModelVerification: true } : {}),
1938
1953
  ...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
@@ -484,7 +484,8 @@ export function resolveAsyncResumeTarget(params, deps = {}, options = {}) {
484
484
  const requireSessionFile = options.requireSessionFile ?? true;
485
485
  const location = resolveAsyncRunLocation(params, asyncDirRoot, resultsDir);
486
486
  if (!location.asyncDir && !location.resultPath) {
487
- throw new Error("Async run not found. Provide id or dir.");
487
+ const requested = params.id ?? params.runId;
488
+ throw new Error(requested ? `Async run not found for '${requested}'.` : "Async run not found. Provide id or dir.");
488
489
  }
489
490
  const reconciliation = location.asyncDir
490
491
  ? reconcileAsyncRun(location.asyncDir, { resultsDir, kill: deps.kill, now: deps.now })
@@ -606,7 +607,7 @@ export function resolveAsyncResumeTarget(params, deps = {}, options = {}) {
606
607
  ? intersectSubagentCapabilityCeilings(status?.admissionCapabilityCeiling, result?.admissionCapabilityCeiling)
607
608
  : intersectSubagentCapabilityCeilings(status?.capabilityCeiling, statusSteps[index]?.capabilityCeiling, result?.capabilityCeiling, resultSteps[index]?.capabilityCeiling);
608
609
  const managedWorktreeCwd = location.asyncDir
609
- ? resolveRetainedWorktreeCwd(parallelHandoffPath(location.asyncDir), runId, index)
610
+ ? resolveRetainedWorktreeCwd(parallelHandoffPath(location.asyncDir), runId, index, statusSteps[index]?.worktreePath)
610
611
  : undefined;
611
612
  const resumeCwd = validateResumeCwd(runId, managedWorktreeCwd ?? status?.cwd ?? result?.cwd ?? recoveryDescriptor?.cwd);
612
613
  return {
@@ -3,6 +3,7 @@ export declare const ASYNC_RETENTION_DAYS = 30;
3
3
  export declare const ASYNC_RETENTION_BATCH_SIZE = 100;
4
4
  export declare const ASYNC_RETENTION_DELAY_MS = 60000;
5
5
  export declare const ASYNC_RETENTION_TOMBSTONE_GRACE_MS: number;
6
+ export declare const RESUMABLE_RETENTION_DAYS = 90;
6
7
  export interface AsyncRetentionOptions {
7
8
  asyncDirRoot: string;
8
9
  resultsDir: string;
@@ -15,7 +15,11 @@ export const ASYNC_RETENTION_DAYS = 30;
15
15
  export const ASYNC_RETENTION_BATCH_SIZE = 100;
16
16
  export const ASYNC_RETENTION_DELAY_MS = 60_000;
17
17
  export const ASYNC_RETENTION_TOMBSTONE_GRACE_MS = 24 * 60 * 60 * 1000;
18
+ // A run or result whose session file still exists can be resumed, so it is kept this long
19
+ // instead of ASYNC_RETENTION_DAYS. Session files outlive run dirs, so without a ceiling it is kept forever.
20
+ export const RESUMABLE_RETENTION_DAYS = 90;
18
21
  const RETENTION_MS = ASYNC_RETENTION_DAYS * 24 * 60 * 60 * 1000;
22
+ const RESUMABLE_RETENTION_MS = RESUMABLE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
19
23
  const LOCK_NAME = ".async-retention.lock";
20
24
  const CURSOR_NAME = ".async-retention-cursor.json";
21
25
  const LOG_NAME = "async-retention-maintenance.jsonl";
@@ -239,9 +243,10 @@ function runSkipReason(input) {
239
243
  return "mission-reference";
240
244
  if (hasUnresolvedRunHandoff(runDir, status))
241
245
  return "handoff-reference";
242
- if (hasResumableContract(runDir, status))
243
- return "resumable";
244
246
  const timestamp = statusTimestamp(status, runDir);
247
+ // Unknown ages stay protected.
248
+ if (hasResumableContract(runDir, status) && (timestamp === undefined || timestamp > input.resumableCutoff))
249
+ return "resumable";
245
250
  if (timestamp === undefined)
246
251
  return "unknown-age";
247
252
  if (timestamp > input.cutoff)
@@ -464,13 +469,13 @@ function resultSkipReason(input) {
464
469
  return "workflow-reference";
465
470
  if (hasUnresolvedResultHandoff(input.data))
466
471
  return "handoff-reference";
467
- if (resultHasResumableSession(input.data))
472
+ const timestamp = resultTimestamp(input.data, input.candidate.path);
473
+ if (resultHasResumableSession(input.data) && (timestamp === undefined || timestamp > input.resumableCutoff))
468
474
  return "resumable";
469
475
  if (input.candidate.kind === "archive" && fs.existsSync(path.join(input.resultsDir, "completion-replay", `${encodeURIComponent(runId)}.json`)))
470
476
  return "replay-reference";
471
477
  if (!terminalResult(input.data) && input.candidate.kind !== "replay" && input.candidate.kind !== "archive" && input.candidate.kind !== "tombstone")
472
478
  return "non-terminal-result";
473
- const timestamp = resultTimestamp(input.data, input.candidate.path);
474
479
  if (timestamp === undefined)
475
480
  return "unknown-age";
476
481
  if (timestamp > input.cutoff)
@@ -690,6 +695,7 @@ export async function cleanupAsyncRetention(options) {
690
695
  return finish();
691
696
  const protectedRunIds = new Set(options.protectedRunIds ?? []);
692
697
  const cutoff = currentTime - retentionMs;
698
+ const resumableCutoff = currentTime - RESUMABLE_RETENTION_MS;
693
699
  const cursor = readCursor(maintenanceRoot);
694
700
  const runBudget = Math.ceil(batchSize / 2);
695
701
  const resultBudget = batchSize - runBudget;
@@ -765,7 +771,7 @@ export async function cleanupAsyncRetention(options) {
765
771
  if (reconciliation.repaired)
766
772
  result.repairedRuns += 1;
767
773
  }
768
- const initialReason = runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
774
+ const initialReason = runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, resumableCutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
769
775
  if (initialReason) {
770
776
  increment(result.skipped, initialReason);
771
777
  continue;
@@ -780,7 +786,7 @@ export async function cleanupAsyncRetention(options) {
780
786
  continue;
781
787
  }
782
788
  const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
783
- const finalReason = freshWaitReferences.safe ? runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
789
+ const finalReason = freshWaitReferences.safe ? runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, resumableCutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
784
790
  if (finalReason) {
785
791
  increment(result.skipped, `recheck-${finalReason}`);
786
792
  continue;
@@ -805,7 +811,7 @@ export async function cleanupAsyncRetention(options) {
805
811
  }
806
812
  const recheckStatus = readStatus(tombstone);
807
813
  const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
808
- const recheckReason = freshWaitReferences.safe ? runSkipReason({ runDir: tombstone, status: recheckStatus, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
814
+ const recheckReason = freshWaitReferences.safe ? runSkipReason({ runDir: tombstone, status: recheckStatus, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, resumableCutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
809
815
  if (recheckReason) {
810
816
  if (!fs.existsSync(runDir)) {
811
817
  fs.renameSync(tombstone, runDir);
@@ -842,7 +848,7 @@ export async function cleanupAsyncRetention(options) {
842
848
  continue;
843
849
  }
844
850
  const data = readJson(candidate.path);
845
- const reason = resultSkipReason({ candidate, data, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
851
+ const reason = resultSkipReason({ candidate, data, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, resumableCutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
846
852
  if (reason) {
847
853
  increment(result.skipped, reason);
848
854
  continue;
@@ -853,7 +859,7 @@ export async function cleanupAsyncRetention(options) {
853
859
  continue;
854
860
  }
855
861
  const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
856
- const finalReason = freshWaitReferences.safe ? resultSkipReason({ candidate, data, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
862
+ const finalReason = freshWaitReferences.safe ? resultSkipReason({ candidate, data, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, resumableCutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
857
863
  if (finalReason) {
858
864
  increment(result.skipped, `recheck-${finalReason}`);
859
865
  continue;
@@ -870,7 +876,7 @@ export async function cleanupAsyncRetention(options) {
870
876
  const tombstoneCandidate = { ...candidate, path: tombstone, kind: "tombstone" };
871
877
  const recheckData = readJson(tombstone);
872
878
  const freshWaitReferences = parseWaitRunIds(waitSubscriptionsDir);
873
- const recheckReason = freshWaitReferences.safe ? resultSkipReason({ candidate: tombstoneCandidate, data: recheckData, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
879
+ const recheckReason = freshWaitReferences.safe ? resultSkipReason({ candidate: tombstoneCandidate, data: recheckData, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, maintenanceRoot, cutoff, resumableCutoff, protectedRunIds, waitRunIds: freshWaitReferences.runIds }) : "wait-references-unknown";
874
880
  if (recheckReason) {
875
881
  if (!fs.existsSync(candidate.path)) {
876
882
  fs.renameSync(tombstone, candidate.path);
@@ -52,7 +52,7 @@ function childResumability(run, step) {
52
52
  }
53
53
  let requiredCwd;
54
54
  try {
55
- requiredCwd = resolveRetainedWorktreeCwd(parallelHandoffPath(run.asyncDir), run.id, step.index) ?? recoveryDescriptor.cwd ?? run.cwd;
55
+ requiredCwd = resolveRetainedWorktreeCwd(parallelHandoffPath(run.asyncDir), run.id, step.index, step.worktreePath) ?? recoveryDescriptor.cwd ?? run.cwd;
56
56
  if (!requiredCwd || !fs.statSync(requiredCwd).isDirectory())
57
57
  return { state: "not-resumable", reason: `required cwd is missing: ${requiredCwd ?? "unknown"}` };
58
58
  }
@@ -83,6 +83,8 @@ export interface RunChildSessionInput {
83
83
  }>;
84
84
  modelResponseAliases?: Record<string, string[]>;
85
85
  mutationTools?: readonly string[];
86
+ /** Provider of the launch model that a parent extension registered and this child does not load. */
87
+ parentExtensionProvider?: string;
86
88
  }
87
89
  export interface RunChildSessionResult {
88
90
  exitCode: number;
@@ -3,7 +3,7 @@ import { qualifyModelWithProvider } from "../../shared/model-info.js";
3
3
  import { acceptChildWatchdogEvent, applyChildWatchdogMessage, childWatchdogIsActive, isChildWatchdogStatusEvent, } from "../../watchdog/child-status.js";
4
4
  import { projectChildLifecycle } from "../shared/child-lifecycle.js";
5
5
  import { formatSubagentModelVerificationError } from "../shared/model-resolution.js";
6
- import { formatChildModelResolutionDiagnostic, isChildModelResolutionFailure } from "../shared/model-resolution-diagnostic.js";
6
+ import { formatChildModelResolutionDiagnostic, formatMissingParentProviderDiagnostic, isChildModelResolutionFailure } from "../shared/model-resolution-diagnostic.js";
7
7
  import { isMutatingTool, resolveCurrentPath } from "../shared/long-running-guard.js";
8
8
  import { effectiveToolTimeoutMs, formatToolTimeoutMessage, toolTimeoutCallKey } from "../shared/tool-timeout.js";
9
9
  import { createReportedChildSessionInput } from "../shared/child-launch.js";
@@ -520,6 +520,14 @@ export function runChildSession(input) {
520
520
  && !input.launch.session.ambientExtensions) {
521
521
  finalError = `${promptErrorMessage}\n\n${formatChildModelResolutionDiagnostic({ agent: input.launch.config.agent, model: input.launch.session.model, host: "runner", capabilityCeiling: input.launch.toolPlan.capabilityCeiling })}`;
522
522
  }
523
+ else if (input.parentExtensionProvider
524
+ && session?.claimedProvider !== input.parentExtensionProvider
525
+ && finalError !== undefined
526
+ && (finalError === promptErrorMessage || finalError === assistantError)
527
+ && toolCount === 0
528
+ && messages.every((message) => message.role !== "assistant" || message.stopReason === "error")) {
529
+ finalError = `${finalError}\n\n${formatMissingParentProviderDiagnostic({ agent: input.launch.config.agent, model: input.launch.session.model, provider: input.parentExtensionProvider, capabilityCeiling: input.launch.toolPlan.capabilityCeiling })}`;
530
+ }
523
531
  const forcedDrainAfterFinalSuccess = (forced || forcedTermination) && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !finalError;
524
532
  const forcedDrainAfterEmptyTerminal = forcedDrainAfterFinalSuccess && hasEmptyTerminalAssistantResponse(messages);
525
533
  if (!finalError && forced && !forcedDrainAfterFinalSuccess && !interrupted && !timedOut && !stopped) {
@@ -938,6 +938,7 @@ export async function runSingleStepInner(step, ctx) {
938
938
  modelVerificationRegistry: step.modelVerificationRegistry,
939
939
  modelResponseAliases: step.modelResponseAliases,
940
940
  mutationTools: step.mutationTools,
941
+ parentExtensionProvider: step.parentExtensionProvider,
941
942
  }));
942
943
  launched = true;
943
944
  aggregateUsage.input += run.usage.input;