pi-cohort 7.1.2 → 7.2.1

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.
@@ -3,7 +3,8 @@
3
3
  */
4
4
 
5
5
  import { spawn } from "node:child_process";
6
- import * as fs from "node:fs";
6
+ import { PINNED_AGENT_DIR_OVERRIDE } from "../../shared/agent-dir.ts";
7
+ import fs from "node:fs";
7
8
  import * as os from "node:os";
8
9
  import * as path from "node:path";
9
10
  import { fileURLToPath } from "node:url";
@@ -12,8 +13,9 @@ import type { AgentConfig } from "../../agents/agents.ts";
12
13
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
13
14
  import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveParallelTaskOutputPath, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
14
15
  import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
15
- import type { RunnerStep } from "../shared/parallel-utils.ts";
16
- import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
16
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
17
+ import { isDynamicRunnerGroup, isParallelGroup, type RunnerStep } from "../shared/parallel-utils.ts";
18
+ import { getPiSpawnCommand, PiRuntimeLaunchError, type PiRuntimeIdentity } from "../shared/pi-spawn.ts";
17
19
  import { ensureJitiCliPath, resolveHostModuleAliases } from "../shared/jiti-cli.ts";
18
20
  export { createJitiCliResolver } from "../shared/jiti-cli.ts";
19
21
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
@@ -39,14 +41,14 @@ import {
39
41
  RESULTS_DIR,
40
42
  SUBAGENT_ASYNC_STARTED_EVENT,
41
43
  TEMP_ROOT_DIR,
44
+ RUN_STATE_ROOT_DIR,
42
45
  getAsyncConfigPath,
43
46
  resolveChildMaxSubagentDepth,
44
47
  } from "../../shared/types.ts";
45
48
  import { nestedResultsPath, resolveInheritedNestedRouteFromEnv, resolveNestedParentAddressFromEnv, writeNestedEvent } from "../shared/nested-events.ts";
46
49
 
47
- const piPackageRoot = resolvePiPackageRoot();
48
-
49
50
  interface AsyncExecutionContext {
51
+ runtime: PiRuntimeIdentity;
50
52
  pi: ExtensionAPI;
51
53
  cwd: string;
52
54
  currentSessionId: string;
@@ -73,6 +75,7 @@ interface AsyncChainParams {
73
75
  sessionRoot?: string;
74
76
  chainSkills?: string[];
75
77
  sessionFilesByFlatIndex?: (string | undefined)[];
78
+ originSessionRoot?: string;
76
79
  dynamicFanoutMaxItems?: number;
77
80
  maxSubagentDepth: number;
78
81
  worktreeSetupHook?: string;
@@ -103,6 +106,7 @@ interface AsyncSingleParams {
103
106
  shareEnabled: boolean;
104
107
  sessionRoot?: string;
105
108
  sessionFile?: string;
109
+ originSessionRoot?: string;
106
110
  skills?: string[];
107
111
  output?: string | boolean;
108
112
  outputMode?: "inline" | "file-only";
@@ -136,8 +140,8 @@ export function formatAsyncStartedMessage(headline: string): string {
136
140
  /**
137
141
  * Check if jiti is available for async execution
138
142
  */
139
- export function isAsyncAvailable(): boolean {
140
- return ensureJitiCliPath() !== undefined;
143
+ export function isAsyncAvailable(runtime: PiRuntimeIdentity): boolean {
144
+ return ensureJitiCliPath(runtime.piPackageRoot) !== undefined;
141
145
  }
142
146
 
143
147
  /**
@@ -184,18 +188,19 @@ export function spawnDetachedWithLog(command: string, args: string[], cwd: strin
184
188
  * Spawn the async runner process
185
189
  */
186
190
  export function spawnRunner(
187
- cfg: object,
191
+ cfg: { runtime: PiRuntimeIdentity },
188
192
  suffix: string,
189
193
  cwd: string,
190
194
  asyncDir: string,
191
- deps: { ensureJiti?: () => string | undefined; spawnImpl?: typeof spawn; hostAliases?: () => Record<string, string> } = {},
195
+ deps: { ensureJiti?: (root: string) => string | undefined; spawnImpl?: typeof spawn; hostAliases?: (root: string) => Record<string, string> } = {},
192
196
  ): { pid?: number; error?: string } {
193
197
  const ensureJiti = deps.ensureJiti ?? ensureJitiCliPath;
194
- const jitiCliPath = ensureJiti();
198
+ const jitiCliPath = ensureJiti(cfg.runtime.piPackageRoot);
195
199
  if (!jitiCliPath) {
196
- return { error: "upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed" };
200
+ throw new PiRuntimeLaunchError(`Cannot resolve jiti from selected Pi installation ${cfg.runtime.piPackageRoot}`);
197
201
  }
198
202
 
203
+ getPiSpawnCommand([], cfg.runtime);
199
204
  try {
200
205
  const cwdStats = fs.statSync(cwd);
201
206
  if (!cwdStats.isDirectory()) {
@@ -210,12 +215,122 @@ export function spawnRunner(
210
215
  fs.writeFileSync(cfgPath, JSON.stringify(cfg));
211
216
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
212
217
 
213
- const aliases = (deps.hostAliases ?? resolveHostModuleAliases)();
218
+ const aliases = (deps.hostAliases ?? resolveHostModuleAliases)(cfg.runtime.piPackageRoot);
214
219
  const env = Object.keys(aliases).length > 0
215
220
  ? { ...process.env, JITI_ALIAS: JSON.stringify(aliases) }
216
- : process.env;
221
+ : { ...process.env };
222
+ if (PINNED_AGENT_DIR_OVERRIDE) env.PI_CODING_AGENT_DIR = PINNED_AGENT_DIR_OVERRIDE;
223
+
224
+ return spawnDetachedWithLog(cfg.runtime.nodeExecPath, [jitiCliPath, runner, cfgPath], cwd, asyncDir, deps.spawnImpl ?? spawn, env);
225
+ }
226
+
227
+ type NestedStartWriter = (...args: Parameters<typeof writeNestedEvent>) => void | boolean;
217
228
 
218
- return spawnDetachedWithLog(process.execPath, [jitiCliPath, runner, cfgPath], cwd, asyncDir, deps.spawnImpl ?? spawn, env);
229
+ function publishAndSpawnRunner(
230
+ cfg: { runtime: PiRuntimeIdentity; id: string; steps: RunnerStep[]; sessionId: string; sessionDir: string; resultMode: SubagentRunMode; [key: string]: unknown },
231
+ suffix: string,
232
+ cwd: string,
233
+ asyncDir: string,
234
+ origin: { route?: NestedRouteInfo; address?: ReturnType<typeof resolveNestedParentAddressFromEnv>; sessionFile?: string; sessionFiles?: string[]; sessionRoot?: string },
235
+ writeStart: NestedStartWriter,
236
+ ): { pid?: number; error?: string } {
237
+ const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
238
+ const steps: object[] = [];
239
+ for (const [stepIndex, step] of cfg.steps.entries()) {
240
+ const leaves = isParallelGroup(step) ? step.parallel : isDynamicRunnerGroup(step) ? [step.parallel] : [step];
241
+ if (isParallelGroup(step) || isDynamicRunnerGroup(step)) parallelGroups.push({ start: steps.length, count: leaves.length, stepIndex });
242
+ for (const leaf of leaves) {
243
+ steps.push({
244
+ agent: isDynamicRunnerGroup(step) ? `expand:${leaf.agent}` : leaf.agent,
245
+ phase: isDynamicRunnerGroup(step) ? step.phase ?? leaf.phase : leaf.phase,
246
+ label: isDynamicRunnerGroup(step) ? step.label ?? leaf.label ?? `Dynamic fanout (${step.collect.as})` : leaf.label,
247
+ outputName: isDynamicRunnerGroup(step) ? step.collect.as : leaf.outputName,
248
+ structured: isDynamicRunnerGroup(step) ? Boolean(step.collect.outputSchema) : leaf.structured,
249
+ status: "pending",
250
+ ...(isDynamicRunnerGroup(step) ? {} : {
251
+ sessionFile: leaf.sessionFile,
252
+ skills: leaf.skills,
253
+ model: leaf.model,
254
+ thinking: leaf.thinking,
255
+ attemptedModels: leaf.modelCandidates && leaf.modelCandidates.length > 0 ? leaf.modelCandidates : leaf.model ? [leaf.model] : undefined,
256
+ }),
257
+ recentTools: [],
258
+ recentOutput: [],
259
+ });
260
+ }
261
+ }
262
+ let preparationError: unknown;
263
+ try {
264
+ fs.mkdirSync(cfg.sessionDir, { recursive: true });
265
+ cfg.sessionDir = fs.realpathSync.native(cfg.sessionDir);
266
+ } catch (error) {
267
+ preparationError = error;
268
+ }
269
+ const statusPath = path.join(asyncDir, "status.json");
270
+ const now = Date.now();
271
+ const status = {
272
+ runId: cfg.id, runtime: cfg.runtime, sessionId: cfg.sessionId,
273
+ state: "queued", mode: cfg.resultMode, cwd, currentStep: 0, chainStepCount: cfg.steps.length,
274
+ startedAt: now, lastUpdate: now, lastActivityAt: now, outputFile: path.join(asyncDir, "output-0.log"), steps, parallelGroups, workflowGraph: cfg.workflowGraph,
275
+ artifactsDir: cfg.artifactsDir, sessionDir: cfg.sessionDir,
276
+ };
277
+ writeAtomicJson(statusPath, status);
278
+ try {
279
+ if (preparationError) throw preparationError;
280
+ let sessionRoot = cfg.sessionDir;
281
+ if (origin.sessionRoot !== undefined) {
282
+ if (!origin.sessionFile && !origin.sessionFiles?.length) throw new Error("Origin session root requires a reused session file.");
283
+ if (!path.isAbsolute(origin.sessionRoot)) throw new Error("Origin session root must be absolute.");
284
+ let stat: fs.Stats;
285
+ try {
286
+ stat = fs.statSync(origin.sessionRoot);
287
+ } catch {
288
+ throw new Error("Origin session root must exist.");
289
+ }
290
+ if (!stat.isDirectory()) throw new Error("Origin session root must be a directory.");
291
+ sessionRoot = fs.realpathSync.native(origin.sessionRoot);
292
+ if (sessionRoot !== origin.sessionRoot) throw new Error("Origin session root must be canonical and cannot be retargeted.");
293
+ }
294
+ const effectiveFiles = origin.sessionFiles ?? (origin.sessionFile ? [origin.sessionFile] : []);
295
+ if (origin.sessionRoot) {
296
+ const contains = (candidate: string) => {
297
+ const relative = path.relative(sessionRoot, candidate);
298
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
299
+ };
300
+ if (origin.sessionFiles && !contains(cfg.sessionDir)) throw new Error("Runner session directory is outside origin session root.");
301
+ for (const file of effectiveFiles) {
302
+ if (!fs.existsSync(file)) throw new Error("Reused session file must be an existing regular .jsonl file.");
303
+ const stat = fs.lstatSync(file);
304
+ if (!stat.isFile() || stat.isSymbolicLink() || path.extname(file) !== ".jsonl") throw new Error("Reused session file must be a regular .jsonl file.");
305
+ if (!contains(fs.realpathSync.native(file))) throw new Error("Reused session file is outside origin session root.");
306
+ }
307
+ }
308
+ if (origin.route && origin.address) {
309
+ if (effectiveFiles.some(file => fs.existsSync(file)) && !origin.sessionRoot) throw new Error("Origin session root is required for a reused nested session.");
310
+ const firstLeaf = isParallelGroup(cfg.steps[0]) ? cfg.steps[0].parallel[0] : isDynamicRunnerGroup(cfg.steps[0]) ? cfg.steps[0].parallel : cfg.steps[0];
311
+ const agents = cfg.steps.flatMap(step => isParallelGroup(step) ? step.parallel.map(leaf => leaf.agent) : isDynamicRunnerGroup(step) ? [step.parallel.agent] : [step.agent]);
312
+ try {
313
+ if (writeStart(origin.route, {
314
+ type: "subagent.nested.started", ts: now,
315
+ parentRunId: origin.address.parentRunId, parentStepIndex: origin.address.parentStepIndex,
316
+ child: {
317
+ id: cfg.id, ...origin.address, asyncDir, sessionId: cfg.sessionId, capabilityToken: origin.route.capabilityToken,
318
+ runtime: cfg.runtime, sessionRoot, mode: cfg.resultMode, state: "queued",
319
+ agent: firstLeaf?.agent, agents, chainStepCount: cfg.steps.length, parallelGroups,
320
+ startedAt: now, lastUpdate: now,
321
+ },
322
+ }) === false) throw new Error("Nested origin stamp was refused.");
323
+ } catch (error) {
324
+ throw new Error(`Failed to stamp nested origin: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
325
+ }
326
+ }
327
+ const result = spawnRunner(cfg, suffix, cwd, asyncDir);
328
+ if (result.error) throw new Error(result.error);
329
+ return result;
330
+ } catch (error) {
331
+ writeAtomicJson(statusPath, { ...status, state: "failed", error: error instanceof Error ? error.message : String(error), lastUpdate: Date.now() });
332
+ throw error;
333
+ }
219
334
  }
220
335
 
221
336
  /**
@@ -245,6 +360,7 @@ class AsyncStartValidationError extends Error {}
245
360
  export function executeAsyncChain(
246
361
  id: string,
247
362
  params: AsyncChainParams,
363
+ writeStart: NestedStartWriter = writeNestedEvent,
248
364
  ): AsyncExecutionResult {
249
365
  const {
250
366
  chain,
@@ -304,10 +420,11 @@ export function executeAsyncChain(
304
420
  const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
305
421
  const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
306
422
  const asyncDir = inheritedNestedRoute
307
- ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
423
+ ? path.join(RUN_STATE_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
308
424
  : path.join(ASYNC_DIR, id);
309
425
  try {
310
- fs.mkdirSync(asyncDir, { recursive: true });
426
+ fs.mkdirSync(path.dirname(asyncDir), { recursive: true });
427
+ fs.mkdirSync(asyncDir);
311
428
  } catch (error) {
312
429
  const message = error instanceof Error ? error.message : String(error);
313
430
  return {
@@ -486,7 +603,7 @@ export function executeAsyncChain(
486
603
 
487
604
  let spawnResult: { pid?: number; error?: string } = {};
488
605
  try {
489
- spawnResult = spawnRunner(
606
+ spawnResult = publishAndSpawnRunner(
490
607
  {
491
608
  id,
492
609
  steps,
@@ -497,11 +614,10 @@ export function executeAsyncChain(
497
614
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
498
615
  artifactConfig,
499
616
  share: shareEnabled,
500
- sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined,
617
+ sessionDir: path.join(sessionRoot ?? path.join(RUN_STATE_ROOT_DIR, "sessions"), `async-${id}`),
501
618
  asyncDir,
502
619
  sessionId: ctx.currentSessionId,
503
- piPackageRoot,
504
- piArgv1: process.argv[1],
620
+ runtime: ctx.runtime,
505
621
  forwardedFlags,
506
622
  worktreeSetupHook,
507
623
  worktreeSetupHookTimeoutMs,
@@ -521,6 +637,15 @@ export function executeAsyncChain(
521
637
  id,
522
638
  runnerCwd,
523
639
  asyncDir,
640
+ {
641
+ route: inheritedNestedRoute,
642
+ address: nestedAddress,
643
+ sessionFiles: steps
644
+ .flatMap(step => isParallelGroup(step) ? step.parallel : isDynamicRunnerGroup(step) ? [step.parallel] : [step])
645
+ .flatMap(step => step.sessionFile ? [step.sessionFile] : []),
646
+ sessionRoot: params.originSessionRoot,
647
+ },
648
+ writeStart,
524
649
  );
525
650
  } catch (error) {
526
651
  const message = error instanceof Error ? error.message : String(error);
@@ -556,37 +681,6 @@ export function executeAsyncChain(
556
681
  flatStepStart++;
557
682
  }
558
683
  }
559
- if (inheritedNestedRoute && nestedAddress) {
560
- const now = Date.now();
561
- try {
562
- writeNestedEvent(inheritedNestedRoute, {
563
- type: "subagent.nested.started",
564
- ts: now,
565
- parentRunId: nestedAddress.parentRunId,
566
- parentStepIndex: nestedAddress.parentStepIndex,
567
- child: {
568
- id,
569
- parentRunId: nestedAddress.parentRunId,
570
- parentStepIndex: nestedAddress.parentStepIndex,
571
- depth: nestedAddress.depth,
572
- path: nestedAddress.path,
573
- asyncDir,
574
- pid: spawnResult.pid,
575
- ownerState: "live",
576
- mode: resultMode,
577
- state: "running",
578
- agent: firstAgents[0],
579
- agents: flatAgents,
580
- chainStepCount: chain.length,
581
- parallelGroups,
582
- startedAt: now,
583
- lastUpdate: now,
584
- },
585
- });
586
- } catch (error) {
587
- console.error("Failed to emit nested async start event:", error);
588
- }
589
- }
590
684
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
591
685
  id,
592
686
  pid: spawnResult.pid,
@@ -629,6 +723,7 @@ export function executeAsyncChain(
629
723
  export function executeAsyncSingle(
630
724
  id: string,
631
725
  params: AsyncSingleParams,
726
+ writeStart: NestedStartWriter = writeNestedEvent,
632
727
  ): AsyncExecutionResult {
633
728
  const {
634
729
  agent,
@@ -663,10 +758,11 @@ export function executeAsyncSingle(
663
758
  const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
664
759
  const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
665
760
  const asyncDir = inheritedNestedRoute
666
- ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
761
+ ? path.join(RUN_STATE_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
667
762
  : path.join(ASYNC_DIR, id);
668
763
  try {
669
- fs.mkdirSync(asyncDir, { recursive: true });
764
+ fs.mkdirSync(path.dirname(asyncDir), { recursive: true });
765
+ fs.mkdirSync(asyncDir);
670
766
  } catch (error) {
671
767
  const message = error instanceof Error ? error.message : String(error);
672
768
  return {
@@ -687,7 +783,7 @@ export function executeAsyncSingle(
687
783
  const model = applyThinkingSuffix(resolveModelCandidate(primaryRaw, availableModels, ctx.currentModelProvider), thinking);
688
784
  let spawnResult: { pid?: number; error?: string } = {};
689
785
  try {
690
- spawnResult = spawnRunner(
786
+ spawnResult = publishAndSpawnRunner(
691
787
  {
692
788
  id,
693
789
  steps: [
@@ -728,11 +824,10 @@ export function executeAsyncSingle(
728
824
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
729
825
  artifactConfig,
730
826
  share: shareEnabled,
731
- sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined,
827
+ sessionDir: path.join(sessionRoot ?? path.join(RUN_STATE_ROOT_DIR, "sessions"), `async-${id}`),
732
828
  asyncDir,
733
829
  sessionId: ctx.currentSessionId,
734
- piPackageRoot,
735
- piArgv1: process.argv[1],
830
+ runtime: ctx.runtime,
736
831
  forwardedFlags,
737
832
  worktreeSetupHook,
738
833
  worktreeSetupHookTimeoutMs,
@@ -750,6 +845,8 @@ export function executeAsyncSingle(
750
845
  id,
751
846
  runnerCwd,
752
847
  asyncDir,
848
+ { route: inheritedNestedRoute, address: nestedAddress, sessionFile, sessionRoot: params.originSessionRoot },
849
+ writeStart,
753
850
  );
754
851
  } catch (error) {
755
852
  const message = error instanceof Error ? error.message : String(error);
@@ -761,36 +858,6 @@ export function executeAsyncSingle(
761
858
  }
762
859
 
763
860
  if (spawnResult.pid) {
764
- if (inheritedNestedRoute && nestedAddress) {
765
- const now = Date.now();
766
- try {
767
- writeNestedEvent(inheritedNestedRoute, {
768
- type: "subagent.nested.started",
769
- ts: now,
770
- parentRunId: nestedAddress.parentRunId,
771
- parentStepIndex: nestedAddress.parentStepIndex,
772
- child: {
773
- id,
774
- parentRunId: nestedAddress.parentRunId,
775
- parentStepIndex: nestedAddress.parentStepIndex,
776
- depth: nestedAddress.depth,
777
- path: nestedAddress.path,
778
- asyncDir,
779
- pid: spawnResult.pid,
780
- ownerState: "live",
781
- mode: "single",
782
- state: "running",
783
- agent,
784
- agents: [agent],
785
- chainStepCount: 1,
786
- startedAt: now,
787
- lastUpdate: now,
788
- },
789
- });
790
- } catch (error) {
791
- console.error("Failed to emit nested async start event:", error);
792
- }
793
- }
794
861
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
795
862
  id,
796
863
  pid: spawnResult.pid,
@@ -140,7 +140,10 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
140
140
  try {
141
141
  emitNewControlEvents(job);
142
142
  reconcileNestedDescendants();
143
- const reconciliation = reconcileAsyncRun(job.asyncDir, {
143
+ const stored = readStatus(job.asyncDir);
144
+ const reconciliation = stored && stored.state !== "running" && stored.state !== "queued"
145
+ ? { status: stored }
146
+ : reconcileAsyncRun(job.asyncDir, {
144
147
  resultsDir,
145
148
  kill: options.kill,
146
149
  now: options.now,
@@ -1,7 +1,12 @@
1
- import * as fs from "node:fs";
1
+ import fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus } from "../../shared/types.ts";
3
+ import { ASYNC_DIR, LEGACY_ASYNC_DIR, RESULTS_DIR, type AsyncStatus } from "../../shared/types.ts";
4
4
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
5
+ import { parsePiRuntime, type PiRuntimeIdentity } from "../shared/pi-spawn.ts";
6
+
7
+ export function isLegacyAsyncDir(asyncDir: string): boolean {
8
+ try { assertInsideRoot(LEGACY_ASYNC_DIR, asyncDir, "Async run directory"); return true; } catch { return false; }
9
+ }
5
10
 
6
11
  export interface AsyncResumeParams {
7
12
  id?: string;
@@ -11,7 +16,7 @@ export interface AsyncResumeParams {
11
16
  }
12
17
 
13
18
  export interface AsyncResumeDeps {
14
- asyncDirRoot?: string;
19
+ asyncDirRoot?: string | readonly string[];
15
20
  resultsDir?: string;
16
21
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
17
22
  now?: () => number;
@@ -19,6 +24,7 @@ export interface AsyncResumeDeps {
19
24
 
20
25
  export type AsyncResumeTarget = {
21
26
  kind: "revive";
27
+ runtime: PiRuntimeIdentity;
22
28
  runId: string;
23
29
  asyncDir?: string;
24
30
  state: AsyncStatus["state"];
@@ -29,6 +35,7 @@ export type AsyncResumeTarget = {
29
35
  };
30
36
 
31
37
  interface AsyncResultFile {
38
+ runtime?: unknown;
32
39
  id?: string;
33
40
  runId?: string;
34
41
  agent?: string;
@@ -82,6 +89,7 @@ function validateResultFile(value: unknown, resultPath: string): AsyncResultFile
82
89
  const success = data.success;
83
90
  if (success !== undefined && typeof success !== "boolean") throw new Error(`Invalid async result file '${resultPath}': success must be a boolean.`);
84
91
  return {
92
+ runtime: data.runtime,
85
93
  id: validateOptionalString(data, "id", resultPath),
86
94
  runId: validateOptionalString(data, "runId", resultPath),
87
95
  agent: validateOptionalString(data, "agent", resultPath),
@@ -124,9 +132,16 @@ function assertRunId(value: string | undefined, field: "id" | "runId"): string |
124
132
  return value;
125
133
  }
126
134
 
135
+ function canonicalPath(value: string): string {
136
+ const resolved = path.resolve(value);
137
+ if (fs.existsSync(resolved)) return fs.realpathSync(resolved);
138
+ const parent = path.dirname(resolved);
139
+ return parent === resolved ? resolved : path.join(canonicalPath(parent), path.basename(resolved));
140
+ }
141
+
127
142
  function assertInsideRoot(root: string, target: string, label: string): void {
128
- const rootPath = path.resolve(root);
129
- const targetPath = path.resolve(target);
143
+ const rootPath = canonicalPath(root);
144
+ const targetPath = canonicalPath(target);
130
145
  const relative = path.relative(rootPath, targetPath);
131
146
  if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
132
147
  throw new Error(`${label} must be inside ${rootPath}.`);
@@ -146,22 +161,34 @@ function exactResultPath(resultsDir: string, runId: string): string | null {
146
161
  return fs.existsSync(resultPath) ? resultPath : null;
147
162
  }
148
163
 
149
- export function findAsyncRunPrefixMatches(prefix: string, asyncDirRoot: string, resultsDir: string): Array<{ id: string; location: AsyncRunLocation }> {
164
+ export function winningAsyncRunEntries(asyncDirRoot: string | readonly string[], prefix = ""): Array<{ id: string; root: string; asyncDir: string }> {
165
+ const winners = new Map<string, { id: string; root: string; asyncDir: string }>();
166
+ for (const root of typeof asyncDirRoot === "string" ? [asyncDirRoot] : asyncDirRoot) {
167
+ for (const id of prefixedRunIds(root, prefix)) {
168
+ if (!winners.has(id)) winners.set(id, { id, root, asyncDir: path.join(path.resolve(root), id) });
169
+ }
170
+ }
171
+ return [...winners.values()].sort((a, b) => a.id.localeCompare(b.id));
172
+ }
173
+
174
+ export function findAsyncRunPrefixMatches(prefix: string, asyncDirRoot: string | readonly string[], resultsDir: string): Array<{ id: string; location: AsyncRunLocation }> {
150
175
  const requestedId = assertRunId(prefix, "id");
151
176
  if (!requestedId) return [];
152
- const asyncRoot = path.resolve(asyncDirRoot);
177
+ const entries = winningAsyncRunEntries(asyncDirRoot, requestedId);
178
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
153
179
  const resultRoot = path.resolve(resultsDir);
154
180
  const matchingIds = [...new Set([
155
- ...prefixedRunIds(asyncRoot, requestedId),
181
+ ...byId.keys(),
156
182
  ...prefixedRunIds(resultRoot, requestedId, ".json"),
157
183
  ])].sort();
158
184
  return matchingIds.map((id) => {
159
- const asyncDir = path.join(asyncRoot, id);
160
- assertInsideRoot(asyncRoot, asyncDir, "Async run directory");
185
+ const entry = byId.get(id);
186
+ const asyncDir = entry?.asyncDir;
187
+ if (entry) assertInsideRoot(entry.root, entry.asyncDir, "Async run directory");
161
188
  return {
162
189
  id,
163
190
  location: {
164
- asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
191
+ asyncDir: asyncDir && fs.existsSync(asyncDir) ? asyncDir : null,
165
192
  resultPath: exactResultPath(resultRoot, id),
166
193
  resolvedId: id,
167
194
  },
@@ -169,7 +196,28 @@ export function findAsyncRunPrefixMatches(prefix: string, asyncDirRoot: string,
169
196
  });
170
197
  }
171
198
 
172
- export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot: string, resultsDir: string): AsyncRunLocation {
199
+ export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot: string | readonly string[], resultsDir: string): AsyncRunLocation {
200
+ if (typeof asyncDirRoot !== "string") {
201
+ if (params.dir) {
202
+ for (const root of asyncDirRoot) {
203
+ try { assertInsideRoot(root, params.dir, "Async run directory"); } catch { continue; }
204
+ return resolveAsyncRunLocation(params, root, resultsDir);
205
+ }
206
+ throw new Error("Async run directory must be inside an authorized async root.");
207
+ }
208
+ const id = assertRunId(params.id, "id") ?? assertRunId(params.runId, "runId");
209
+ if (!id) return { asyncDir: null, resultPath: null };
210
+ const exact = winningAsyncRunEntries(asyncDirRoot, id).find((entry) => entry.id === id);
211
+ if (exact) {
212
+ assertInsideRoot(exact.root, exact.asyncDir, "Async run directory");
213
+ return { asyncDir: exact.asyncDir, resultPath: exactResultPath(resultsDir, id), resolvedId: id };
214
+ }
215
+ const resultPath = exactResultPath(resultsDir, id);
216
+ if (resultPath) return { asyncDir: null, resultPath, resolvedId: id };
217
+ const matches = findAsyncRunPrefixMatches(id, asyncDirRoot, resultsDir);
218
+ if (matches.length > 1) throw new Error(`Ambiguous async run id prefix '${id}' matched: ${matches.map((match) => match.id).join(", ")}. Provide a longer id.`);
219
+ return matches[0]?.location ?? { asyncDir: null, resultPath: null, resolvedId: id };
220
+ }
173
221
  const asyncRoot = path.resolve(asyncDirRoot);
174
222
  const resultRoot = path.resolve(resultsDir);
175
223
  const requestedId = assertRunId(params.id, "id") ?? assertRunId(params.runId, "runId");
@@ -234,23 +282,50 @@ function validateResumeSessionFile(runId: string, sessionFile: string): string {
234
282
  }
235
283
 
236
284
  export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncResumeDeps = {}): AsyncResumeTarget {
237
- const asyncDirRoot = deps.asyncDirRoot ?? ASYNC_DIR;
285
+ const asyncDirRoot = deps.asyncDirRoot ?? [ASYNC_DIR, LEGACY_ASYNC_DIR];
238
286
  const resultsDir = deps.resultsDir ?? RESULTS_DIR;
239
287
  const location = resolveAsyncRunLocation(params, asyncDirRoot, resultsDir);
240
288
  if (!location.asyncDir && !location.resultPath) {
241
289
  throw new Error("Async run not found. Provide id or dir.");
242
290
  }
243
291
 
244
- const reconciliation = location.asyncDir
245
- ? reconcileAsyncRun(location.asyncDir, { resultsDir, kill: deps.kill, now: deps.now })
246
- : undefined;
247
- const status = reconciliation?.status ?? null;
292
+ const statusPath = location.asyncDir ? path.join(location.asyncDir, "status.json") : undefined;
293
+ let status: AsyncStatus | null = null;
294
+ if (statusPath) {
295
+ let present = false;
296
+ try {
297
+ fs.lstatSync(statusPath);
298
+ present = true;
299
+ } catch (error) {
300
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
301
+ throw new Error(`Failed to inspect async status file '${statusPath}': ${getErrorMessage(error)}`, { cause: error });
302
+ }
303
+ }
304
+ if (present) {
305
+ let value: unknown;
306
+ try {
307
+ value = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
308
+ } catch (error) {
309
+ throw new Error(`Failed to read or parse async status file '${statusPath}': ${getErrorMessage(error)}`, { cause: error });
310
+ }
311
+ status = ensureObject(value, statusPath) as unknown as AsyncStatus;
312
+ }
313
+ }
314
+ if (status && (status.state === "queued" || status.state === "running") && location.asyncDir && !isLegacyAsyncDir(location.asyncDir)) {
315
+ status = reconcileAsyncRun(location.asyncDir, { resultsDir, kill: deps.kill, now: deps.now }).status ?? status;
316
+ }
248
317
  validateStatusForResume(status, location.asyncDir ? path.join(location.asyncDir, "status.json") : "status.json");
249
318
  const result = location.resultPath ? readResultFile(location.resultPath) : undefined;
319
+ const runtime = parsePiRuntime(status ? status.runtime : result?.runtime, statusPath ?? location.resultPath ?? "Async run");
320
+ if (status && result?.runtime !== undefined) {
321
+ const other = parsePiRuntime(result.runtime, location.resultPath!);
322
+ if (runtime.nodeExecPath !== other.nodeExecPath || runtime.piEntryPath !== other.piEntryPath || runtime.piPackageRoot !== other.piPackageRoot) throw new Error("Async status and result runtime identities conflict; a new run is required.");
323
+ }
250
324
  const runId = status?.runId ?? result?.runId ?? result?.id ?? location.resolvedId ?? (location.asyncDir ? path.basename(location.asyncDir) : "unknown");
251
325
  const state = status?.state ?? (result ? resultState(result) : undefined);
252
326
  if (!state) throw new Error(`Status file not found for async run '${runId}'.`);
253
327
 
328
+ if (state === "queued") throw new Error(`Async run '${runId}' is queued and cannot be revived before startup.`);
254
329
  const statusSteps = status?.steps ?? [];
255
330
  const resultSteps = result?.results ?? [];
256
331
  const stepCount = statusSteps.length || resultSteps.length || (result?.agent ? 1 : 0);
@@ -288,6 +363,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
288
363
 
289
364
  return {
290
365
  kind: "revive",
366
+ runtime,
291
367
  runId,
292
368
  asyncDir: location.asyncDir ?? undefined,
293
369
  state,
@@ -2,9 +2,10 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
4
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
5
- import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
5
+ import { RESULTS_DIR, type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
6
+ import { isLegacyAsyncDir, resolveAsyncRunLocation, winningAsyncRunEntries } from "./async-resume.ts";
6
7
  import { readStatus } from "../../shared/utils.ts";
7
- import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot } from "../shared/nested-events.ts";
8
+ import { attachRootChildrenToSteps, findNestedRouteForRootId, isLegacyNestedRoute, projectNestedRegistryForRoot } from "../shared/nested-events.ts";
8
9
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
9
10
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
10
11
  import { readRunnerLogTail, reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
@@ -133,7 +134,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
133
134
  let nestedChildren: NestedRunSummary[] = [];
134
135
  if (nestedWarnings.length === 0) {
135
136
  try {
136
- nestedChildren = projectNestedRegistryForRoot(status.runId || path.basename(asyncDir))?.children ?? [];
137
+ nestedChildren = projectNestedRegistryForRoot(status.runId || path.basename(asyncDir), { readOnlyLegacy: true })?.children ?? [];
137
138
  } catch (error) {
138
139
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
139
140
  }
@@ -242,10 +243,10 @@ function summarizePreStatusCrash(asyncDir: string, now: number): AsyncRunSummary
242
243
  };
243
244
  }
244
245
 
245
- export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
246
- let entries: string[];
246
+ export function listAsyncRuns(asyncDirRoot: string | readonly string[], options: AsyncRunListOptions = {}): AsyncRunSummary[] {
247
+ let entries: ReturnType<typeof winningAsyncRunEntries>;
247
248
  try {
248
- entries = fs.readdirSync(asyncDirRoot).filter((entry) => isAsyncRunDir(asyncDirRoot, entry));
249
+ entries = winningAsyncRunEntries(asyncDirRoot);
249
250
  } catch (error) {
250
251
  if (isNotFoundError(error)) return [];
251
252
  throw new Error(`Failed to list async runs in '${asyncDirRoot}': ${getErrorMessage(error)}`, {
@@ -256,8 +257,10 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
256
257
  const allowedStates = options.states ? new Set(options.states) : undefined;
257
258
  const runs: AsyncRunSummary[] = [];
258
259
  for (const entry of entries) {
259
- const asyncDir = path.join(asyncDirRoot, entry);
260
- const reconciliation = options.reconcile === false
260
+ const asyncDir = resolveAsyncRunLocation({ id: entry.id }, entry.root, options.resultsDir ?? RESULTS_DIR).asyncDir;
261
+ if (!asyncDir || !isAsyncRunDir(entry.root, entry.id)) continue;
262
+ const stored = readStatus(asyncDir);
263
+ const reconciliation = options.reconcile === false || isLegacyAsyncDir(asyncDir) || (stored && stored.state !== "running" && stored.state !== "queued")
261
264
  ? undefined
262
265
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
263
266
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
@@ -269,7 +272,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
269
272
  const nestedWarnings: string[] = [];
270
273
  try {
271
274
  const nestedRoute = findNestedRouteForRootId(status.runId || path.basename(asyncDir));
272
- if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
275
+ if (nestedRoute && !isLegacyAsyncDir(asyncDir) && !isLegacyNestedRoute(nestedRoute)) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
273
276
  } catch (error) {
274
277
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
275
278
  }