pi-subagents 0.56.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 (83) hide show
  1. package/CHANGELOG.md +53 -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 +111 -15
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +2 -2
  11. package/docs/observability.md +8 -7
  12. package/docs/tool-reference.md +11 -1
  13. package/docs/workflows.md +20 -0
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +1 -1
  16. package/src/agents/agent-management.ts +13 -5
  17. package/src/agents/agent-refinements.ts +4 -4
  18. package/src/agents/agents.ts +219 -48
  19. package/src/agents/builtin-names.ts +6 -0
  20. package/src/agents/runtime-agent-registry.ts +8 -2
  21. package/src/api/preflight.ts +2 -2
  22. package/src/extension/config.ts +70 -0
  23. package/src/extension/doctor.ts +3 -3
  24. package/src/extension/index.ts +18 -4
  25. package/src/extension/public-execution.ts +29 -13
  26. package/src/extension/rpc.ts +20 -5
  27. package/src/extension/schemas.ts +4 -3
  28. package/src/extension/tool-description.ts +11 -9
  29. package/src/inspectors/herdr/actions.ts +2 -1
  30. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  31. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  32. package/src/integrations/herdr-status.ts +46 -3
  33. package/src/runs/background/active-async-capacity.ts +77 -10
  34. package/src/runs/background/async-execution.ts +100 -9
  35. package/src/runs/background/async-job-tracker.ts +5 -0
  36. package/src/runs/background/async-retention.ts +20 -3
  37. package/src/runs/background/async-status.ts +5 -0
  38. package/src/runs/background/chain-root-attachment.ts +15 -1
  39. package/src/runs/background/fleet-view.ts +16 -10
  40. package/src/runs/background/inspect-rpc.ts +8 -8
  41. package/src/runs/background/result-files.ts +27 -14
  42. package/src/runs/background/result-watcher.ts +9 -5
  43. package/src/runs/background/run-status.ts +33 -6
  44. package/src/runs/background/scheduled-runs.ts +7 -1
  45. package/src/runs/background/subagent-runner.ts +177 -44
  46. package/src/runs/background/wait-completions.ts +4 -0
  47. package/src/runs/foreground/execution.ts +72 -10
  48. package/src/runs/foreground/foreground-control.ts +6 -0
  49. package/src/runs/foreground/foreground-history.ts +22 -1
  50. package/src/runs/foreground/subagent-executor.ts +270 -81
  51. package/src/runs/foreground/workflow-detach-reconcile.ts +16 -1
  52. package/src/runs/shared/child-protocol.ts +21 -7
  53. package/src/runs/shared/claude-code-adapter.ts +129 -0
  54. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  55. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  56. package/src/runs/shared/external-cli-contract.ts +167 -0
  57. package/src/runs/shared/external-cli-preflight.ts +122 -0
  58. package/src/runs/shared/external-cli-runner.ts +348 -55
  59. package/src/runs/shared/model-exclusions.ts +57 -7
  60. package/src/runs/shared/model-fallback.ts +23 -1
  61. package/src/runs/shared/mutation-evidence.ts +7 -2
  62. package/src/runs/shared/nested-events.ts +3 -1
  63. package/src/runs/shared/nested-render.ts +2 -2
  64. package/src/runs/shared/process-signal.ts +13 -0
  65. package/src/runs/shared/run-history.ts +21 -1
  66. package/src/shared/fork-context.ts +21 -0
  67. package/src/shared/formatters.ts +7 -1
  68. package/src/shared/pruned-fork.ts +450 -0
  69. package/src/shared/session-file-trust.ts +19 -0
  70. package/src/shared/session-tokens.ts +14 -3
  71. package/src/shared/settings.ts +10 -2
  72. package/src/shared/shortcuts.ts +17 -0
  73. package/src/shared/types.ts +118 -9
  74. package/src/shared/workflow-child-permit.ts +116 -0
  75. package/src/slash/delegation-adapters.ts +0 -1
  76. package/src/slash/slash-commands.ts +8 -6
  77. package/src/tui/fleet-status.ts +27 -10
  78. package/src/tui/fleet-transcript.ts +11 -5
  79. package/src/tui/fleet.ts +28 -13
  80. package/src/tui/render.ts +47 -21
  81. package/src/workflows/scripted-workflow.ts +248 -27
  82. package/src/workflows/workflow-child-summary.ts +117 -0
  83. package/src/workflows/workflow-receipt.ts +119 -4
@@ -4,9 +4,13 @@ import * as path from "node:path";
4
4
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
5
5
  import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } from "../../shared/types.ts";
6
6
  import { readStatus } from "../../shared/utils.ts";
7
+ import { checkPidLiveness, type PidLiveness } from "./stale-run-reconciler.ts";
7
8
  import { readProcessTerminal } from "./process-terminal.ts";
8
9
 
9
10
  export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
11
+ export const DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS = 20 * 60 * 1000;
12
+ export const MIN_ABANDONED_SLOT_RELEASE_AFTER_MS = 5 * 60 * 1000;
13
+ export const MAX_ABANDONED_SLOT_RELEASE_AFTER_MS = 24 * 60 * 60 * 1000;
10
14
 
11
15
  export interface ActiveAsyncCapacityOwnerV1 {
12
16
  version: 1;
@@ -36,11 +40,21 @@ interface CapacityOptions {
36
40
  rootDir?: string;
37
41
  now?: () => number;
38
42
  token?: () => string;
43
+ abandonedSlotReleaseAfterMs?: number | false;
44
+ pidLiveness?: (pid: number) => PidLiveness;
39
45
  afterSlotRename?: (releasedDir: string) => void;
40
46
  }
41
47
 
48
+ export interface ActiveAsyncCapacityReleaseEvidence {
49
+ releasedBy: "abandoned-timeout";
50
+ processProof: "unknown";
51
+ runnerPid: "gone";
52
+ lastActivityAgeMs: number;
53
+ abandonedSlotReleaseAfterMs: number;
54
+ }
55
+
42
56
  export type ActiveAsyncCapacityReleaseVerdict =
43
- | { state: "releasable"; reason: string }
57
+ | { state: "releasable"; reason: string; evidence?: ActiveAsyncCapacityReleaseEvidence }
44
58
  | { state: "retained"; reason: string }
45
59
  | { state: "not-owned"; reason: string };
46
60
 
@@ -66,6 +80,12 @@ export function resolveMaxActiveAsyncRunsPerSession(value: unknown): number | un
66
80
  return value === 0 ? undefined : value;
67
81
  }
68
82
 
83
+ export function resolveAbandonedSlotReleaseAfterMs(value: unknown): number | false {
84
+ if (value === false) return false;
85
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS;
86
+ return value;
87
+ }
88
+
69
89
  export function activeAsyncCapacitySessionKey(sessionId: string): string {
70
90
  return createHash("sha256").update(sessionId).digest("hex");
71
91
  }
@@ -154,7 +174,7 @@ function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; va
154
174
  }
155
175
  }
156
176
 
157
- function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false): boolean {
177
+ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false, release?: ActiveAsyncCapacityReleaseVerdict): boolean {
158
178
  if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
159
179
  const claimed = withSlotClaim(dir, () => {
160
180
  const current = matchingOwner(dir, expected);
@@ -162,17 +182,36 @@ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, opti
162
182
  const releasedDir = path.join(path.dirname(dir), `.${path.basename(dir)}.released-${randomUUID()}`);
163
183
  fs.renameSync(dir, releasedDir);
164
184
  options.afterSlotRename?.(releasedDir);
185
+ if (release?.state === "releasable" && release.evidence) {
186
+ appendAbandonedReleaseEvent(expected.asyncDir, expected, release.evidence, options.now?.() ?? Date.now());
187
+ }
165
188
  fs.rmSync(releasedDir, { recursive: true, force: true });
166
189
  return true;
167
190
  });
168
191
  return claimed.acquired && claimed.value;
169
192
  }
170
193
 
194
+ function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacityOwnerV1, evidence: ActiveAsyncCapacityReleaseEvidence, now: number): void {
195
+ try {
196
+ const eventsPath = path.join(asyncDir, "events.jsonl");
197
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
198
+ fs.appendFileSync(eventsPath, `${JSON.stringify({
199
+ type: "subagent.capacity.released",
200
+ ts: now,
201
+ runId: owner.runId,
202
+ sessionId: owner.ownerSessionId,
203
+ ...evidence,
204
+ })}\n`, "utf-8");
205
+ } catch {
206
+ // Capacity release must not fail because its diagnostic event cannot be written.
207
+ }
208
+ }
209
+
171
210
  function terminalState(state: AsyncStatus["state"]): boolean {
172
211
  return state !== "queued" && state !== "running" && state !== "paused";
173
212
  }
174
213
 
175
- function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null): ActiveAsyncCapacityReleaseVerdict {
214
+ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
176
215
  if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
177
216
  if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
178
217
  if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
@@ -191,7 +230,34 @@ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncSt
191
230
  && proof.runId === owner.runId
192
231
  && proof.runnerProcessInstanceId === owner.runnerProcessInstanceId
193
232
  ? { state: "releasable", reason: "matching observed process-terminal proof is present" }
194
- : { state: "retained", reason: `process-terminal proof is ${proof?.state ?? "missing"}` };
233
+ : abandonedRunnerReleaseVerdict(owner, status, proof?.state ?? "missing", options);
234
+ }
235
+
236
+ function abandonedRunnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, proofState: string, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
237
+ const proofReason = `process-terminal proof is ${proofState}`;
238
+ const thresholdMs = resolveAbandonedSlotReleaseAfterMs(options.abandonedSlotReleaseAfterMs);
239
+ if (thresholdMs === false) return { state: "retained", reason: `${proofReason}; abandoned-timeout policy is disabled` };
240
+ if (status.state !== "failed") return { state: "retained", reason: `${proofReason}; abandoned-timeout policy requires a failed run, not ${status.state}` };
241
+ if (typeof status.pid !== "number" || !Number.isInteger(status.pid) || status.pid < 1) return { state: "retained", reason: `${proofReason}; runner PID is missing or invalid` };
242
+ const liveness = (options.pidLiveness ?? checkPidLiveness)(status.pid);
243
+ if (liveness !== "dead") return { state: "retained", reason: `${proofReason}; runner PID liveness is ${liveness}` };
244
+ const lastActivityAt = status.lastActivityAt ?? status.lastUpdate ?? status.endedAt;
245
+ if (typeof lastActivityAt !== "number" || !Number.isFinite(lastActivityAt)) return { state: "retained", reason: `${proofReason}; last activity timestamp is missing or invalid` };
246
+ const now = options.now?.() ?? Date.now();
247
+ const lastActivityAgeMs = Math.max(0, now - lastActivityAt);
248
+ if (lastActivityAgeMs <= thresholdMs) return { state: "retained", reason: `${proofReason}; last activity age ${lastActivityAgeMs}ms has not exceeded abandoned-timeout ${thresholdMs}ms` };
249
+ const evidence: ActiveAsyncCapacityReleaseEvidence = {
250
+ releasedBy: "abandoned-timeout",
251
+ processProof: "unknown",
252
+ runnerPid: "gone",
253
+ lastActivityAgeMs,
254
+ abandonedSlotReleaseAfterMs: thresholdMs,
255
+ };
256
+ return {
257
+ state: "releasable",
258
+ reason: `${evidence.releasedBy}: ${proofReason}; runner PID is gone; process proof unknown; last activity age ${lastActivityAgeMs}ms exceeds ${thresholdMs}ms`,
259
+ evidence,
260
+ };
195
261
  }
196
262
 
197
263
  function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
@@ -222,10 +288,10 @@ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: Async
222
288
  return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
223
289
  }
224
290
 
225
- function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
291
+ function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
226
292
  const status = readStatus(owner.asyncDir);
227
293
  return owner.kind === "runner"
228
- ? runnerReleaseVerdict(owner, status)
294
+ ? runnerReleaseVerdict(owner, status, options)
229
295
  : workflowReleaseVerdict(owner, status, liveWorkflowRunIds);
230
296
  }
231
297
 
@@ -262,7 +328,7 @@ export function inspectActiveAsyncCapacityOwner(
262
328
  release: { state: "not-owned", reason: `slot was transferred to ${owner.runId}` },
263
329
  };
264
330
  }
265
- return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds) };
331
+ return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds, options) };
266
332
  }
267
333
  }
268
334
  return { relation: "none", release: { state: "not-owned", reason: "no active-capacity slot records this run" } };
@@ -281,9 +347,10 @@ export function reconcileActiveAsyncCapacity(
281
347
  if (!owner
282
348
  || owner.ownerSessionId !== sessionId
283
349
  || owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
284
- || path.basename(dir) !== `slot-${owner.slot}`
285
- || ownerReleaseVerdict(owner, liveWorkflowRunIds).state !== "releasable") continue;
286
- removeOwnedSlot(dir, owner, options);
350
+ || path.basename(dir) !== `slot-${owner.slot}`) continue;
351
+ const release = ownerReleaseVerdict(owner, liveWorkflowRunIds, options);
352
+ if (release.state !== "releasable") continue;
353
+ removeOwnedSlot(dir, owner, options, false, release);
287
354
  }
288
355
  return snapshotFor(sessionId, limit, rootDir);
289
356
  }
@@ -10,7 +10,7 @@ import * as path from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { createRequire } from "node:module";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
- import type { AgentConfig } from "../../agents/agents.ts";
13
+ import { discoverAgents, formatUnknownAgentError, unknownAgentDiagnosticContext, type AgentConfig, type UnknownAgentDiagnosticContext } from "../../agents/agents.ts";
14
14
  import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts";
15
15
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
16
16
  import { currentCompletionOwnerId } from "../../shared/completion-owner.ts";
@@ -39,6 +39,7 @@ import { validateImplementationToolContract } from "../shared/completion-guard.t
39
39
  import {
40
40
  type AcceptanceInput,
41
41
  type AgentContract,
42
+ type AsyncParallelGroupStatus,
42
43
  type AsyncStatus,
43
44
  type ArtifactConfig,
44
45
  type Details,
@@ -70,6 +71,7 @@ import type { ImportedAsyncRoot } from "./chain-root-attachment.ts";
70
71
  import type { SessionLeaseRequest } from "../shared/session-lease.ts";
71
72
  import { finalizeProcessTerminal, readProcessTerminal } from "./process-terminal.ts";
72
73
  import type { ActiveAsyncCapacityHandle } from "./active-async-capacity.ts";
74
+ import { statusStepDescription } from "./chain-append.ts";
73
75
  import { SUBAGENT_PROCESS_TERMINAL_EVENT } from "../../shared/types.ts";
74
76
  import { assertAgentAllowedByCapabilityCeiling, decodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, SUBAGENT_CAPABILITY_CEILING_ENV, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
75
77
  import { agentDefinitionDigest, launchBindingDigest } from "../../shared/launch-contract.ts";
@@ -151,6 +153,8 @@ interface AsyncChainParams {
151
153
  attachRoot?: ImportedAsyncRoot & { agent: string; outputName?: string; label?: string };
152
154
  resultMode?: SubagentRunMode;
153
155
  agents: AgentConfig[];
156
+ /** Original discovery provenance, retained by normal callers for unknown-agent diagnostics. */
157
+ unknownAgentDiagnosticContext?: UnknownAgentDiagnosticContext;
154
158
  ctx: AsyncExecutionContext;
155
159
  availableModels?: AvailableModelInfo[];
156
160
  cwd?: string;
@@ -283,6 +287,8 @@ export interface AsyncRunnerStepBuildParams {
283
287
  attachRoot?: ImportedAsyncRoot & { agent: string; outputName?: string; label?: string };
284
288
  resultMode?: SubagentRunMode;
285
289
  agents: AgentConfig[];
290
+ /** Exact discovery provenance for failed resolution; omission triggers defensive fallback discovery. */
291
+ unknownAgentDiagnosticContext?: UnknownAgentDiagnosticContext;
286
292
  ctx: AsyncExecutionContext;
287
293
  availableModels?: AvailableModelInfo[];
288
294
  cwd?: string;
@@ -493,7 +499,20 @@ interface SpawnRunnerResult {
493
499
  startupDidNotProceed?: boolean;
494
500
  }
495
501
 
496
- function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal?: (proof: unknown) => void): SpawnRunnerResult {
502
+ function isStaleExtensionContextError(error: unknown): boolean {
503
+ return error instanceof Error && /extension ctx is stale|stale after session replacement or reload/i.test(error.message);
504
+ }
505
+
506
+ export function emitProcessTerminalEvent(ctx: AsyncExecutionContext, proof: unknown): void {
507
+ try {
508
+ ctx.pi.events.emit(SUBAGENT_PROCESS_TERMINAL_EVENT, proof);
509
+ } catch (error) {
510
+ if (isStaleExtensionContextError(error)) return;
511
+ console.error("Failed to emit subagent process-terminal event:", error);
512
+ }
513
+ }
514
+
515
+ function spawnRunner(cfg: object, suffix: string, cwd: string, initialStatus: Omit<AsyncStatus, "pid" | "processTerminal">, initialStatusPath: string, onProcessTerminal?: (proof: unknown) => void): SpawnRunnerResult {
497
516
  if (!jitiCliPath) {
498
517
  return { error: "upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed" };
499
518
  }
@@ -510,7 +529,9 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
510
529
  fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true });
511
530
  const cfgPath = getAsyncConfigPath(suffix);
512
531
  const runnerProcessInstanceId = randomUUID();
513
- const launchConfig = { ...cfg, runnerProcessInstanceId };
532
+ const hasRevivalLease = typeof (cfg as { revivalLease?: unknown }).revivalLease === "object";
533
+ const launchBarrierToken = hasRevivalLease ? undefined : runnerProcessInstanceId;
534
+ const launchConfig = { ...cfg, runnerProcessInstanceId, ...(launchBarrierToken ? { launchBarrierToken } : {}) };
514
535
  writePrivateAtomicJson(cfgPath, launchConfig);
515
536
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
516
537
  const nodeCommand = resolveNodeExecutable();
@@ -523,7 +544,9 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
523
544
  ? path.join(launchAsyncDir, "runner-startup.json")
524
545
  : undefined;
525
546
  const startupAckPath = startupPath ? path.join(path.dirname(startupPath), "runner-startup-ack.json") : undefined;
526
- const startupProceedPath = startupPath ? path.join(path.dirname(startupPath), "runner-startup-proceed.json") : undefined;
547
+ const startupProceedPath = launchAsyncDir && (startupPath || launchBarrierToken)
548
+ ? path.join(launchAsyncDir, "runner-startup-proceed.json")
549
+ : undefined;
527
550
  if (startupPath) fs.rmSync(startupPath, { force: true });
528
551
  if (startupAckPath) fs.rmSync(startupAckPath, { force: true });
529
552
  if (startupProceedPath) fs.rmSync(startupProceedPath, { force: true });
@@ -605,6 +628,27 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
605
628
  if (typeof proc.pid !== "number") {
606
629
  return { error: `async runner did not produce a pid for cwd: ${cwd}` };
607
630
  }
631
+ try {
632
+ writePrivateAtomicJson(initialStatusPath, {
633
+ ...initialStatus,
634
+ pid: proc.pid,
635
+ processTerminal: { version: 1, state: "pending", runId: initialStatus.runId, runnerProcessInstanceId },
636
+ });
637
+ } catch (error) {
638
+ const message = `Failed to persist initial async status: ${error instanceof Error ? error.message : String(error)}`;
639
+ const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
640
+ return { pid: proc.pid, runnerProcessInstanceId, error: message, terminationObserved, startupDidNotProceed: true };
641
+ }
642
+ if (launchBarrierToken && startupProceedPath) {
643
+ try {
644
+ writeRunnerStartupControl(startupProceedPath, { action: "proceed", token: launchBarrierToken });
645
+ } catch (error) {
646
+ const message = `Failed to authorize async runner startup: ${error instanceof Error ? error.message : String(error)}`;
647
+ if (launchAsyncDir) persistPreProceedStartupFailure(launchAsyncDir, launchRunId, runnerProcessInstanceId, launchSessionId, launchCompletionOwnerId, message);
648
+ const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
649
+ return { pid: proc.pid, runnerProcessInstanceId, error: message, terminationObserved, startupDidNotProceed: true };
650
+ }
651
+ }
608
652
  proc.unref();
609
653
  if (startupPath && startupAckPath && startupProceedPath) {
610
654
  const persistStartupFailure = (message: string) => {
@@ -709,6 +753,8 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
709
753
  }
710
754
  const workflowGraph = buildWorkflowGraphSnapshot({ runId: id, mode: resultMode, steps: graphChain });
711
755
 
756
+ const diagnosticContext = params.unknownAgentDiagnosticContext
757
+ ?? unknownAgentDiagnosticContext(discoverAgents(path.resolve(runnerCwd), "both"));
712
758
  for (const s of chain) {
713
759
  const stepAgents = isParallelStep(s)
714
760
  ? s.parallel.map((t) => t.agent)
@@ -716,9 +762,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
716
762
  ? [s.parallel.agent]
717
763
  : [(s as SequentialStep).agent];
718
764
  for (const agentName of stepAgents) {
719
- if (!agents.find((x) => x.name === agentName)) {
720
- return { error: `Unknown agent: ${agentName}` };
721
- }
765
+ if (!agents.find((x) => x.name === agentName)) return { error: formatUnknownAgentError(agentName, diagnosticContext) };
722
766
  }
723
767
  }
724
768
 
@@ -1140,6 +1184,7 @@ export function executeAsyncChain(
1140
1184
  attachRoot: params.attachRoot,
1141
1185
  resultMode,
1142
1186
  agents,
1187
+ unknownAgentDiagnosticContext: params.unknownAgentDiagnosticContext,
1143
1188
  ctx,
1144
1189
  availableModels: params.availableModels,
1145
1190
  cwd,
@@ -1191,6 +1236,21 @@ export function executeAsyncChain(
1191
1236
  }
1192
1237
  return "agent" in step ? [childIntercomTarget(step.agent, childTargetIndex++)] : [undefined];
1193
1238
  }) : undefined;
1239
+ const initialStatusSteps = eventChain.flatMap((step) => isParallelStep(step)
1240
+ ? step.parallel.map((task) => ({ agent: task.agent, ...(statusStepDescription(task.task) ? { description: statusStepDescription(task.task) } : {}), ...(task.label ? { label: task.label } : {}), ...(task.as ? { outputName: task.as } : {}), status: "pending" as const }))
1241
+ : isDynamicParallelStep(step)
1242
+ ? [{ agent: `expand:${step.parallel.agent}`, label: step.label ?? step.parallel.label ?? `Dynamic fanout (${step.collect.as})`, outputName: step.collect.as, status: "pending" as const }]
1243
+ : [{ agent: (step as SequentialStep).agent, ...(statusStepDescription((step as SequentialStep).task) ? { description: statusStepDescription((step as SequentialStep).task) } : {}), ...((step as SequentialStep).label ? { label: (step as SequentialStep).label } : {}), ...((step as SequentialStep).as ? { outputName: (step as SequentialStep).as } : {}), status: "pending" as const }]);
1244
+ const initialParallelGroups: AsyncParallelGroupStatus[] = [];
1245
+ let initialFlatIndex = 0;
1246
+ for (let stepIndex = 0; stepIndex < eventChain.length; stepIndex++) {
1247
+ const step = eventChain[stepIndex]!;
1248
+ if (isParallelStep(step)) initialParallelGroups.push({ start: initialFlatIndex, count: step.parallel.length, stepIndex });
1249
+ else if (isDynamicParallelStep(step)) initialParallelGroups.push({ start: initialFlatIndex, count: 1, stepIndex });
1250
+ initialFlatIndex += isParallelStep(step) ? step.parallel.length : 1;
1251
+ }
1252
+ const initialStatusAt = Date.now();
1253
+ const initialCompletionOwnerId = ctx.completionOwnerId ?? currentCompletionOwnerId();
1194
1254
 
1195
1255
  let spawnResult: SpawnRunnerResult = {};
1196
1256
  try {
@@ -1240,7 +1300,22 @@ export function executeAsyncChain(
1240
1300
  },
1241
1301
  id,
1242
1302
  runnerCwd,
1243
- (proof) => ctx.pi.events.emit(SUBAGENT_PROCESS_TERMINAL_EVENT, proof),
1303
+ {
1304
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
1305
+ runId: id,
1306
+ ...(ctx.currentSessionId ? { sessionId: ctx.currentSessionId } : {}),
1307
+ ...(initialCompletionOwnerId ? { completionOwnerId: initialCompletionOwnerId } : {}),
1308
+ mode: resultMode,
1309
+ state: "running",
1310
+ startedAt: initialStatusAt,
1311
+ lastUpdate: initialStatusAt,
1312
+ currentStep: 0,
1313
+ chainStepCount: eventChain.length,
1314
+ ...(initialParallelGroups.length ? { parallelGroups: initialParallelGroups } : {}),
1315
+ steps: initialStatusSteps,
1316
+ },
1317
+ path.join(asyncDir, "status.json"),
1318
+ (proof) => emitProcessTerminalEvent(ctx, proof),
1244
1319
  );
1245
1320
  } catch (error) {
1246
1321
  params.activeAsyncCapacity?.rollback();
@@ -1678,6 +1753,8 @@ export function executeAsyncSingle(
1678
1753
  }
1679
1754
  }
1680
1755
  let spawnResult: SpawnRunnerResult = {};
1756
+ const initialStatusAt = Date.now();
1757
+ const initialCompletionOwnerId = ctx.completionOwnerId ?? currentCompletionOwnerId();
1681
1758
  try {
1682
1759
  spawnResult = spawnRunner(
1683
1760
  {
@@ -1773,7 +1850,21 @@ export function executeAsyncSingle(
1773
1850
  },
1774
1851
  id,
1775
1852
  runnerCwd,
1776
- (proof) => ctx.pi.events.emit(SUBAGENT_PROCESS_TERMINAL_EVENT, proof),
1853
+ {
1854
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
1855
+ runId: id,
1856
+ ...(ctx.currentSessionId ? { sessionId: ctx.currentSessionId } : {}),
1857
+ ...(initialCompletionOwnerId ? { completionOwnerId: initialCompletionOwnerId } : {}),
1858
+ mode: "single",
1859
+ state: "running",
1860
+ startedAt: initialStatusAt,
1861
+ lastUpdate: initialStatusAt,
1862
+ currentStep: 0,
1863
+ chainStepCount: 1,
1864
+ steps: [{ agent, status: "pending" }],
1865
+ },
1866
+ path.join(asyncDir, "status.json"),
1867
+ (proof) => emitProcessTerminalEvent(ctx, proof),
1777
1868
  );
1778
1869
  } catch (error) {
1779
1870
  params.activeAsyncCapacity?.rollback();
@@ -24,6 +24,7 @@ import { findNestedRouteForRootId, hasLiveNestedDescendants, updateAsyncJobNeste
24
24
  import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
25
25
  import { EXTERNAL_JOB_BRIDGE_REQUEST_DIR, serviceExternalJobBridgeRequests } from "../shared/external-job-bridge.ts";
26
26
  import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
27
+ import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
27
28
 
28
29
  interface AsyncJobTrackerOptions {
29
30
  completionRetentionMs?: number;
@@ -171,6 +172,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
171
172
  parentWorkflowRunId: run.parentWorkflowRunId,
172
173
  workflowKey: run.workflowKey,
173
174
  workflow: run.workflow,
175
+ workflowChildren: parseWorkflowChildSummary(run.workflowChildren),
174
176
  };
175
177
  };
176
178
  const cancelCleanup = (asyncId: string) => {
@@ -398,6 +400,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
398
400
  job.parentWorkflowRunId = status.parentWorkflowRunId ?? job.parentWorkflowRunId;
399
401
  job.workflowKey = status.workflowKey ?? job.workflowKey;
400
402
  job.workflow = status.workflow ?? job.workflow;
403
+ const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
404
+ if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error("workflowChildren.workflowRunId does not match async status runId.");
405
+ job.workflowChildren = workflowChildren ?? job.workflowChildren;
401
406
  job.currentStep = status.currentStep ?? job.currentStep;
402
407
  job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
403
408
  job.startedAt = status.startedAt ?? job.startedAt;
@@ -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;
@@ -121,6 +122,7 @@ export interface AsyncRunSummary {
121
122
  parentWorkflowRunId?: string;
122
123
  workflowKey?: string;
123
124
  workflow?: Details["workflow"];
125
+ workflowChildren?: Details["workflowChildren"];
124
126
  }
125
127
 
126
128
  interface AsyncRunListOptions {
@@ -229,6 +231,8 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
229
231
  }
230
232
 
231
233
  function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
234
+ const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
235
+ if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': workflowChildren.workflowRunId does not match.`);
232
236
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
233
237
  throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
234
238
  }
@@ -357,6 +361,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
357
361
  ...(status.parentWorkflowRunId ? { parentWorkflowRunId: status.parentWorkflowRunId } : {}),
358
362
  ...(status.workflowKey ? { workflowKey: status.workflowKey } : {}),
359
363
  ...(status.workflow ? { workflow: status.workflow } : {}),
364
+ ...(workflowChildren ? { workflowChildren } : {}),
360
365
  ...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
361
366
  ...(status.outputFile ? { outputFile: status.outputFile } : {}),
362
367
  ...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
@@ -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}`);