pi-subagents 0.55.0 → 0.57.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 (97) hide show
  1. package/CHANGELOG.md +81 -3
  2. package/agents/claude-code-writer.md +15 -0
  3. package/agents/claude-code.md +15 -0
  4. package/agents/codex-exec-writer.md +15 -0
  5. package/agents/codex-exec.md +15 -0
  6. package/agents/cursor-agent-writer.md +14 -0
  7. package/agents/cursor-agent.md +14 -0
  8. package/docs/agents.md +111 -15
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +2 -2
  11. package/docs/models.md +6 -0
  12. package/docs/observability.md +8 -7
  13. package/docs/tool-reference.md +11 -1
  14. package/docs/workflows.md +20 -0
  15. package/package.json +1 -1
  16. package/skills/pi-subagents/references/execution-controls.md +1 -1
  17. package/src/agents/agent-management.ts +13 -5
  18. package/src/agents/agent-refinements.ts +4 -4
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +242 -49
  21. package/src/agents/builtin-names.ts +6 -0
  22. package/src/agents/runtime-agent-registry.ts +8 -2
  23. package/src/api/preflight.ts +22 -3
  24. package/src/extension/config.ts +70 -0
  25. package/src/extension/doctor.ts +3 -3
  26. package/src/extension/index.ts +18 -4
  27. package/src/extension/public-execution.ts +29 -13
  28. package/src/extension/rpc.ts +20 -5
  29. package/src/extension/schemas.ts +9 -3
  30. package/src/extension/tool-description.ts +11 -9
  31. package/src/inspectors/herdr/actions.ts +2 -1
  32. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  33. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  34. package/src/integrations/herdr-status.ts +46 -3
  35. package/src/runs/background/active-async-capacity.ts +77 -10
  36. package/src/runs/background/async-execution.ts +148 -27
  37. package/src/runs/background/async-job-tracker.ts +5 -0
  38. package/src/runs/background/async-resume.ts +3 -0
  39. package/src/runs/background/async-retention.ts +20 -3
  40. package/src/runs/background/async-status.ts +5 -0
  41. package/src/runs/background/chain-root-attachment.ts +15 -1
  42. package/src/runs/background/fleet-view.ts +16 -10
  43. package/src/runs/background/inspect-rpc.ts +8 -8
  44. package/src/runs/background/result-files.ts +27 -14
  45. package/src/runs/background/result-watcher.ts +9 -5
  46. package/src/runs/background/run-status.ts +33 -6
  47. package/src/runs/background/scheduled-runs.ts +7 -1
  48. package/src/runs/background/subagent-runner.ts +254 -49
  49. package/src/runs/background/wait-completions.ts +4 -0
  50. package/src/runs/foreground/execution.ts +107 -16
  51. package/src/runs/foreground/foreground-control.ts +6 -0
  52. package/src/runs/foreground/foreground-history.ts +25 -3
  53. package/src/runs/foreground/subagent-executor.ts +324 -84
  54. package/src/runs/foreground/workflow-detach-reconcile.ts +31 -7
  55. package/src/runs/shared/acceptance.ts +10 -5
  56. package/src/runs/shared/agent-contract.ts +1 -1
  57. package/src/runs/shared/child-protocol.ts +21 -7
  58. package/src/runs/shared/claude-code-adapter.ts +129 -0
  59. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  60. package/src/runs/shared/completion-guard.ts +3 -2
  61. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  62. package/src/runs/shared/dynamic-fanout.ts +1 -1
  63. package/src/runs/shared/extension-bindings.ts +78 -0
  64. package/src/runs/shared/external-cli-contract.ts +167 -0
  65. package/src/runs/shared/external-cli-preflight.ts +122 -0
  66. package/src/runs/shared/external-cli-runner.ts +349 -54
  67. package/src/runs/shared/fast-mode-extension.ts +10 -0
  68. package/src/runs/shared/model-exclusions.ts +59 -8
  69. package/src/runs/shared/model-fallback.ts +25 -8
  70. package/src/runs/shared/mutation-evidence.ts +150 -0
  71. package/src/runs/shared/nested-events.ts +3 -1
  72. package/src/runs/shared/nested-render.ts +2 -2
  73. package/src/runs/shared/parallel-utils.ts +3 -0
  74. package/src/runs/shared/pi-args.ts +47 -0
  75. package/src/runs/shared/process-signal.ts +13 -0
  76. package/src/runs/shared/run-history.ts +21 -1
  77. package/src/runs/shared/structured-output.ts +18 -4
  78. package/src/runs/shared/subagent-prompt-runtime.ts +9 -5
  79. package/src/shared/fork-context.ts +21 -0
  80. package/src/shared/formatters.ts +7 -1
  81. package/src/shared/launch-contract.ts +6 -0
  82. package/src/shared/pruned-fork.ts +450 -0
  83. package/src/shared/session-file-trust.ts +19 -0
  84. package/src/shared/session-tokens.ts +14 -3
  85. package/src/shared/settings.ts +16 -3
  86. package/src/shared/shortcuts.ts +17 -0
  87. package/src/shared/types.ts +166 -9
  88. package/src/shared/workflow-child-permit.ts +116 -0
  89. package/src/slash/delegation-adapters.ts +0 -1
  90. package/src/slash/slash-commands.ts +8 -6
  91. package/src/tui/fleet-status.ts +27 -10
  92. package/src/tui/fleet-transcript.ts +11 -5
  93. package/src/tui/fleet.ts +28 -13
  94. package/src/tui/render.ts +47 -21
  95. package/src/workflows/scripted-workflow.ts +298 -30
  96. package/src/workflows/workflow-child-summary.ts +117 -0
  97. package/src/workflows/workflow-receipt.ts +119 -4
@@ -18,6 +18,7 @@ import { readStatus } from "../../shared/utils.ts";
18
18
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
19
19
  import { contextModeLabel, summarizeContextModes } from "../shared/context-mode.ts";
20
20
  import { formatAsyncRunOutputPath, formatAsyncRunProgressLabel, listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
21
+ import { isTrustedRecordedSessionFile } from "../../shared/session-file-trust.ts";
21
22
 
22
23
  const DEFAULT_TRANSCRIPT_LINES = 80;
23
24
  const MAX_TRANSCRIPT_LINES = 500;
@@ -43,6 +44,8 @@ interface TranscriptOptions {
43
44
  index?: number;
44
45
  lines?: number;
45
46
  sessionRoots?: string[];
47
+ trustedSessionFiles?: string[];
48
+ trustedSessionFileRoot?: string;
46
49
  }
47
50
 
48
51
  interface TextTailResult {
@@ -120,10 +123,13 @@ function readTextTail(filePath: string, maxLines: number): TextTailResult {
120
123
  }
121
124
  }
122
125
 
123
- function readContainedTextTail(filePath: string, maxLines: number, trustedRoots: string[], label: string): TextTailResult {
124
- if (trustedRoots.length === 0) return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path without a trusted root: ${filePath}` };
126
+ function readContainedTextTail(filePath: string, maxLines: number, trustedRoots: string[], label: string, trustedFiles: string[] = [], trustedFileRoot?: string): TextTailResult {
127
+ if (trustedRoots.length === 0 && (!trustedFileRoot || trustedFiles.length === 0)) return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path without a trusted root: ${filePath}` };
125
128
  const resolvedPath = path.resolve(filePath);
126
- if (!trustedRoots.some((root) => pathWithin(root, resolvedPath))) {
129
+ const recordedCandidate = trustedFileRoot
130
+ && pathWithin(trustedFileRoot, resolvedPath)
131
+ && trustedFiles.some((file) => path.resolve(file) === resolvedPath);
132
+ if (!trustedRoots.some((root) => pathWithin(root, resolvedPath)) && !recordedCandidate) {
127
133
  return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path outside trusted roots: ${filePath}` };
128
134
  }
129
135
  let lstat: fs.Stats;
@@ -143,7 +149,7 @@ function readContainedTextTail(filePath: string, maxLines: number, trustedRoots:
143
149
  } catch (error) {
144
150
  return { path: filePath, lines: [], truncated: false, error: getErrorMessage(error) };
145
151
  }
146
- if (!realRoots.some((root) => pathWithin(root, realPath))) {
152
+ if (!realRoots.some((root) => pathWithin(root, realPath)) && !isTrustedRecordedSessionFile(realPath, trustedFiles, trustedFileRoot)) {
147
153
  return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path outside trusted roots: ${filePath}` };
148
154
  }
149
155
  return readTextTail(resolvedPath, maxLines);
@@ -213,8 +219,8 @@ function sessionMessageLine(record: unknown): string | undefined {
213
219
 
214
220
  /** Structured session tail: parsed content parts, newest last, bounded by
215
221
  * maxMessages. Same trusted-root containment as the prose transcript tail. */
216
- export function readSessionMessagesTail(sessionFile: string, maxMessages: number, trustedRoots: string[]): { messages: SessionTranscriptMessage[]; warnings: string[]; truncated: boolean } {
217
- const tail = readContainedTextTail(sessionFile, Math.max(maxMessages * 4, maxMessages), trustedRoots, "session");
222
+ export function readSessionMessagesTail(sessionFile: string, maxMessages: number, trustedRoots: string[], trustedFiles: string[] = [], trustedFileRoot?: string): { messages: SessionTranscriptMessage[]; warnings: string[]; truncated: boolean } {
223
+ const tail = readContainedTextTail(sessionFile, Math.max(maxMessages * 4, maxMessages), trustedRoots, "session", trustedFiles, trustedFileRoot);
218
224
  const warnings: string[] = [];
219
225
  if (tail.error) warnings.push(`Session read failed for ${sessionFile}: ${tail.error}`);
220
226
  const parsedMessages: SessionTranscriptMessage[] = [];
@@ -235,8 +241,8 @@ export function readSessionMessagesTail(sessionFile: string, maxMessages: number
235
241
  return { messages, warnings, truncated: tail.truncated || parsedMessages.length > messages.length };
236
242
  }
237
243
 
238
- function readSessionTranscriptTail(sessionFile: string, maxLines: number, trustedRoots: string[]): { lines: string[]; warnings: string[] } {
239
- const tail = readContainedTextTail(sessionFile, Math.max(maxLines * 4, maxLines), trustedRoots, "session");
244
+ function readSessionTranscriptTail(sessionFile: string, maxLines: number, trustedRoots: string[], trustedFiles: string[] = [], trustedFileRoot?: string): { lines: string[]; warnings: string[] } {
245
+ const tail = readContainedTextTail(sessionFile, Math.max(maxLines * 4, maxLines), trustedRoots, "session", trustedFiles, trustedFileRoot);
240
246
  const warnings: string[] = [];
241
247
  if (tail.error) warnings.push(`Session read failed for ${sessionFile}: ${tail.error}`);
242
248
  const lines: string[] = [];
@@ -523,7 +529,7 @@ export function formatAsyncRunTranscript(status: AsyncStatus, asyncDir: string,
523
529
  transcriptSource = "Recent output from status.json";
524
530
  }
525
531
  if (transcriptLines.length === 0 && sessionFile) {
526
- const sessionTail = readSessionTranscriptTail(sessionFile, lineLimit, options.sessionRoots ?? []);
532
+ const sessionTail = readSessionTranscriptTail(sessionFile, lineLimit, options.sessionRoots ?? [], options.trustedSessionFiles, options.trustedSessionFileRoot);
527
533
  transcriptLines = sessionTail.lines;
528
534
  warnings.push(...sessionTail.warnings);
529
535
  if (transcriptLines.length > 0) transcriptSource = `Session transcript tail from ${sessionFile}`;
@@ -554,7 +560,7 @@ export function formatNestedRunTranscript(run: NestedRunSummary, options: Transc
554
560
  appendTranscriptBody(lines, "Transcript tail", [], false);
555
561
  return safeTranscriptLines(lines);
556
562
  }
557
- const sessionTail = readSessionTranscriptTail(run.sessionFile, lineLimit, options.sessionRoots ?? []);
563
+ const sessionTail = readSessionTranscriptTail(run.sessionFile, lineLimit, options.sessionRoots ?? [], options.trustedSessionFiles, options.trustedSessionFileRoot);
558
564
  if (sessionTail.warnings.length) {
559
565
  lines.push("Warnings:");
560
566
  for (const warning of sessionTail.warnings) lines.push(` ${warning}`);
@@ -218,19 +218,19 @@ function readOutputArtifact(outputPath: string): { output?: string; errorText?:
218
218
  }
219
219
  }
220
220
 
221
- function readSessionBackedOutput(sessionPath: string, trustedRoots: string[]): { output?: string } {
222
- if (trustedRoots.length === 0) return {};
221
+ function readSessionBackedOutput(sessionPath: string, trustedRoots: string[], trustedSessionFileRoot?: string): { output?: string } {
222
+ if (trustedRoots.length === 0 && !trustedSessionFileRoot) return {};
223
223
  // The archive retains the child's session file as its output record. The
224
224
  // final answer is the terminal assistant message's text — all text parts
225
225
  // of that one session record, not just the last part.
226
- const tail = readSessionMessagesTail(sessionPath, MAX_MESSAGE_LINES, trustedRoots);
226
+ const tail = readSessionMessagesTail(sessionPath, MAX_MESSAGE_LINES, trustedRoots, [sessionPath], trustedSessionFileRoot);
227
227
  const last = tail.messages.findLast((message) => message.role === "assistant" && message.kind === "text");
228
228
  if (!last) return {};
229
229
  const parts = tail.messages.filter((message) => message.recordIndex === last.recordIndex && message.kind === "text");
230
230
  return { output: parts.map((part) => part.text).join("\n") };
231
231
  }
232
232
 
233
- function readResultOutput(resultsDir: string, sessionId: string, runId: string, stepIndex: number | undefined, trustedRoots: string[], stepAgent: string | undefined, now: () => number): { output?: string; errorText?: string } {
233
+ function readResultOutput(resultsDir: string, sessionId: string, runId: string, stepIndex: number | undefined, trustedRoots: string[], stepAgent: string | undefined, now: () => number, trustedSessionFileRoot?: string): { output?: string; errorText?: string } {
234
234
  const resultPath = resultPayloadPathForSessionRun(resultsDir, sessionId, runId);
235
235
  if (resultPath) return resultOutput(JSON.parse(fs.readFileSync(resultPath, "utf-8")) as unknown, stepIndex);
236
236
  // Read the raw record first: readCompletionReplay best-effort deletes
@@ -284,7 +284,7 @@ function readResultOutput(resultsDir: string, sessionId: string, runId: string,
284
284
  if (artifactPath) return readOutputArtifact(artifactPath);
285
285
  const sessionEntry = archive?.entries.find((entry) => entry.source === "session" && matches(entry));
286
286
  const sessionPath = sessionEntry?.source === "session" ? sessionEntry.path : undefined;
287
- if (sessionPath) return readSessionBackedOutput(sessionPath, trustedRoots);
287
+ if (sessionPath) return readSessionBackedOutput(sessionPath, trustedRoots, trustedSessionFileRoot);
288
288
  const output = archive?.entries.find((entry) => entry.source === "result-tail" && matches(entry))?.text;
289
289
  return output ? { output } : {};
290
290
  }
@@ -377,8 +377,8 @@ export function buildInspectReply(request: InspectRequest, deps: InspectDeps = {
377
377
 
378
378
  let messages: InspectReplyMessage[] | undefined;
379
379
  let task: string | undefined;
380
- if (sessionFile && trustedRoots.length > 0) {
381
- const tail = readSessionMessagesTail(sessionFile, lineLimit, trustedRoots);
380
+ if (sessionFile && (trustedRoots.length > 0 || deps.state?.trustedSessionFileRoot)) {
381
+ const tail = readSessionMessagesTail(sessionFile, lineLimit, trustedRoots, [sessionFile], deps.state?.trustedSessionFileRoot);
382
382
  if (tail.messages.length > 0) {
383
383
  messages = tail.messages.map(toReplyMessage);
384
384
  // The delegated task is the child session's first user message, but
@@ -393,7 +393,7 @@ export function buildInspectReply(request: InspectRequest, deps: InspectDeps = {
393
393
  }
394
394
  }
395
395
 
396
- const result = readResultOutput(resultsDir, currentSessionId, node.status.runId, node.stepIndex, trustedRoots, step?.agent, deps.now ?? Date.now);
396
+ const result = readResultOutput(resultsDir, currentSessionId, node.status.runId, node.stepIndex, trustedRoots, step?.agent, deps.now ?? Date.now, deps.state?.trustedSessionFileRoot);
397
397
  const failedText = step?.error ?? node.status.error;
398
398
  if (result.output === undefined && result.errorText === undefined && failedText !== undefined) {
399
399
  return errorReply(request, "internal", "Inspection could not read the async run artifacts.");
@@ -40,6 +40,11 @@ function nonEmptyString(value: unknown): string | undefined {
40
40
  return typeof value === "string" && value.length > 0 ? value : undefined;
41
41
  }
42
42
 
43
+ /** A legacy alias can be valid metadata while still being unaddressable on the current filesystem. */
44
+ function isUnaddressableResultCandidate(error: unknown): boolean {
45
+ return (error as NodeJS.ErrnoException)?.code === "ENAMETOOLONG";
46
+ }
47
+
43
48
  export function resultFileName(runId: string): string {
44
49
  return `${runId}${JSON_EXTENSION}`;
45
50
  }
@@ -227,7 +232,7 @@ function existingResultFile(resultPath: string): boolean {
227
232
  try {
228
233
  return fs.statSync(resultPath).isFile();
229
234
  } catch (error) {
230
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to inspect async result payload '${resultPath}':`, error);
235
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !isUnaddressableResultCandidate(error)) console.error(`Failed to inspect async result payload '${resultPath}':`, error);
231
236
  return false;
232
237
  }
233
238
  }
@@ -241,7 +246,7 @@ function pendingResultPayloadMatches(filePath: string, sessionId: string, runId:
241
246
  const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record<string, unknown>;
242
247
  return (nonEmptyString(data.runId) ?? nonEmptyString(data.id)) === runId && nonEmptyString(data.sessionId) === sessionId;
243
248
  } catch (error) {
244
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid pending async result '${filePath}':`, error);
249
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !isUnaddressableResultCandidate(error)) console.error(`Ignoring invalid pending async result '${filePath}':`, error);
245
250
  return false;
246
251
  }
247
252
  }
@@ -263,11 +268,11 @@ export function promotePendingResultFile(resultsDir: string, sessionId: string,
263
268
  return existingResultFile(resultPath) ? "promoted" : "pending";
264
269
  } catch (error) {
265
270
  const code = (error as NodeJS.ErrnoException).code;
266
- if (code === "ENOENT" || code === "EEXIST" || code === "EPERM" || code === "EACCES") {
271
+ if (code === "ENOENT" || code === "EEXIST" || code === "EPERM" || code === "EACCES" || isUnaddressableResultCandidate(error)) {
267
272
  const pendingExists = existingResultFile(pendingPath);
268
273
  const resultExists = existingResultFile(resultPath);
269
274
  if (pendingExists) {
270
- if (!resultExists && options.logFailure !== false) console.error(`Failed to promote pending async result '${pendingPath}' to '${resultPath}':`, error);
275
+ if (!resultExists && options.logFailure !== false && !isUnaddressableResultCandidate(error)) console.error(`Failed to promote pending async result '${pendingPath}' to '${resultPath}':`, error);
271
276
  return "pending";
272
277
  }
273
278
  if (resultExists) return "promoted";
@@ -318,7 +323,7 @@ function readResultIndexForSessionRun(resultsDir: string, sessionId: string, run
318
323
  } catch (error) {
319
324
  const code = (error as NodeJS.ErrnoException).code;
320
325
  if (code === "EPERM" || code === "EACCES") throw error;
321
- if (code !== "ENOENT" && code !== "ENOTDIR") {
326
+ if (code !== "ENOENT" && code !== "ENOTDIR" && !isUnaddressableResultCandidate(error)) {
322
327
  console.error(`Ignoring invalid async result index for '${runId}':`, error);
323
328
  }
324
329
  }
@@ -338,7 +343,7 @@ export function resultPayloadPathForMissionObserverRun(resultsDir: string, runId
338
343
  if (!entry || entry.runId !== runId) return undefined;
339
344
  return resultPayloadLocationFromIndex(resultsDir, entry)?.path;
340
345
  } catch (error) {
341
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid async result observer index for '${runId}':`, error);
346
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !isUnaddressableResultCandidate(error)) console.error(`Ignoring invalid async result observer index for '${runId}':`, error);
342
347
  return undefined;
343
348
  }
344
349
  }
@@ -356,9 +361,12 @@ export function resultPayloadPathForIndexedRun(resultsDir: string, runId: string
356
361
  fs.rmSync(entryPath, { force: true });
357
362
  } catch (error) {
358
363
  const code = (error as NodeJS.ErrnoException).code;
364
+ if (isUnaddressableResultCandidate(error)) return undefined;
359
365
  if (code !== "ENOENT" && code !== "ENOTDIR") {
360
366
  console.error(`Ignoring invalid async result run index '${entryPath}':`, error);
361
- try { fs.rmSync(entryPath, { force: true }); } catch {}
367
+ try { fs.rmSync(entryPath, { force: true }); } catch {
368
+ // Invalid optional run-index cleanup is best-effort.
369
+ }
362
370
  }
363
371
  }
364
372
  return undefined;
@@ -378,7 +386,7 @@ function listIndexFiles(dir: string): string[] {
378
386
  .map((entry) => path.join(dir, entry.name));
379
387
  } catch (error) {
380
388
  const code = (error as NodeJS.ErrnoException).code;
381
- if (code === "ENOENT" || code === "ENOTDIR" || code === "EPERM" || code === "EACCES") return [];
389
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EPERM" || code === "EACCES" || isUnaddressableResultCandidate(error)) return [];
382
390
  throw error;
383
391
  }
384
392
  return files;
@@ -396,7 +404,7 @@ function resultFilesFromIndexDir(resultsDir: string, dir: string, includePending
396
404
  const resultFile = indexedResultFile(resultsDir, entry, includePending);
397
405
  if (resultFile) candidates.add(resultFile);
398
406
  } catch (error) {
399
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid async result index '${entryPath}':`, error);
407
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !isUnaddressableResultCandidate(error)) console.error(`Ignoring invalid async result index '${entryPath}':`, error);
400
408
  }
401
409
  }
402
410
  return [...candidates];
@@ -418,7 +426,7 @@ function pendingResultFilesForSession(resultsDir: string, sessionId: string): st
418
426
  entries = fs.readdirSync(dir, { withFileTypes: true });
419
427
  } catch (error) {
420
428
  const code = (error as NodeJS.ErrnoException).code;
421
- if (code === "ENOENT" || code === "ENOTDIR" || code === "EPERM" || code === "EACCES") continue;
429
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EPERM" || code === "EACCES" || isUnaddressableResultCandidate(error)) continue;
422
430
  throw error;
423
431
  }
424
432
  for (const entry of entries) {
@@ -429,7 +437,7 @@ function pendingResultFilesForSession(resultsDir: string, sessionId: string): st
429
437
  const runId = nonEmptyString(data.runId) ?? nonEmptyString(data.id);
430
438
  if (runId && nonEmptyString(data.sessionId) === sessionId) files.add(resultFileName(runId));
431
439
  } catch (error) {
432
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid pending async result '${pendingPath}':`, error);
440
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !isUnaddressableResultCandidate(error)) console.error(`Ignoring invalid pending async result '${pendingPath}':`, error);
433
441
  }
434
442
  }
435
443
  }
@@ -471,14 +479,16 @@ export function cleanupResultIndexes(resultsDir: string, now = Date.now(), maxAg
471
479
  entries = fs.readdirSync(dir, { withFileTypes: true });
472
480
  } catch (error) {
473
481
  const code = (error as NodeJS.ErrnoException).code;
474
- if (code === "ENOENT" || code === "ENOTDIR" || code === "EPERM" || code === "EACCES") return;
482
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EPERM" || code === "EACCES" || isUnaddressableResultCandidate(error)) return;
475
483
  throw error;
476
484
  }
477
485
  for (const entry of entries) {
478
486
  const fullPath = path.join(dir, entry.name);
479
487
  if (entry.isDirectory()) {
480
488
  visit(fullPath);
481
- try { fs.rmdirSync(fullPath); } catch {}
489
+ try { fs.rmdirSync(fullPath); } catch {
490
+ // Non-empty or concurrently removed index directories are left in place.
491
+ }
482
492
  continue;
483
493
  }
484
494
  if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
@@ -492,11 +502,14 @@ export function cleanupResultIndexes(resultsDir: string, now = Date.now(), maxAg
492
502
  removed += 1;
493
503
  }
494
504
  } catch (error) {
505
+ if (isUnaddressableResultCandidate(error)) continue;
495
506
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Ignoring invalid async result index '${fullPath}':`, error);
496
507
  try {
497
508
  fs.rmSync(fullPath, { force: true });
498
509
  removed += 1;
499
- } catch {}
510
+ } catch {
511
+ // Invalid optional index cleanup is best-effort.
512
+ }
500
513
  }
501
514
  }
502
515
  };
@@ -148,6 +148,10 @@ function isNotFound(error: unknown): boolean {
148
148
  return errorCode(error) === "ENOENT";
149
149
  }
150
150
 
151
+ function isAbsentResultCandidate(error: unknown): boolean {
152
+ return isNotFound(error) || errorCode(error) === "ENAMETOOLONG";
153
+ }
154
+
151
155
  function isAccessDenied(error: unknown): boolean {
152
156
  const code = errorCode(error);
153
157
  return code === "EPERM" || code === "EACCES";
@@ -235,7 +239,7 @@ export function createResultWatcher(
235
239
  try {
236
240
  return fsApi.statSync(publicResultPath(file)).isFile();
237
241
  } catch (error) {
238
- if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${publicResultPath(file)}':`, error);
242
+ if (!isAbsentResultCandidate(error)) console.error(`Failed to inspect subagent result file '${publicResultPath(file)}':`, error);
239
243
  return false;
240
244
  }
241
245
  };
@@ -265,7 +269,7 @@ export function createResultWatcher(
265
269
  } catch (error) {
266
270
  identityCache.delete(file);
267
271
  if (isAccessDenied(error)) throw error;
268
- if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
272
+ if (!isAbsentResultCandidate(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
269
273
  return undefined;
270
274
  }
271
275
  };
@@ -283,7 +287,7 @@ export function createResultWatcher(
283
287
  } catch (error) {
284
288
  identityCache.delete(file);
285
289
  if (isAccessDenied(error)) throw error;
286
- if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
290
+ if (!isAbsentResultCandidate(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
287
291
  return undefined;
288
292
  }
289
293
  };
@@ -317,7 +321,7 @@ export function createResultWatcher(
317
321
  removeResultIndex(resultsDir, sessionId, runId, toolCallId);
318
322
  return true;
319
323
  } catch (error) {
320
- if (!isNotFound(error)) {
324
+ if (!isAbsentResultCandidate(error)) {
321
325
  console.error(`Failed to remove delivered subagent result '${publicResultPath(file)}'; will retry:`, error);
322
326
  return false;
323
327
  }
@@ -384,7 +388,7 @@ export function createResultWatcher(
384
388
  if (!resultPayloadWasReplaced(data, readPublicResultIdentity())) return false;
385
389
  } catch (error) {
386
390
  if (isAccessDenied(error)) throw error;
387
- if (isNotFound(error)) return false;
391
+ if (isAbsentResultCandidate(error)) return false;
388
392
  console.error(`Failed to re-read subagent result file '${publicResultPath(file)}':`, error);
389
393
  }
390
394
  identityCache.delete(file);
@@ -11,6 +11,7 @@ import { DIRS, type AsyncStatus, type Details, type ForegroundResumeRun, type Ne
11
11
  import { inspectActiveAsyncCapacityOwner, type ActiveAsyncCapacityInspection } from "./active-async-capacity.ts";
12
12
  import { readStatus } from "../../shared/utils.ts";
13
13
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
14
+ import { normalizeExternalCliRunnerStatus } from "../shared/external-cli-contract.ts";
14
15
  import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
15
16
  import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
16
17
  import { formatWaitSubscriptions } from "./wait-subscriptions.ts";
@@ -21,6 +22,7 @@ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-
21
22
  import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot, type NestedRunResolutionScope } from "../shared/nested-events.ts";
22
23
  import { readMissionBinding } from "../../missions/lifecycle.ts";
23
24
  import { formatWorkflowJsonPreview } from "../../workflows/scripted-workflow.ts";
25
+ import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
24
26
  import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
25
27
  import { getExternalJobProvider } from "../../api/external-job-provider.ts";
26
28
 
@@ -103,6 +105,7 @@ interface RunStatusDeps {
103
105
  nested?: NestedRunResolutionScope;
104
106
  sessionRoots?: string[];
105
107
  activeCapacityRoot?: string;
108
+ abandonedSlotReleaseAfterMs?: number | false;
106
109
  }
107
110
 
108
111
  function hasExistingSessionFile(value: unknown): value is string {
@@ -405,7 +408,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
405
408
  if (!status && diskStatus?.displayDismissedAt !== undefined) {
406
409
  if (params.action === "debug.run") {
407
410
  const { sidecar, overlay } = debugProcessTerminal(asyncDir, diskStatus);
408
- const capacity = inspectActiveAsyncCapacityOwner({ runId: diskStatus.runId, sessionId: diskStatus.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []) });
411
+ const capacity = inspectActiveAsyncCapacityOwner({ runId: diskStatus.runId, sessionId: diskStatus.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []), abandonedSlotReleaseAfterMs: deps.abandonedSlotReleaseAfterMs });
409
412
  return {
410
413
  content: [{ type: "text", text: formatRunLifecycleDebug({ status: diskStatus, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
411
414
  details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
@@ -432,7 +435,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
432
435
  if (status) {
433
436
  if (params.action === "debug.run") {
434
437
  const { sidecar, overlay } = debugProcessTerminal(asyncDir, status);
435
- const capacity = inspectActiveAsyncCapacityOwner({ runId: status.runId, sessionId: status.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []) });
438
+ const capacity = inspectActiveAsyncCapacityOwner({ runId: status.runId, sessionId: status.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []), abandonedSlotReleaseAfterMs: deps.abandonedSlotReleaseAfterMs });
436
439
  return {
437
440
  content: [{ type: "text", text: formatRunLifecycleDebug({ status, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
438
441
  details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
@@ -536,9 +539,29 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
536
539
  const phase = step.phase ? `[${step.phase}] ` : "";
537
540
  lines.push(`${stepLineLabel(status, index)}: ${phase}${display} ${step.status}${modelText}${stepActivityText ? `, ${stepActivityText}` : ""}${steeringSuffix}${acceptanceText}${budgetText}${errorText}`);
538
541
  if (step.runner?.type === "external-cli") {
539
- lines.push(` Runner: external-cli (${step.runner.command}${step.runner.args.length ? ` ${step.runner.args.join(" ")}` : ""})`);
542
+ const runner = normalizeExternalCliRunnerStatus(step.runner);
543
+ if (runner) {
544
+ lines.push(` Runner: external-cli (${runner.command}${runner.args.length ? ` ${runner.args.join(" ")}` : ""})`);
545
+ lines.push(` Adapter: ${runner.adapter.id} v${runner.adapter.version} (${runner.adapter.executionMode})`);
546
+ if (runner.safety) {
547
+ if ("approvalPolicy" in runner.safety) lines.push(` Safety: ${"access" in runner.safety ? `access=${runner.safety.access}, ` : ""}sandbox=${runner.safety.sandbox}, approval=${runner.safety.approvalPolicy}, ephemeral=${runner.safety.ephemeral}`);
548
+ else if ("mode" in runner.safety) lines.push(` Safety: access=${runner.safety.access}, auth=${runner.safety.authentication}, mode=${runner.safety.mode}, sandbox=${runner.safety.sandbox}, workspaceTrust=${runner.safety.workspaceTrust}, sessionReuse=${runner.safety.sessionReuse}`);
549
+ else if ("authentication" in runner.safety) lines.push(` Safety: access=${runner.safety.access}, auth=${runner.safety.authentication}, permission=${runner.safety.permissionMode}, tools=${runner.safety.tools}, mcp=${runner.safety.mcp}, settings=${runner.safety.settingSources}, settingsTrust=${runner.safety.userSettingsTrust}, persistence=${runner.safety.sessionPersistence}`);
550
+ else lines.push(` Safety: access=read-only, permission=${runner.safety.permissionMode}, tools=${runner.safety.tools}, mcp=${runner.safety.mcp}, settings=${runner.safety.settingSources}, persistence=${runner.safety.sessionPersistence}`);
551
+ }
552
+ lines.push(` Capabilities: stop=${runner.capabilities.stop}, steer=false, resume=false, structuredOutput=false, toolEvents=false, supervisor=unsupported, forkContext=false, extensionBindings=false`);
553
+ lines.push(` Unsupported steer: ${runner.unsupportedReasons.steer}`);
554
+ lines.push(` Unsupported resume: ${runner.nonResumableReason}`);
555
+ lines.push(` Unsupported supervisor: ${runner.unsupportedReasons.supervisor}`);
556
+ lines.push(` Context handoff: fresh only (${runner.unsupportedReasons.forkContext})`);
557
+ } else {
558
+ lines.push(" Runner: external-cli (invalid persisted runner metadata)");
559
+ }
540
560
  if (step.externalProcess?.pid !== undefined) lines.push(` Process: ${step.externalProcess.pid}`);
541
- if (step.externalProcess) lines.push(` Stdout: ${step.externalProcess.stdoutPath}`, ` Stderr: ${step.externalProcess.stderrPath}`);
561
+ if (step.externalProcess) {
562
+ lines.push(` Stdout: ${step.externalProcess.stdoutPath}`, ` Stderr: ${step.externalProcess.stderrPath}`);
563
+ if (step.externalProcess.finalOutputPath) lines.push(` Final output: ${step.externalProcess.finalOutputPath}`);
564
+ }
542
565
  } else if (step.runner?.type === "external-job") {
543
566
  lines.push(` Runner: external-job (${step.runner.provider})`);
544
567
  if (step.externalJob?.providerJobId) lines.push(` Provider job: ${step.externalJob.providerJobId}`);
@@ -583,7 +606,9 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
583
606
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
584
607
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
585
608
 
586
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
609
+ const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
610
+ if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error("workflowChildren.workflowRunId does not match async status runId.");
611
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(workflowChildren ? { workflowChildren } : {}), ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
587
612
  }
588
613
  }
589
614
 
@@ -627,7 +652,9 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
627
652
  const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
628
653
  lines.push(formatResumeGuidance(runId, children, data.sessionFile, { stopped: status === "stopped" }));
629
654
  if (data.summary) lines.push("", data.summary);
630
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
655
+ const workflowChildren = parseWorkflowChildSummary((data as unknown as Record<string, unknown>).workflowChildren);
656
+ if (workflowChildren && workflowChildren.workflowRunId !== runId) throw new Error("workflowChildren.workflowRunId does not match the result run id.");
657
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(workflowChildren ? { workflowChildren } : {}) } };
631
658
  } catch (error) {
632
659
  const message = error instanceof Error ? error.message : String(error);
633
660
  return {
@@ -294,6 +294,10 @@ function resolveMaxPending(config: ExtensionConfig): number {
294
294
  return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : DEFAULT_MAX_PENDING;
295
295
  }
296
296
 
297
+ function hasPendingScheduleWork(schedule: ScheduleRecord): boolean {
298
+ return schedule.activeRunId !== undefined || schedule.trigger.nextRunAt !== undefined;
299
+ }
300
+
297
301
  function nextAfter(trigger: ScheduleTrigger, plannedAt: number, now: number): string | undefined {
298
302
  if (trigger.kind === "once") return undefined;
299
303
  let next = plannedAt + trigger.everyMs;
@@ -479,7 +483,9 @@ export class ScheduledRunManager {
479
483
  if (params.on !== undefined || params.timezone !== undefined || every === "day" || every === "week" || every === "month" || every === "year") return textResult("Calendar schedules are deferred from this first safe slice. Use a fixed interval such as every:'24h' or every:'7d'.", undefined, undefined, true);
480
484
  const sessionId = ctx.sessionManager.getSessionId() ?? "unknown";
481
485
  if (this.deps.resolveCapabilityCeiling?.(sessionId)) return textResult("Cannot persist a schedule while a capability ceiling is active.", undefined, undefined, true);
482
- if (store.list().length >= resolveMaxPending(this.deps.config)) return textResult(`Schedule limit reached (${resolveMaxPending(this.deps.config)}).`, undefined, undefined, true);
486
+ const pendingCount = store.list().filter(hasPendingScheduleWork).length;
487
+ const maxPending = resolveMaxPending(this.deps.config);
488
+ if (pendingCount >= maxPending) return textResult(`Schedule limit reached (${maxPending}).`, undefined, undefined, true);
483
489
  const id = validateScheduleId((params.id?.trim() || this.randomId()));
484
490
  if (store.ids().includes(id)) return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
485
491
  const now = this.now();