pi-subagents 0.53.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +66 -5
  2. package/README.md +1 -1
  3. package/agents/reviewer.md +12 -2
  4. package/docs/agents.md +4 -4
  5. package/docs/configuration.md +6 -6
  6. package/docs/extension-api.md +14 -4
  7. package/docs/models.md +41 -6
  8. package/docs/observability.md +14 -3
  9. package/docs/tool-reference.md +21 -6
  10. package/docs/workflows.md +7 -1
  11. package/index.ts +10 -1
  12. package/package.json +1 -1
  13. package/prompts/council.md +19 -7
  14. package/prompts/parallel-review.md +5 -1
  15. package/prompts/review-loop.md +9 -5
  16. package/skills/council-mode/SKILL.md +50 -26
  17. package/skills/pi-subagents/SKILL.md +5 -1
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
  19. package/skills/pi-subagents/references/execution-controls.md +13 -4
  20. package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
  21. package/skills/pi-subagents/references/prompting-and-roles.md +14 -7
  22. package/src/agents/agent-management.ts +115 -14
  23. package/src/agents/agent-serializer.ts +2 -2
  24. package/src/agents/agents.ts +195 -47
  25. package/src/api/external-job-provider.ts +10 -1
  26. package/src/api/preflight.ts +33 -19
  27. package/src/api/project-panes.ts +2 -0
  28. package/src/extension/doctor.ts +10 -0
  29. package/src/extension/fanout-child.ts +3 -2
  30. package/src/extension/index.ts +24 -4
  31. package/src/extension/public-execution.ts +12 -9
  32. package/src/extension/rpc.ts +77 -3
  33. package/src/extension/schemas.ts +2 -1
  34. package/src/extension/tool-description.ts +9 -6
  35. package/src/extension/tool-result.ts +19 -0
  36. package/src/inspectors/herdr/client.ts +3 -3
  37. package/src/inspectors/herdr/focus.ts +55 -0
  38. package/src/inspectors/herdr/project-panes.ts +228 -44
  39. package/src/integrations/herdr-status.ts +26 -4
  40. package/src/runs/background/async-execution.ts +112 -13
  41. package/src/runs/background/async-job-tracker.ts +33 -14
  42. package/src/runs/background/async-resume.ts +20 -2
  43. package/src/runs/background/async-retention.ts +1 -1
  44. package/src/runs/background/async-status.ts +10 -0
  45. package/src/runs/background/chain-root-attachment.ts +5 -0
  46. package/src/runs/background/control-channel.ts +98 -10
  47. package/src/runs/background/notify.ts +62 -1
  48. package/src/runs/background/result-watcher.ts +4 -3
  49. package/src/runs/background/run-status.ts +15 -1
  50. package/src/runs/background/stale-run-reconciler.ts +3 -0
  51. package/src/runs/background/subagent-runner.ts +251 -38
  52. package/src/runs/background/wait-completions.ts +2 -0
  53. package/src/runs/background/wait-tool.ts +4 -3
  54. package/src/runs/foreground/async-stop-action.ts +23 -2
  55. package/src/runs/foreground/execution.ts +110 -8
  56. package/src/runs/foreground/subagent-executor.ts +361 -45
  57. package/src/runs/foreground/workflow-detach-reconcile.ts +3 -0
  58. package/src/runs/shared/acceptance.ts +1 -0
  59. package/src/runs/shared/child-identity.ts +36 -0
  60. package/src/runs/shared/completion-guard.ts +50 -1
  61. package/src/runs/shared/external-job-bridge.ts +53 -37
  62. package/src/runs/shared/external-job-runner.ts +126 -23
  63. package/src/runs/shared/model-fallback.ts +46 -15
  64. package/src/runs/shared/model-scope.ts +106 -39
  65. package/src/runs/shared/orca-progress-tabs.ts +71 -9
  66. package/src/runs/shared/parallel-utils.ts +12 -0
  67. package/src/runs/shared/pi-args.ts +31 -1
  68. package/src/runs/shared/subagent-prompt-runtime.ts +39 -10
  69. package/src/runs/shared/tool-availability.ts +1 -3
  70. package/src/shared/launch-contract.ts +6 -5
  71. package/src/shared/thinking-ceiling.ts +52 -0
  72. package/src/shared/types.ts +72 -5
  73. package/src/tui/fleet-status.ts +66 -13
  74. package/src/tui/render.ts +5 -4
  75. package/src/watchdog/permission-arbiter.ts +59 -51
  76. package/src/workflows/scripted-workflow.ts +107 -10
@@ -119,6 +119,7 @@ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result
119
119
  timestamp: Date.now(),
120
120
  results: workflowResultChildren(status, childRunId, result, existing?.results),
121
121
  workflow: status.workflow,
122
+ reconciledFromDetachedChild: childRunId,
122
123
  ...(receipt ? { workflowReceipt: { path: path.join(asyncDir, "workflow-receipt.json"), receipt } } : {}),
123
124
  asyncDir,
124
125
  cwd: status.cwd,
@@ -252,6 +253,8 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
252
253
  success: next.state === "complete",
253
254
  state: next.state,
254
255
  summary: typeof published.summary === "string" ? published.summary : (next.state === "complete" ? "Workflow completed." : next.error),
256
+ reconciledFromDetachedChild: input.childRunId,
257
+ ...(Array.isArray(published.results) ? { results: published.results } : {}),
255
258
  sessionId: next.sessionId,
256
259
  completionOwnerId: next.completionOwnerId,
257
260
  timestamp: Date.now(),
@@ -959,6 +959,7 @@ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
959
959
  function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
960
960
  const checks: AcceptanceRuntimeCheck[] = [];
961
961
  for (const kind of acceptance.evidence) {
962
+ if (kind === "no-staged-files" && report.noStagedFiles === undefined) continue;
962
963
  const status = reportEvidenceStatus(report, kind);
963
964
  checks.push({
964
965
  id: `evidence:${kind}`,
@@ -0,0 +1,36 @@
1
+ import type { AsyncStatus } from "../../shared/types.ts";
2
+
3
+ export type AsyncStatusStep = NonNullable<AsyncStatus["steps"]>[number];
4
+
5
+ export interface ResolvedAsyncStatusChild {
6
+ index: number;
7
+ step: AsyncStatusStep;
8
+ id: string;
9
+ }
10
+
11
+ export type AsyncStatusChildResolution =
12
+ | { ok: true; child: ResolvedAsyncStatusChild }
13
+ | { ok: false; code: "not_found" | "ambiguous"; message: string };
14
+
15
+ export function asyncStatusChildIdentity(step: AsyncStatusStep, index: number): string {
16
+ return step.workflowKey ?? step.runId ?? `step:${index}`;
17
+ }
18
+
19
+ export function asyncStatusChildIdentityCandidates(step: AsyncStatusStep, index: number): string[] {
20
+ return [...new Set([step.workflowKey, step.runId, `step:${index}`].filter((value): value is string => typeof value === "string" && value.length > 0))];
21
+ }
22
+
23
+ export function resolveAsyncStatusChild(status: Pick<AsyncStatus, "runId" | "steps">, childId: string): AsyncStatusChildResolution {
24
+ const matches: ResolvedAsyncStatusChild[] = [];
25
+ for (const [index, step] of (status.steps ?? []).entries()) {
26
+ if (!asyncStatusChildIdentityCandidates(step, index).includes(childId)) continue;
27
+ matches.push({ index, step, id: asyncStatusChildIdentity(step, index) });
28
+ }
29
+ if (matches.length === 1) return { ok: true, child: matches[0]! };
30
+ if (matches.length > 1) return { ok: false, code: "ambiguous", message: `Child '${childId}' is ambiguous under async run '${status.runId}'.` };
31
+ return { ok: false, code: "not_found", message: `Child '${childId}' was not found under async run '${status.runId}'.` };
32
+ }
33
+
34
+ export function isStoppableAsyncStatusStep(step: AsyncStatusStep): boolean {
35
+ return step.status === "pending" || step.status === "running";
36
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
+ import type { AcceptanceRole } from "../../shared/types.ts";
2
3
  import { isMutatingTool } from "./long-running-guard.ts";
3
- import { expectsImplementationMutation } from "./task-intent.ts";
4
+ import { classifyTaskMutationIntent, expectsImplementationMutation, taskMayMutate } from "./task-intent.ts";
4
5
 
5
6
  export { expectsImplementationMutation };
6
7
 
@@ -14,6 +15,7 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
14
15
  "get_search_content",
15
16
  "intercom",
16
17
  "contact_supervisor",
18
+ "structured_output",
17
19
  ]);
18
20
 
19
21
  // Cursor native edit/write often land as thinking traces (inactive_trace /
@@ -45,12 +47,15 @@ interface CompletionMutationGuardInput {
45
47
  messages: Message[];
46
48
  tools?: string[];
47
49
  mcpDirectTools?: string[];
50
+ toolAvailabilityError?: string;
48
51
  }
49
52
 
50
53
  interface CompletionMutationGuardResult {
51
54
  expectedMutation: boolean;
52
55
  attemptedMutation: boolean;
53
56
  triggered: boolean;
57
+ blocked: boolean;
58
+ message?: string;
54
59
  }
55
60
 
56
61
  interface ReportSentence {
@@ -64,6 +69,40 @@ export function hasMutationToolCapability(tools: string[] | undefined, mcpDirect
64
69
  return !tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool));
65
70
  }
66
71
 
72
+ function hasBuiltinMutationTool(tools: string[] | undefined): boolean {
73
+ return tools === undefined || tools.some((tool) => !READ_ONLY_BUILTIN_TOOLS.has(tool));
74
+ }
75
+
76
+ function isWriterRole(agent: string, acceptanceRole: AcceptanceRole | undefined): boolean {
77
+ return acceptanceRole === "writer"
78
+ || (acceptanceRole === undefined && /\bworker\b/i.test(agent));
79
+ }
80
+
81
+ const WRITER_DELIVERY_PATTERN = /\b(?:address|resolve|work)\s+(?:backlog\s+)?(?:item|issue)\b|\b(?:land|ship)\s+(?:the\s+)?(?:change|changes|fix|feature|implementation)\b/i;
82
+
83
+ export function validateImplementationToolContract(input: {
84
+ agent: string;
85
+ task: string;
86
+ tools?: string[];
87
+ mcpDirectTools?: string[];
88
+ configuredExtensions?: string[];
89
+ requestedTools?: string[];
90
+ acceptanceRole?: AcceptanceRole;
91
+ completionGuard?: boolean;
92
+ }): string | undefined {
93
+ if (input.completionGuard === false) return undefined;
94
+ const requestedMutationTools = input.requestedTools?.filter((tool) => !READ_ONLY_BUILTIN_TOOLS.has(tool)) ?? [];
95
+ const declaredMutationToolsWereRemoved = requestedMutationTools.length > 0 && !hasBuiltinMutationTool(input.tools);
96
+ const configuredExtensionCapability = (input.configuredExtensions?.length ?? 0) > 0 && !declaredMutationToolsWereRemoved;
97
+ if (hasMutationToolCapability(input.tools, input.mcpDirectTools) || configuredExtensionCapability) return undefined;
98
+ const intent = classifyTaskMutationIntent(input.agent, input.task);
99
+ if (intent.kind === "read-only") return undefined;
100
+ const writerTaskMayMutate = isWriterRole(input.agent, input.acceptanceRole)
101
+ && (taskMayMutate(input.task) || WRITER_DELIVERY_PATTERN.test(input.task));
102
+ if (intent.kind !== "implementation" && !writerTaskMayMutate && !declaredMutationToolsWereRemoved) return undefined;
103
+ return `Agent '${input.agent}' was given an implementation task, but its tool allowlist has no mutation-capable tools. Add bash, edit, write, or another mutation-capable tool to the agent, or use a read-only task/agent.`;
104
+ }
105
+
67
106
  function hasCheckpointMutationEvidence(message: Message): boolean {
68
107
  const record = message as unknown as {
69
108
  role?: string;
@@ -187,6 +226,15 @@ function reportsNoBetterChallengeChange(messages: Message[]): boolean {
187
226
  }
188
227
 
189
228
  export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
229
+ if (input.toolAvailabilityError && expectsImplementationMutation(input.agent, input.task)) {
230
+ return {
231
+ expectedMutation: true,
232
+ attemptedMutation: false,
233
+ triggered: false,
234
+ blocked: true,
235
+ message: input.toolAvailabilityError,
236
+ };
237
+ }
190
238
  const expectedMutation = hasMutationToolCapability(input.tools, input.mcpDirectTools)
191
239
  ? expectsImplementationMutation(input.agent, input.task)
192
240
  : false;
@@ -197,5 +245,6 @@ export function evaluateCompletionMutationGuard(input: CompletionMutationGuardIn
197
245
  expectedMutation,
198
246
  attemptedMutation,
199
247
  triggered: expectedMutation && !attemptedMutation && !noEditChallengeComplete,
248
+ blocked: false,
200
249
  };
201
250
  }
@@ -11,6 +11,7 @@ import {
11
11
  type ExternalJobHandle,
12
12
  type ExternalJobOperation,
13
13
  type ExternalJobResult,
14
+ type ExternalJobFollowUpInput,
14
15
  type ExternalJobStartInput,
15
16
  } from "../../api/external-job-provider.ts";
16
17
 
@@ -26,6 +27,7 @@ interface ExternalJobBridgeRequest {
26
27
  provider: string;
27
28
  providerJobId?: string;
28
29
  start?: ExternalJobStartInput;
30
+ followUp?: ExternalJobFollowUpInput;
29
31
  createdAt: number;
30
32
  claimedAt?: number;
31
33
  }
@@ -72,32 +74,36 @@ function requestPath(asyncDir: string, id: string): string {
72
74
  return path.join(requestDir(asyncDir), `${id}.json`);
73
75
  }
74
76
 
75
- function startClaimDir(asyncDir: string, id: string): string {
77
+ function isDispatchOperation(operation: ExternalJobOperation): boolean {
78
+ return operation === "start" || operation === "follow-up";
79
+ }
80
+
81
+ function dispatchClaimDir(asyncDir: string, id: string): string {
76
82
  return path.join(requestDir(asyncDir), `${id}.claim`);
77
83
  }
78
84
 
79
- function startClaimTempDir(asyncDir: string, id: string): string {
85
+ function dispatchClaimTempDir(asyncDir: string, id: string): string {
80
86
  return path.join(requestDir(asyncDir), `${id}.claim.tmp-${randomUUID()}`);
81
87
  }
82
88
 
83
- function startClaimCompletedPath(claimDir: string): string {
89
+ function dispatchClaimCompletedPath(claimDir: string): string {
84
90
  return path.join(claimDir, "completed.json");
85
91
  }
86
92
 
87
- function startClaimHandlePath(claimDir: string): string {
93
+ function dispatchClaimHandlePath(claimDir: string): string {
88
94
  return path.join(claimDir, "handle.json");
89
95
  }
90
96
 
91
- function completedStartClaimExists(asyncDir: string, id: string): boolean {
92
- return fs.existsSync(startClaimCompletedPath(startClaimDir(asyncDir, id)));
97
+ function completedDispatchClaimExists(asyncDir: string, id: string): boolean {
98
+ return fs.existsSync(dispatchClaimCompletedPath(dispatchClaimDir(asyncDir, id)));
93
99
  }
94
100
 
95
- function cancelStartRequest(asyncDir: string, request: ExternalJobBridgeRequest): boolean {
96
- const claimDir = startClaimDir(asyncDir, request.id);
97
- const tempClaimDir = startClaimTempDir(asyncDir, request.id);
101
+ function cancelDispatchRequest(asyncDir: string, request: ExternalJobBridgeRequest): boolean {
102
+ const claimDir = dispatchClaimDir(asyncDir, request.id);
103
+ const tempClaimDir = dispatchClaimTempDir(asyncDir, request.id);
98
104
  try {
99
105
  fs.mkdirSync(tempClaimDir);
100
- writeAtomicJson(startClaimCompletedPath(tempClaimDir), { completedAt: Date.now() });
106
+ writeAtomicJson(dispatchClaimCompletedPath(tempClaimDir), { completedAt: Date.now() });
101
107
  fs.renameSync(tempClaimDir, claimDir);
102
108
  } catch (error) {
103
109
  fs.rmSync(tempClaimDir, { recursive: true, force: true });
@@ -190,12 +196,14 @@ function assertRequest(value: unknown, filePath: string): ExternalJobBridgeReque
190
196
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`External-job bridge request '${filePath}' must be an object.`);
191
197
  const request = value as ExternalJobBridgeRequest;
192
198
  if (typeof request.id !== "string" || !request.id) throw new Error(`External-job bridge request '${filePath}' has invalid id.`);
193
- if (request.operation !== "start" && request.operation !== "status" && request.operation !== "result" && request.operation !== "reattach") throw new Error(`External-job bridge request '${filePath}' has invalid operation.`);
199
+ if (request.operation !== "start" && request.operation !== "follow-up" && request.operation !== "status" && request.operation !== "result" && request.operation !== "reattach") throw new Error(`External-job bridge request '${filePath}' has invalid operation.`);
194
200
  if (typeof request.provider !== "string" || !request.provider.trim()) throw new Error(`External-job bridge request '${filePath}' has invalid provider.`);
195
201
  if (typeof request.createdAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid createdAt.`);
196
202
  if (request.claimedAt !== undefined && typeof request.claimedAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid claimedAt.`);
197
203
  if (request.operation === "start") {
198
204
  if (!request.start || typeof request.start !== "object" || Array.isArray(request.start)) throw new Error(`External-job bridge start request '${filePath}' is missing start input.`);
205
+ } else if (request.operation === "follow-up") {
206
+ if (!request.followUp || typeof request.followUp !== "object" || Array.isArray(request.followUp)) throw new Error(`External-job bridge follow-up request '${filePath}' is missing follow-up input.`);
199
207
  } else if (typeof request.providerJobId !== "string" || !request.providerJobId.trim()) {
200
208
  throw new Error(`External-job bridge ${request.operation} request '${filePath}' is missing providerJobId.`);
201
209
  }
@@ -216,17 +224,25 @@ async function executeBridgeRequest(request: ExternalJobBridgeRequest, claimDir?
216
224
  };
217
225
  }
218
226
  try {
219
- const raw = request.operation === "start"
220
- ? await provider.start(request.start!)
221
- : request.operation === "status"
227
+ let raw: ExternalJobHandle | ExternalJobResult;
228
+ if (request.operation === "start") {
229
+ raw = await provider.start(request.start!);
230
+ } else if (request.operation === "follow-up") {
231
+ if (typeof provider.followUp !== "function") {
232
+ throw new ExternalJobProviderError(`External-job provider '${request.provider}' does not support follow-up. Update or reload the provider package, then retry action='resume'.`, { code: "follow-up-unsupported" });
233
+ }
234
+ raw = await provider.followUp(request.followUp!);
235
+ } else {
236
+ raw = request.operation === "status"
222
237
  ? await provider.status(request.providerJobId!)
223
238
  : request.operation === "reattach"
224
239
  ? await provider.reattach(request.providerJobId!)
225
240
  : await provider.result(request.providerJobId!);
241
+ }
226
242
  const result = request.operation === "result"
227
243
  ? validateExternalJobResult(provider.name, raw, "External-job bridge result")
228
244
  : validateExternalJobHandle(provider.name, raw, "External-job bridge handle");
229
- if (request.operation === "start" && claimDir) writeAtomicJson(startClaimHandlePath(claimDir), result);
245
+ if (isDispatchOperation(request.operation) && claimDir) writeAtomicJson(dispatchClaimHandlePath(claimDir), result);
230
246
  return { id: request.id, ok: true, operation: request.operation, provider: request.provider, result, completedAt: Date.now() };
231
247
  } catch (error) {
232
248
  const details = bridgeError(error);
@@ -241,11 +257,11 @@ async function executeBridgeRequest(request: ExternalJobBridgeRequest, claimDir?
241
257
  }
242
258
  }
243
259
 
244
- function claimStartRequest(asyncDir: string, filePath: string, request: ExternalJobBridgeRequest): { request: ExternalJobBridgeRequest; filePath: string } | undefined {
260
+ function claimDispatchRequest(asyncDir: string, filePath: string, request: ExternalJobBridgeRequest): { request: ExternalJobBridgeRequest; filePath: string } | undefined {
245
261
  const owner = currentClaimOwner();
246
262
  const claimed: ExternalJobBridgeRequest = { ...request, claimedAt: owner.claimedAt };
247
- const claimDir = startClaimDir(asyncDir, request.id);
248
- const tempClaimDir = startClaimTempDir(asyncDir, request.id);
263
+ const claimDir = dispatchClaimDir(asyncDir, request.id);
264
+ const tempClaimDir = dispatchClaimTempDir(asyncDir, request.id);
249
265
  try {
250
266
  fs.mkdirSync(tempClaimDir);
251
267
  writeAtomicJson(path.join(tempClaimDir, "owner.json"), owner);
@@ -287,7 +303,7 @@ function readClaimOwner(claimDir: string): ExternalJobClaimOwner | undefined {
287
303
 
288
304
  function readClaimHandle(provider: string, claimDir: string): ExternalJobHandle | undefined {
289
305
  try {
290
- return validateExternalJobHandle(provider, readJson(startClaimHandlePath(claimDir)), "External-job bridge recovered start handle");
306
+ return validateExternalJobHandle(provider, readJson(dispatchClaimHandlePath(claimDir)), "External-job bridge recovered dispatch handle");
291
307
  } catch {
292
308
  return undefined;
293
309
  }
@@ -297,7 +313,7 @@ export function serviceExternalJobBridgeRequests(asyncDir: string): void {
297
313
  let files: string[];
298
314
  try {
299
315
  files = fs.readdirSync(requestDir(asyncDir), { withFileTypes: true })
300
- .filter((entry) => (entry.isFile() && entry.name.endsWith(".json") && !completedStartClaimExists(asyncDir, entry.name.replace(/\.json$/, ""))) || (entry.isDirectory() && entry.name.endsWith(".claim") && !fs.existsSync(startClaimCompletedPath(path.join(requestDir(asyncDir), entry.name)))))
316
+ .filter((entry) => (entry.isFile() && entry.name.endsWith(".json") && !completedDispatchClaimExists(asyncDir, entry.name.replace(/\.json$/, ""))) || (entry.isDirectory() && entry.name.endsWith(".claim") && !fs.existsSync(dispatchClaimCompletedPath(path.join(requestDir(asyncDir), entry.name)))))
301
317
  .map((entry) => entry.name)
302
318
  .slice(0, MAX_REQUESTS_PER_SWEEP);
303
319
  } catch (error) {
@@ -313,7 +329,7 @@ export function serviceExternalJobBridgeRequests(asyncDir: string): void {
313
329
  export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: string): void {
314
330
  fs.mkdirSync(responseDir(asyncDir), { recursive: true });
315
331
  if (file.endsWith(".claim")) {
316
- serviceExternalJobStartClaim(asyncDir, file);
332
+ serviceExternalJobDispatchClaim(asyncDir, file);
317
333
  return;
318
334
  }
319
335
  const filePath = path.join(requestDir(asyncDir), file);
@@ -335,20 +351,20 @@ export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: stri
335
351
  fs.rmSync(filePath, { force: true });
336
352
  return;
337
353
  }
338
- if (request.operation === "start" && completedStartClaimExists(asyncDir, request.id)) {
354
+ if (isDispatchOperation(request.operation) && completedDispatchClaimExists(asyncDir, request.id)) {
339
355
  fs.rmSync(filePath, { force: true });
340
356
  return;
341
357
  }
342
358
  if (inFlight.has(request.id) || fs.existsSync(responsePath(asyncDir, request.id))) return;
343
- if (request.operation === "start" && request.claimedAt !== undefined) return;
344
- const claimed = request.operation === "start" ? claimStartRequest(asyncDir, filePath, request) : { request, filePath };
359
+ if (isDispatchOperation(request.operation) && request.claimedAt !== undefined) return;
360
+ const claimed = isDispatchOperation(request.operation) ? claimDispatchRequest(asyncDir, filePath, request) : { request, filePath };
345
361
  if (!claimed) return;
346
362
  const claimedRequest = claimed.request;
347
363
  inFlight.add(claimedRequest.id);
348
- void executeBridgeRequest(claimedRequest, claimedRequest.operation === "start" ? claimed.filePath : undefined).then((response) => {
364
+ void executeBridgeRequest(claimedRequest, isDispatchOperation(claimedRequest.operation) ? claimed.filePath : undefined).then((response) => {
349
365
  writeAtomicJson(responsePath(asyncDir, claimedRequest.id), response);
350
- if (claimedRequest.operation === "start") {
351
- writeAtomicJson(startClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
366
+ if (isDispatchOperation(claimedRequest.operation)) {
367
+ writeAtomicJson(dispatchClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
352
368
  } else {
353
369
  fs.rmSync(claimed.filePath, { recursive: true, force: true });
354
370
  }
@@ -362,15 +378,15 @@ export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: stri
362
378
  message: error instanceof Error ? error.message : String(error),
363
379
  completedAt: Date.now(),
364
380
  } satisfies ExternalJobBridgeResponse);
365
- if (claimedRequest.operation === "start") writeAtomicJson(startClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
381
+ if (isDispatchOperation(claimedRequest.operation)) writeAtomicJson(dispatchClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
366
382
  }).finally(() => {
367
383
  inFlight.delete(claimedRequest.id);
368
384
  });
369
385
  }
370
386
 
371
- function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
387
+ function serviceExternalJobDispatchClaim(asyncDir: string, file: string): void {
372
388
  const claimDir = path.join(requestDir(asyncDir), file);
373
- if (fs.existsSync(startClaimCompletedPath(claimDir))) return;
389
+ if (fs.existsSync(dispatchClaimCompletedPath(claimDir))) return;
374
390
  const owner = readClaimOwner(claimDir);
375
391
  if (!owner && fs.existsSync(requestPath(asyncDir, file.replace(/\.claim$/, ""))) && !fs.existsSync(responsePath(asyncDir, file.replace(/\.claim$/, "")))) {
376
392
  fs.rmSync(claimDir, { recursive: true, force: true });
@@ -383,7 +399,7 @@ function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
383
399
  } catch {
384
400
  return;
385
401
  }
386
- if (request.operation !== "start" || fs.existsSync(responsePath(asyncDir, request.id))) return;
402
+ if (!isDispatchOperation(request.operation) || fs.existsSync(responsePath(asyncDir, request.id))) return;
387
403
  const handle = readClaimHandle(request.provider, claimDir);
388
404
  if (handle) {
389
405
  writeAtomicJson(responsePath(asyncDir, request.id), {
@@ -394,7 +410,7 @@ function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
394
410
  result: handle,
395
411
  completedAt: Date.now(),
396
412
  } satisfies ExternalJobBridgeResponse);
397
- writeAtomicJson(startClaimCompletedPath(claimDir), { completedAt: Date.now() });
413
+ writeAtomicJson(dispatchClaimCompletedPath(claimDir), { completedAt: Date.now() });
398
414
  fs.rmSync(requestPath(asyncDir, request.id), { force: true });
399
415
  return;
400
416
  }
@@ -403,8 +419,8 @@ function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
403
419
  ok: false,
404
420
  operation: request.operation,
405
421
  provider: request.provider,
406
- code: "start-dispatch-abandoned",
407
- message: `External-job start for provider '${request.provider}' was claimed by a host process that is no longer alive before a provider job id was committed. Refusing to redispatch the prompt automatically.`,
422
+ code: `${request.operation}-dispatch-abandoned`,
423
+ message: `External-job ${request.operation} for provider '${request.provider}' was claimed by a host process that is no longer alive before a provider job id was committed. Refusing to redispatch the prompt automatically.`,
408
424
  completedAt: Date.now(),
409
425
  } satisfies ExternalJobBridgeResponse);
410
426
  fs.rmSync(claimDir, { recursive: true, force: true });
@@ -417,16 +433,16 @@ export async function requestExternalJobOperation<T extends ExternalJobHandle |
417
433
  fs.mkdirSync(responseDir(asyncDir), { recursive: true });
418
434
  const bridgeRequest = { ...request, id, createdAt: Date.now() } satisfies ExternalJobBridgeRequest;
419
435
  writeAtomicJson(requestPath(asyncDir, id), bridgeRequest);
420
- const deadline = request.operation === "start" ? undefined : Date.now() + timeoutMs;
436
+ const deadline = isDispatchOperation(request.operation) ? undefined : Date.now() + timeoutMs;
421
437
  const outPath = responsePath(asyncDir, id);
422
438
  while (!fs.existsSync(outPath)) {
423
439
  const canceled = cancel?.();
424
440
  if (canceled) {
425
- if (request.operation === "start" && !cancelStartRequest(asyncDir, bridgeRequest)) {
441
+ if (isDispatchOperation(request.operation) && !cancelDispatchRequest(asyncDir, bridgeRequest)) {
426
442
  await sleep(POLL_INTERVAL_MS);
427
443
  continue;
428
444
  }
429
- if (request.operation !== "start") fs.rmSync(requestPath(asyncDir, id), { force: true });
445
+ if (!isDispatchOperation(request.operation)) fs.rmSync(requestPath(asyncDir, id), { force: true });
430
446
  throw canceled;
431
447
  }
432
448
  if (deadline !== undefined && Date.now() >= deadline) {
@@ -20,6 +20,31 @@ export function externalJobPromptDigest(prompt: string): string {
20
20
  return createHash("sha256").update(prompt).digest("hex");
21
21
  }
22
22
 
23
+ export function externalJobStableJson(value: unknown): string {
24
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
25
+ if (Array.isArray(value)) return `[${value.map(externalJobStableJson).join(",")}]`;
26
+ const record = value as Record<string, unknown>;
27
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${externalJobStableJson(record[key])}`).join(",")}}`;
28
+ }
29
+
30
+ export function externalJobFollowUpRequestDigest(input: { provider: string; parentProviderJobId: string; promptDigest: string; options: Record<string, unknown> }): string {
31
+ return createHash("sha256")
32
+ .update(externalJobStableJson({ provider: input.provider, parentProviderJobId: input.parentProviderJobId, promptDigest: input.promptDigest, options: input.options }))
33
+ .digest("hex");
34
+ }
35
+
36
+ export function externalJobFollowUpRequestId(requestDigest: string): string {
37
+ return `follow-up-${requestDigest.slice(0, 48)}`;
38
+ }
39
+
40
+ export function externalJobFollowUpRunId(requestDigest: string): string {
41
+ const hex = requestDigest.padEnd(32, "0").slice(0, 32).split("");
42
+ hex[12] = "4";
43
+ hex[16] = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
44
+ const value = hex.join("");
45
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20, 32)}`;
46
+ }
47
+
23
48
  function sleep(ms: number): Promise<void> {
24
49
  return new Promise((resolve) => setTimeout(resolve, ms));
25
50
  }
@@ -70,11 +95,29 @@ function parseExternalJobStatus(value: unknown, statusPath: string): ExternalJob
70
95
  if (failureCode !== undefined && typeof failureCode !== "string") throw malformedStatus(statusPath);
71
96
  if (failureMessage !== undefined && typeof failureMessage !== "string") throw malformedStatus(statusPath);
72
97
  if (blockingJobId !== undefined && typeof blockingJobId !== "string") throw malformedStatus(statusPath);
98
+ const operation = value.operation;
99
+ const sourceRunId = value.sourceRunId;
100
+ const sourceStepIndex = value.sourceStepIndex;
101
+ const parentProviderJobId = value.parentProviderJobId;
102
+ const requestId = value.requestId;
103
+ const requestDigest = value.requestDigest;
104
+ if (operation !== undefined && operation !== "start" && operation !== "follow-up") throw malformedStatus(statusPath);
105
+ if (sourceRunId !== undefined && (typeof sourceRunId !== "string" || sourceRunId.length === 0)) throw malformedStatus(statusPath);
106
+ if (sourceStepIndex !== undefined && (typeof sourceStepIndex !== "number" || !Number.isInteger(sourceStepIndex) || sourceStepIndex < 0)) throw malformedStatus(statusPath);
107
+ if (parentProviderJobId !== undefined && (typeof parentProviderJobId !== "string" || parentProviderJobId.length === 0)) throw malformedStatus(statusPath);
108
+ if (requestId !== undefined && (typeof requestId !== "string" || requestId.length === 0)) throw malformedStatus(statusPath);
109
+ if (requestDigest !== undefined && (typeof requestDigest !== "string" || requestDigest.length === 0)) throw malformedStatus(statusPath);
73
110
  if (startedAt !== undefined && typeof startedAt !== "number") throw malformedStatus(statusPath);
74
111
  if (updatedAt !== undefined && typeof updatedAt !== "number") throw malformedStatus(statusPath);
75
112
  return {
76
113
  provider,
77
114
  promptDigest,
115
+ ...(operation ? { operation } : {}),
116
+ ...(sourceRunId ? { sourceRunId } : {}),
117
+ ...(sourceStepIndex !== undefined ? { sourceStepIndex } : {}),
118
+ ...(parentProviderJobId ? { parentProviderJobId } : {}),
119
+ ...(requestId ? { requestId } : {}),
120
+ ...(requestDigest ? { requestDigest } : {}),
78
121
  options: value.options ?? {},
79
122
  state,
80
123
  ...(providerJobId ? { providerJobId } : {}),
@@ -111,10 +154,30 @@ function readExistingExternalJob(asyncDir: string, stepIndex: number): ExternalJ
111
154
  return parseExternalJobStatus(step.externalJob, statusPath);
112
155
  }
113
156
 
157
+ function externalJobLineage(followUp: ExternalJobFollowUpDescriptor | undefined, previous: ExternalJobStatus | undefined) {
158
+ return {
159
+ operation: followUp ? "follow-up" as const : previous?.operation,
160
+ ...(followUp ? {
161
+ sourceRunId: followUp.sourceRunId,
162
+ sourceStepIndex: followUp.sourceStepIndex,
163
+ parentProviderJobId: followUp.parentProviderJobId,
164
+ requestId: followUp.requestId,
165
+ requestDigest: followUp.requestDigest,
166
+ } : {
167
+ ...(previous?.sourceRunId ? { sourceRunId: previous.sourceRunId } : {}),
168
+ ...(previous?.sourceStepIndex !== undefined ? { sourceStepIndex: previous.sourceStepIndex } : {}),
169
+ ...(previous?.parentProviderJobId ? { parentProviderJobId: previous.parentProviderJobId } : {}),
170
+ ...(previous?.requestId ? { requestId: previous.requestId } : {}),
171
+ ...(previous?.requestDigest ? { requestDigest: previous.requestDigest } : {}),
172
+ }),
173
+ };
174
+ }
175
+
114
176
  function statusFromHandle(input: {
115
177
  provider: string;
116
178
  promptDigest: string;
117
179
  options: Record<string, unknown>;
180
+ followUp?: ExternalJobFollowUpDescriptor;
118
181
  startedAt?: number;
119
182
  previous?: ExternalJobStatus;
120
183
  handle: ExternalJobHandle;
@@ -124,6 +187,7 @@ function statusFromHandle(input: {
124
187
  provider: input.provider,
125
188
  providerJobId: input.handle.providerJobId,
126
189
  promptDigest: input.promptDigest,
190
+ ...externalJobLineage(input.followUp, input.previous),
127
191
  options: input.options,
128
192
  handleUrl: input.handle.handleUrl ?? input.previous?.handleUrl,
129
193
  conversationUrl: input.handle.conversationUrl ?? input.previous?.conversationUrl,
@@ -141,6 +205,7 @@ function failureStatus(input: {
141
205
  provider: string;
142
206
  promptDigest: string;
143
207
  options: Record<string, unknown>;
208
+ followUp?: ExternalJobFollowUpDescriptor;
144
209
  previous?: ExternalJobStatus;
145
210
  code: string;
146
211
  message: string;
@@ -150,6 +215,7 @@ function failureStatus(input: {
150
215
  provider: input.provider,
151
216
  providerJobId: input.previous?.providerJobId,
152
217
  promptDigest: input.promptDigest,
218
+ ...externalJobLineage(input.followUp, input.previous),
153
219
  options: input.options,
154
220
  handleUrl: input.previous?.handleUrl,
155
221
  conversationUrl: input.previous?.conversationUrl,
@@ -176,6 +242,24 @@ function blocksStartRedispatch(status: ExternalJobStatus, provider: string, prom
176
242
  && status.failureCode !== "provider-unavailable";
177
243
  }
178
244
 
245
+ interface ExternalJobFollowUpDescriptor {
246
+ sourceRunId: string;
247
+ sourceStepIndex: number;
248
+ parentProviderJobId: string;
249
+ requestId: string;
250
+ requestDigest: string;
251
+ }
252
+
253
+ function sameFollowUpLineage(status: ExternalJobStatus, followUp: ExternalJobFollowUpDescriptor | undefined): boolean {
254
+ if (!followUp) return status.operation !== "follow-up";
255
+ return status.operation === "follow-up"
256
+ && status.sourceRunId === followUp.sourceRunId
257
+ && status.sourceStepIndex === followUp.sourceStepIndex
258
+ && status.parentProviderJobId === followUp.parentProviderJobId
259
+ && status.requestId === followUp.requestId
260
+ && status.requestDigest === followUp.requestDigest;
261
+ }
262
+
179
263
  export async function runExternalJob(input: {
180
264
  provider: string;
181
265
  options?: Record<string, unknown>;
@@ -191,6 +275,7 @@ export async function runExternalJob(input: {
191
275
  timeoutMessage?: string;
192
276
  stopMessage?: string;
193
277
  onExternalJob?: (status: ExternalJobStatus) => void;
278
+ followUp?: ExternalJobFollowUpDescriptor;
194
279
  }): Promise<ExternalJobRunResult> {
195
280
  const provider = input.provider;
196
281
  const options = input.options ?? {};
@@ -218,37 +303,55 @@ export async function runExternalJob(input: {
218
303
  current = readExistingExternalJob(input.asyncDir, input.stepIndex);
219
304
  let handle: ExternalJobHandle;
220
305
  if (current?.providerJobId) {
221
- if (current.provider !== provider || current.promptDigest !== promptDigest) {
222
- const message = `Existing external job '${current.providerJobId}' does not match provider or prompt digest. Refusing to redispatch prompt.`;
223
- const status = failureStatus({ provider, promptDigest, options, previous: current, code: "recovery-mismatch", message });
306
+ if (current.provider !== provider || current.promptDigest !== promptDigest || !sameFollowUpLineage(current, input.followUp)) {
307
+ const message = `Existing external job '${current.providerJobId}' does not match provider, prompt digest, or follow-up lineage. Refusing to redispatch prompt.`;
308
+ const status = failureStatus({ provider, promptDigest, options, followUp: input.followUp, previous: current, code: "recovery-mismatch", message });
224
309
  publish(status);
225
310
  return { output: message, exitCode: 1, error: message, externalJob: status };
226
311
  }
227
312
  handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "reattach", provider, providerJobId: current.providerJobId });
228
313
  } else {
229
314
  if (current && blocksStartRedispatch(current, provider, promptDigest)) {
230
- const message = `External-job start for provider '${provider}' previously ended without a durable provider job id. Refusing to redispatch the prompt automatically.`;
231
- const status = failureStatus({ provider, promptDigest, options, previous: current, code: "start-redispatch-blocked", message });
315
+ const message = `External-job ${input.followUp ? "follow-up" : "start"} for provider '${provider}' previously ended without a durable provider job id. Refusing to redispatch the prompt automatically.`;
316
+ const status = failureStatus({ provider, promptDigest, options, followUp: input.followUp, previous: current, code: input.followUp ? "dispatch-redispatch-blocked" : "start-redispatch-blocked", message });
232
317
  publish(status);
233
318
  return { output: message, exitCode: 1, error: message, externalJob: status };
234
319
  }
235
- publish({ provider, promptDigest, options, state: "queued", startedAt, updatedAt: Date.now() });
320
+ publish({ provider, promptDigest, ...(input.followUp ? { operation: "follow-up" as const, ...input.followUp } : {}), options, state: "queued", startedAt, updatedAt: Date.now() });
321
+ if (input.followUp) {
236
322
  handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, {
237
- operation: "start",
238
- provider,
239
- start: {
240
- prompt: input.prompt,
241
- promptDigest,
242
- cwd: input.cwd,
243
- runId: input.runId,
244
- stepIndex: input.stepIndex,
245
- agent: input.agent,
246
- options,
247
- ...(input.sessionId ? { sessionId: input.sessionId } : {}),
248
- },
249
- }, undefined, localCancellation);
323
+ operation: "follow-up",
324
+ provider,
325
+ followUp: {
326
+ prompt: input.prompt,
327
+ promptDigest,
328
+ cwd: input.cwd,
329
+ runId: input.runId,
330
+ stepIndex: input.stepIndex,
331
+ agent: input.agent,
332
+ options,
333
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
334
+ ...input.followUp,
335
+ },
336
+ }, undefined, localCancellation);
337
+ } else {
338
+ handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, {
339
+ operation: "start",
340
+ provider,
341
+ start: {
342
+ prompt: input.prompt,
343
+ promptDigest,
344
+ cwd: input.cwd,
345
+ runId: input.runId,
346
+ stepIndex: input.stepIndex,
347
+ agent: input.agent,
348
+ options,
349
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
350
+ },
351
+ }, undefined, localCancellation);
352
+ }
250
353
  }
251
- publish(statusFromHandle({ provider, promptDigest, options, startedAt, previous: current, handle }));
354
+ publish(statusFromHandle({ provider, promptDigest, options, followUp: input.followUp, startedAt, previous: current, handle }));
252
355
  while (!terminal(handle.state)) {
253
356
  if (timedOut || stopped) {
254
357
  const message = stopped
@@ -258,7 +361,7 @@ export async function runExternalJob(input: {
258
361
  }
259
362
  await sleep(STATUS_POLL_INTERVAL_MS);
260
363
  handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "status", provider, providerJobId: handle.providerJobId });
261
- publish(statusFromHandle({ provider, promptDigest, options, previous: current, handle }));
364
+ publish(statusFromHandle({ provider, promptDigest, options, followUp: input.followUp, previous: current, handle }));
262
365
  }
263
366
  const result = await requestExternalJobOperation<ExternalJobResult>(input.asyncDir, { operation: "result", provider, providerJobId: handle.providerJobId });
264
367
  let artifactPath = result.artifactPath;
@@ -266,7 +369,7 @@ export async function runExternalJob(input: {
266
369
  artifactPath = path.join(input.asyncDir, `external-job-${input.stepIndex}.result.md`);
267
370
  fs.writeFileSync(artifactPath, result.output, "utf-8");
268
371
  }
269
- const finalStatus = statusFromHandle({ provider, promptDigest, options, previous: current, handle: result, resultArtifactPath: artifactPath });
372
+ const finalStatus = statusFromHandle({ provider, promptDigest, options, followUp: input.followUp, previous: current, handle: result, resultArtifactPath: artifactPath });
270
373
  publish(finalStatus);
271
374
  const output = resultOutput(result, artifactPath);
272
375
  const error = result.state === "completed" ? undefined : result.failureMessage ?? `External job ${result.state}.`;
@@ -276,7 +379,7 @@ export async function runExternalJob(input: {
276
379
  ? error
277
380
  : new ExternalJobProviderError(error instanceof Error ? error.message : String(error), { code: "provider-error", cause: error });
278
381
  const message = formatError(providerError, provider);
279
- const status = failureStatus({ provider, promptDigest, options, previous: current, code: providerError.code, message, ...(providerError.blockingJobId ? { blockingJobId: providerError.blockingJobId } : {}) });
382
+ const status = failureStatus({ provider, promptDigest, options, followUp: input.followUp, previous: current, code: providerError.code, message, ...(providerError.blockingJobId ? { blockingJobId: providerError.blockingJobId } : {}) });
280
383
  publish(status);
281
384
  return { output: message, exitCode: 1, error: message, ...(timedOut ? { timedOut: true } : {}), ...(stopped ? { stopped: true } : {}), externalJob: status };
282
385
  } finally {