pi-subagents 0.56.0 → 0.58.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 (108) hide show
  1. package/CHANGELOG.md +92 -0
  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 +124 -21
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +41 -2
  11. package/docs/models.md +3 -3
  12. package/docs/observability.md +8 -7
  13. package/docs/tool-reference.md +14 -3
  14. package/docs/workflows.md +44 -0
  15. package/package.json +1 -1
  16. package/skills/pi-subagents/SKILL.md +2 -0
  17. package/skills/pi-subagents/references/execution-controls.md +2 -2
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -0
  19. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  20. package/src/agents/agent-management.ts +36 -5
  21. package/src/agents/agent-refinements.ts +4 -4
  22. package/src/agents/agent-serializer.ts +5 -0
  23. package/src/agents/agents.ts +257 -51
  24. package/src/agents/builtin-names.ts +6 -0
  25. package/src/agents/runtime-agent-events.ts +70 -0
  26. package/src/agents/runtime-agent-registry.ts +18 -4
  27. package/src/api/agents.ts +10 -5
  28. package/src/api/preflight.ts +28 -3
  29. package/src/extension/config.ts +70 -0
  30. package/src/extension/doctor.ts +3 -3
  31. package/src/extension/index.ts +33 -8
  32. package/src/extension/public-execution.ts +29 -13
  33. package/src/extension/rpc.ts +55 -19
  34. package/src/extension/schemas.ts +6 -5
  35. package/src/extension/tool-description.ts +18 -12
  36. package/src/inspectors/herdr/actions.ts +2 -1
  37. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  38. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  39. package/src/integrations/herdr-status.ts +51 -3
  40. package/src/runs/background/active-async-capacity.ts +77 -10
  41. package/src/runs/background/async-execution.ts +127 -19
  42. package/src/runs/background/async-job-tracker.ts +5 -0
  43. package/src/runs/background/async-resume.ts +6 -2
  44. package/src/runs/background/async-retention.ts +20 -3
  45. package/src/runs/background/async-status.ts +7 -0
  46. package/src/runs/background/chain-append.ts +2 -0
  47. package/src/runs/background/chain-root-attachment.ts +15 -1
  48. package/src/runs/background/fleet-view.ts +16 -10
  49. package/src/runs/background/inspect-rpc.ts +8 -8
  50. package/src/runs/background/notify.ts +26 -3
  51. package/src/runs/background/result-delivery-ownership.ts +45 -0
  52. package/src/runs/background/result-files.ts +27 -14
  53. package/src/runs/background/result-watcher.ts +36 -15
  54. package/src/runs/background/run-status.ts +33 -6
  55. package/src/runs/background/scheduled-runs.ts +7 -1
  56. package/src/runs/background/subagent-runner.ts +239 -56
  57. package/src/runs/background/wait-completions.ts +4 -0
  58. package/src/runs/foreground/execution.ts +92 -11
  59. package/src/runs/foreground/foreground-control.ts +6 -0
  60. package/src/runs/foreground/foreground-history.ts +22 -1
  61. package/src/runs/foreground/subagent-executor.ts +322 -102
  62. package/src/runs/foreground/workflow-detach-reconcile.ts +144 -18
  63. package/src/runs/shared/child-protocol.ts +21 -7
  64. package/src/runs/shared/claude-code-adapter.ts +129 -0
  65. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  66. package/src/runs/shared/completion-guard.ts +4 -3
  67. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  68. package/src/runs/shared/dynamic-fanout.ts +3 -3
  69. package/src/runs/shared/external-cli-contract.ts +167 -0
  70. package/src/runs/shared/external-cli-preflight.ts +122 -0
  71. package/src/runs/shared/external-cli-runner.ts +348 -55
  72. package/src/runs/shared/fast-mode-extension.ts +5 -5
  73. package/src/runs/shared/launch-cwd.ts +16 -0
  74. package/src/runs/shared/long-running-guard.ts +2 -1
  75. package/src/runs/shared/mcp-config-sources.ts +386 -0
  76. package/src/runs/shared/mcp-direct-tool-allowlist.ts +155 -42
  77. package/src/runs/shared/model-exclusions.ts +69 -7
  78. package/src/runs/shared/model-fallback.ts +39 -5
  79. package/src/runs/shared/mutation-evidence.ts +7 -2
  80. package/src/runs/shared/nested-events.ts +3 -1
  81. package/src/runs/shared/nested-render.ts +2 -2
  82. package/src/runs/shared/parallel-utils.ts +8 -1
  83. package/src/runs/shared/pi-args.ts +61 -6
  84. package/src/runs/shared/process-signal.ts +13 -0
  85. package/src/runs/shared/run-history.ts +21 -1
  86. package/src/runs/shared/single-output.ts +17 -0
  87. package/src/runs/shared/subagent-prompt-runtime.ts +85 -9
  88. package/src/shared/fork-context.ts +21 -0
  89. package/src/shared/formatters.ts +13 -1
  90. package/src/shared/launch-contract.ts +4 -0
  91. package/src/shared/pruned-fork.ts +450 -0
  92. package/src/shared/session-file-trust.ts +19 -0
  93. package/src/shared/session-tokens.ts +14 -3
  94. package/src/shared/settings.ts +10 -2
  95. package/src/shared/shortcuts.ts +17 -0
  96. package/src/shared/types.ts +160 -10
  97. package/src/shared/utils.ts +6 -29
  98. package/src/shared/workflow-child-permit.ts +116 -0
  99. package/src/slash/delegation-adapters.ts +0 -1
  100. package/src/slash/slash-commands.ts +8 -6
  101. package/src/slash/subagents-admin.ts +3 -0
  102. package/src/tui/fleet-status.ts +27 -10
  103. package/src/tui/fleet-transcript.ts +11 -5
  104. package/src/tui/fleet.ts +28 -13
  105. package/src/tui/render.ts +55 -21
  106. package/src/workflows/scripted-workflow.ts +299 -31
  107. package/src/workflows/workflow-child-summary.ts +117 -0
  108. package/src/workflows/workflow-receipt.ts +155 -5
@@ -9,6 +9,7 @@ import type { AsyncStatus } from "../../shared/types.ts";
9
9
  import { MISSION_BINDING_FILE } from "../../missions/lifecycle.ts";
10
10
  import { ACTIVE_RUN_INDEX_DIR } from "./active-run-index.ts";
11
11
  import { encodeIndexSegment } from "./index-segment.ts";
12
+ import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
12
13
 
13
14
  export const ASYNC_RETENTION_DAYS = 30;
14
15
  export const ASYNC_RETENTION_BATCH_SIZE = 100;
@@ -96,11 +97,13 @@ export interface AsyncRetentionOptions {
96
97
  lstatSync?: typeof fs.lstatSync;
97
98
  signal?: AbortSignal;
98
99
  discoveryWorkerUrl?: URL;
100
+ reconcileKill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
99
101
  }
100
102
 
101
103
  export interface AsyncRetentionResult {
102
104
  acquired: boolean;
103
105
  scanned: number;
106
+ repairedRuns: number;
104
107
  deletedRuns: number;
105
108
  deletedResults: number;
106
109
  reapedTombstones: number;
@@ -135,7 +138,7 @@ function listDir(dir: string): fs.Dirent[] {
135
138
 
136
139
  function readJson(filePath: string): Record<string, unknown> | undefined {
137
140
  try {
138
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
141
+ const parsed = JSON.parse(fs.readFileSync(filePath).toString("utf8")) as unknown;
139
142
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
140
143
  } catch {
141
144
  return undefined;
@@ -320,7 +323,7 @@ function readCursor(root: string): RetentionCursor {
320
323
  function processStartIdentity(pid: number): string | undefined {
321
324
  if (process.platform === "linux") {
322
325
  try {
323
- const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
326
+ const stat = fs.readFileSync(`/proc/${pid}/stat`).toString("utf8");
324
327
  const commandEnd = stat.lastIndexOf(")");
325
328
  if (commandEnd === -1) return undefined;
326
329
  const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
@@ -504,6 +507,7 @@ function appendMaintenanceLog(root: string, result: AsyncRetentionResult, now: n
504
507
  at: new Date(now).toISOString(),
505
508
  acquired: result.acquired,
506
509
  scanned: result.scanned,
510
+ repairedRuns: result.repairedRuns,
507
511
  deletedRuns: result.deletedRuns,
508
512
  deletedResults: result.deletedResults,
509
513
  reapedTombstones: result.reapedTombstones,
@@ -654,6 +658,7 @@ export async function cleanupAsyncRetention(options: AsyncRetentionOptions): Pro
654
658
  const result: AsyncRetentionResult = {
655
659
  acquired: false,
656
660
  scanned: 0,
661
+ repairedRuns: 0,
657
662
  deletedRuns: 0,
658
663
  deletedResults: 0,
659
664
  reapedTombstones: 0,
@@ -750,7 +755,19 @@ export async function cleanupAsyncRetention(options: AsyncRetentionOptions): Pro
750
755
  increment(result.skipped, "unsafe-run-path");
751
756
  continue;
752
757
  }
753
- const status = readStatus(runDir);
758
+ let status = readStatus(runDir);
759
+ if (status?.state === "running"
760
+ && validRunId(status.runId)
761
+ && path.basename(runDir) === status.runId
762
+ && !protectedRunIds.has(status.runId)) {
763
+ const reconciliation = reconcileAsyncRun(runDir, {
764
+ resultsDir: options.resultsDir,
765
+ kill: options.reconcileKill,
766
+ now,
767
+ });
768
+ status = reconciliation.status ?? undefined;
769
+ if (reconciliation.repaired) result.repairedRuns += 1;
770
+ }
754
771
  const initialReason = runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
755
772
  if (initialReason) {
756
773
  increment(result.skipped, initialReason);
@@ -16,6 +16,7 @@ import { ACTIVE_RUN_INDEX_DIR, DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS, activeRu
16
16
  import { readRecentTerminalRunIndex, TERMINAL_RUN_INDEX_DIR } from "./terminal-run-index.ts";
17
17
  import { canScanAsyncRunPrefix } from "./run-id-query.ts";
18
18
  import { asyncStatusChildIdentity } from "../shared/child-identity.ts";
19
+ import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
19
20
 
20
21
  interface AsyncRunStepSummary {
21
22
  index: number;
@@ -47,6 +48,7 @@ interface AsyncRunStepSummary {
47
48
  totalCost?: CostSummary;
48
49
  skills?: string[];
49
50
  model?: string;
51
+ contextLimit?: number;
50
52
  thinking?: string;
51
53
  attemptedModels?: string[];
52
54
  sessionFile?: string;
@@ -121,6 +123,7 @@ export interface AsyncRunSummary {
121
123
  parentWorkflowRunId?: string;
122
124
  workflowKey?: string;
123
125
  workflow?: Details["workflow"];
126
+ workflowChildren?: Details["workflowChildren"];
124
127
  }
125
128
 
126
129
  interface AsyncRunListOptions {
@@ -229,6 +232,8 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
229
232
  }
230
233
 
231
234
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
235
+ const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
236
+ if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': workflowChildren.workflowRunId does not match.`);
232
237
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
233
238
  throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
234
239
  }
@@ -283,6 +288,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
283
288
  ...(step.totalCost ? { totalCost: step.totalCost } : {}),
284
289
  ...(step.skills ? { skills: step.skills } : {}),
285
290
  ...(step.model ? { model: step.model } : {}),
291
+ ...(step.contextLimit !== undefined ? { contextLimit: step.contextLimit } : {}),
286
292
  ...(step.thinking ? { thinking: step.thinking } : {}),
287
293
  ...(step.thinkingCeiling ? { thinkingCeiling: step.thinkingCeiling } : {}),
288
294
  ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
@@ -357,6 +363,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
357
363
  ...(status.parentWorkflowRunId ? { parentWorkflowRunId: status.parentWorkflowRunId } : {}),
358
364
  ...(status.workflowKey ? { workflowKey: status.workflowKey } : {}),
359
365
  ...(status.workflow ? { workflow: status.workflow } : {}),
366
+ ...(workflowChildren ? { workflowChildren } : {}),
360
367
  ...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
361
368
  ...(status.outputFile ? { outputFile: status.outputFile } : {}),
362
369
  ...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
@@ -172,6 +172,7 @@ function statusStepForTask(task: RunnerSubagentStep): StatusStep {
172
172
  ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
173
173
  skills: task.skills,
174
174
  model: task.model,
175
+ ...(task.contextLimit !== undefined ? { contextLimit: task.contextLimit } : {}),
175
176
  thinking: task.thinking,
176
177
  attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
177
178
  recentTools: [],
@@ -189,6 +190,7 @@ function statusStepsForRunnerStep(step: RunnerStep, stepIndex: number): StatusSt
189
190
  label: step.label ?? step.parallel.label ?? `Dynamic fanout (${step.collect.as})`,
190
191
  outputName: step.collect.as,
191
192
  structured: Boolean(step.collect.outputSchema),
193
+ ...(step.parallel.contextLimit !== undefined ? { contextLimit: step.parallel.contextLimit } : {}),
192
194
  status: "pending",
193
195
  recentTools: [],
194
196
  recentOutput: [],
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import { resultFilePath } from "./result-files.ts";
3
- import type { AcceptanceLedger, AsyncStatus, CostSummary, ModelAttempt } from "../../shared/types.ts";
3
+ import type { AcceptanceLedger, ArtifactPaths, AsyncStatus, CostSummary, ModelAttempt } from "../../shared/types.ts";
4
4
  import { readStatus } from "../../shared/utils.ts";
5
5
 
6
6
  export interface ImportedAsyncRoot {
@@ -27,6 +27,10 @@ export interface ImportedAsyncRootResult {
27
27
  structuredOutputPath?: string;
28
28
  structuredOutputSchemaPath?: string;
29
29
  acceptance?: AcceptanceLedger;
30
+ artifactPaths?: ArtifactPaths;
31
+ outputSaveError?: string;
32
+ transcriptPath?: string;
33
+ transcriptError?: string;
30
34
  timedOut?: boolean;
31
35
  stopped?: boolean;
32
36
  }
@@ -56,6 +60,10 @@ interface AsyncResultFile {
56
60
  structuredOutputPath?: string;
57
61
  structuredOutputSchemaPath?: string;
58
62
  acceptance?: AcceptanceLedger;
63
+ artifactPaths?: ArtifactPaths;
64
+ outputSaveError?: string;
65
+ transcriptPath?: string;
66
+ transcriptError?: string;
59
67
  }>;
60
68
  }
61
69
 
@@ -118,6 +126,7 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
118
126
  ...(step?.structuredOutputPath ? { structuredOutputPath: step.structuredOutputPath } : {}),
119
127
  ...(step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: step.structuredOutputSchemaPath } : {}),
120
128
  ...(step?.acceptance ? { acceptance: step.acceptance } : {}),
129
+ ...(step?.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
121
130
  };
122
131
  }
123
132
 
@@ -136,6 +145,7 @@ function outputFromTimeout(root: ImportedAsyncRoot, status: AsyncStatus | null,
136
145
  ...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
137
146
  ...(step?.contextOverflow ? { contextOverflow: true } : {}),
138
147
  ...(step?.totalCost ? { totalCost: step.totalCost } : {}),
148
+ ...(step?.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
139
149
  };
140
150
  }
141
151
 
@@ -168,6 +178,10 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
168
178
  ...(child?.structuredOutputPath ?? step?.structuredOutputPath ? { structuredOutputPath: child?.structuredOutputPath ?? step?.structuredOutputPath } : {}),
169
179
  ...(child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath } : {}),
170
180
  ...(child?.acceptance ?? step?.acceptance ? { acceptance: child?.acceptance ?? step?.acceptance } : {}),
181
+ ...(child?.artifactPaths ? { artifactPaths: child.artifactPaths } : {}),
182
+ ...(child?.outputSaveError ? { outputSaveError: child.outputSaveError } : {}),
183
+ ...(child?.transcriptPath ?? step?.transcriptPath ? { transcriptPath: child?.transcriptPath ?? step?.transcriptPath } : {}),
184
+ ...(child?.transcriptError ? { transcriptError: child.transcriptError } : {}),
171
185
  };
172
186
  }
173
187
 
@@ -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.");
@@ -16,6 +16,7 @@ import {
16
16
  } from "./completion-batcher.ts";
17
17
  import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type ParallelHandoffReference, type ScheduleOrigin, type SubagentState } from "../../shared/types.ts";
18
18
  import { isUnexplainedProcessSignal } from "../shared/process-signal.ts";
19
+ import type { ResultDeliveryOwnership } from "./result-delivery-ownership.ts";
19
20
 
20
21
  export interface SubagentNotifyDetails {
21
22
  agent: string;
@@ -91,6 +92,7 @@ export interface RegisterSubagentNotifyOptions {
91
92
  batchConfig?: CompletionBatchConfig;
92
93
  timers?: NotifyTimerApi;
93
94
  now?: () => number;
95
+ ownership?: Pick<ResultDeliveryOwnership, "owns">;
94
96
  }
95
97
 
96
98
  export interface CompletionNotifier {
@@ -228,6 +230,8 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
228
230
  interface PendingCompletion {
229
231
  key: string;
230
232
  details: SubagentNotifyDetails;
233
+ sessionId: string;
234
+ completionOwnerId: unknown;
231
235
  triggerTurn: boolean;
232
236
  resolve(accepted: boolean): void;
233
237
  }
@@ -335,6 +339,10 @@ export default function registerSubagentNotify(
335
339
  const batchConfig = resolveCompletionBatchConfig(options.batchConfig);
336
340
  const batchers = new Map<string, CompletionBatcher<PendingCompletion>>();
337
341
  let disposed = false;
342
+ const ownsResult = options.ownership?.owns
343
+ ?? ((sessionId: string, completionOwnerId: unknown) => sessionId === state.currentSessionId
344
+ && typeof completionOwnerId === "string"
345
+ && completionOwnerId === state.completionOwnerId);
338
346
 
339
347
  const settle = (items: PendingCompletion[], accepted: boolean) => {
340
348
  for (const item of items) {
@@ -343,7 +351,18 @@ export default function registerSubagentNotify(
343
351
  item.resolve(accepted);
344
352
  }
345
353
  };
346
- const emit = (items: PendingCompletion[]) => settle(items, sendCompletion(pi, items));
354
+ const emit = (items: PendingCompletion[]) => {
355
+ const accepted: PendingCompletion[] = [];
356
+ const rejected: PendingCompletion[] = [];
357
+ for (const item of items) {
358
+ const owned = item.details.source === "foreground"
359
+ ? item.sessionId === state.currentSessionId
360
+ : ownsResult(item.sessionId, item.completionOwnerId);
361
+ (owned ? accepted : rejected).push(item);
362
+ }
363
+ settle(rejected, false);
364
+ settle(accepted, sendCompletion(pi, accepted));
365
+ };
347
366
  const getBatcher = (result: CompletionNotification) => {
348
367
  const key = completionBatchKey(result);
349
368
  let batcher = batchers.get(key);
@@ -360,8 +379,10 @@ export default function registerSubagentNotify(
360
379
  };
361
380
 
362
381
  const deliver = (result: CompletionNotification): Promise<boolean> => {
363
- if (disposed || typeof result.sessionId !== "string" || result.sessionId !== state.currentSessionId) return Promise.resolve(false);
364
- if (result.source !== "foreground" && (!state.completionOwnerId || result.completionOwnerId !== state.completionOwnerId)) return Promise.resolve(false);
382
+ if (disposed || typeof result.sessionId !== "string") return Promise.resolve(false);
383
+ if (result.source === "foreground") {
384
+ if (result.sessionId !== state.currentSessionId) return Promise.resolve(false);
385
+ } else if (!ownsResult(result.sessionId, result.completionOwnerId)) return Promise.resolve(false);
365
386
  if (result.intercomDelivered === true) return Promise.resolve(true);
366
387
  const key = buildCompletionKey(result, "notify");
367
388
  const seenAt = seen.get(key);
@@ -376,6 +397,8 @@ export default function registerSubagentNotify(
376
397
  const item: PendingCompletion = {
377
398
  key,
378
399
  details,
400
+ sessionId: result.sessionId,
401
+ completionOwnerId: result.completionOwnerId,
379
402
  triggerTurn: result.triggerTurn !== false,
380
403
  resolve,
381
404
  };
@@ -0,0 +1,45 @@
1
+ import type { SubagentState } from "../../shared/types.ts";
2
+
3
+ const MAX_CLAIMED_PREDECESSOR_SESSIONS = 8;
4
+
5
+ type ResultDeliveryState = Pick<SubagentState, "currentSessionId" | "completionOwnerId">;
6
+
7
+ export interface ResultDeliveryOwnership {
8
+ owns(sessionId: string, completionOwnerId: unknown): boolean;
9
+ claimedSessionIds(): readonly string[];
10
+ claimPredecessor(previousSessionFile: string | undefined, previousRuntimeSessionId: string | null): boolean;
11
+ clear(): void;
12
+ }
13
+
14
+ export function createResultDeliveryOwnership(state: ResultDeliveryState): ResultDeliveryOwnership {
15
+ const claimed = new Map<string, string>();
16
+
17
+ const currentOwner = (): string | undefined => typeof state.completionOwnerId === "string" && state.completionOwnerId
18
+ ? state.completionOwnerId
19
+ : undefined;
20
+
21
+ return {
22
+ owns(sessionId, completionOwnerId) {
23
+ const owner = currentOwner();
24
+ if (!owner || completionOwnerId !== owner) return false;
25
+ return sessionId === state.currentSessionId || claimed.get(sessionId) === owner;
26
+ },
27
+ claimedSessionIds() {
28
+ const owner = currentOwner();
29
+ if (!owner) return [];
30
+ return [...claimed].flatMap(([sessionId, claimedOwner]) => claimedOwner === owner ? [sessionId] : []);
31
+ },
32
+ claimPredecessor(previousSessionFile, previousRuntimeSessionId) {
33
+ const owner = currentOwner();
34
+ if (!owner || !previousSessionFile || !previousRuntimeSessionId) return false;
35
+ if (previousSessionFile !== previousRuntimeSessionId || state.currentSessionId !== previousRuntimeSessionId) return false;
36
+ claimed.delete(previousRuntimeSessionId);
37
+ claimed.set(previousRuntimeSessionId, owner);
38
+ while (claimed.size > MAX_CLAIMED_PREDECESSOR_SESSIONS) claimed.delete(claimed.keys().next().value!);
39
+ return true;
40
+ },
41
+ clear() {
42
+ claimed.clear();
43
+ },
44
+ };
45
+ }
@@ -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
  };