pi-cohort 7.1.2 → 7.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.2.0] - 2026-10-11
4
+
5
+ ### Changed
6
+
7
+ - Children and detached coordinators launch with the originating parent's Node/Pi paths instead of `PATH`; revival keeps that runtime across parent installations. Run records and nested state use durable profile storage without automatic age expiry. Legacy records remain inspectable in their original paths, but revival requires a recorded original runtime and an available session file; missing installations require restoration or a new run. (#19)
8
+
3
9
  ## [7.1.2] - 2026-09-29
4
10
 
5
11
  ### Fixed
package/README.md CHANGED
@@ -39,6 +39,8 @@ pi install npm:pi-cohort
39
39
 
40
40
  That is the only required step.
41
41
 
42
+ Children and detached coordinators use the originating parent's resolved Node executable and Pi entry path directly, bypassing `PATH`. Revival keeps that original runtime even when requested from a different parent installation. Custom hosts whose entry cannot be identified as the installed Pi package's declared CLI are unsupported. These paths are not a version lock: replacing Node or Pi in place changes what runs. If the original installation is removed, restore it at the recorded paths or start a new run; there is no fallback to the current parent's installation. Legacy records without an original runtime identity remain inspectable but cannot be revived - start a new run.
43
+
42
44
  ## Execution backend extension API
43
45
 
44
46
  External execution backends are currently unsupported on Windows, for both foreground and background runs. Native execution remains supported.
@@ -155,6 +157,10 @@ Full reference: agent/chain authoring in [doc/agents-and-chains.md](doc/agents-a
155
157
 
156
158
  Most installs need zero configuration. Full env/JSON reference: [doc/configuration.md](doc/configuration.md).
157
159
 
160
+ Run history lives under the pinned profile's `pi-cohort/` root and has no automatic age expiry. Status listings can grow without bound as history accumulates. To remove history manually, delete only a terminal top-level run directory after confirming its entire subtree has stopped; never delete queued or running state, individual nested records, or shared route files. This removes that directory's status and resume lookup, not separately stored session files or shared routes. Keep those sessions if you need their context; deleting a run directory is not a complete session purge. Storage details: [doc/observability.md](doc/observability.md#files-logs-and-observability).
161
+
162
+ A queued start that never publishes running status stays unresolved: pi-cohort does not automatically recover or revive it. Start a new run instead.
163
+
158
164
  Parent extension CLI flags (e.g. pi-lens's `--no-autofix`) are forwarded into spawned children by default, so subagents inherit the same tooling behavior you launched with; toggle with [`forwardParentFlags`](doc/configuration.md#forwardparentflags).
159
165
 
160
166
  Optional companions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cohort",
3
- "version": "7.1.2",
3
+ "version": "7.2.0",
4
4
  "description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -40,8 +40,8 @@
40
40
  "scripts": {
41
41
  "check:agents-core": "node scripts/check-agents-core.mjs",
42
42
  "test": "npm run test:unit",
43
- "test:unit": "node --test test/unit/*.test.ts",
44
- "test:integration": "node --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
43
+ "test:unit": "node --import ./test/support/isolate-test-env.mjs --test test/unit/*.test.ts",
44
+ "test:integration": "node --import ./test/support/isolate-test-env.mjs --import ./test/support/register-loader.mjs --test test/integration/*.test.ts",
45
45
  "test:all": "npm run check:agents-core && npm run test:unit && npm run test:integration"
46
46
  },
47
47
  "pi": {
@@ -7,18 +7,20 @@ import { ChildHostFrameDecoder, encodeChildHostMessage, type ChildHostMessage }
7
7
  import { PI_COHORT_CHILD_HOST_CONFIG } from "./child-host-runtime.ts";
8
8
  import { ensureJitiCliPath } from "../runs/shared/jiti-cli.ts";
9
9
  import type { ExecutionBackend, ExecutionBackendLease } from "./types.ts";
10
+ import { getPiSpawnCommand, PiRuntimeLaunchError, type PiRuntimeIdentity } from "../runs/shared/pi-spawn.ts";
10
11
 
11
12
  export interface ChildHostAttempt { readonly attemptId: string; readonly command: string; readonly args: readonly string[]; readonly cwd: string; readonly environment: Readonly<Record<string, string>>; }
12
13
  export interface ChildHostExit { readonly status: number | null; readonly signal: string | null; }
13
14
  export interface ChildHostController { readonly lease: ExecutionBackendLease; readonly ready: Promise<void>; startAttempt(attempt: ChildHostAttempt): Promise<ChildHostExit>; shutdown(): Promise<void>; releaseObserver(): Promise<void>; }
14
- export interface ChildHostControllerOptions { readonly backend: ExecutionBackend; readonly runId: string; readonly childId: string; readonly cwd: string; readonly title?: string; readonly signal: AbortSignal; }
15
- export interface ChildHostControllerDependencies { readonly fs?: typeof fs; readonly ensureJiti?: () => string | undefined; readonly childHostCommand?: () => { command: string; args: readonly string[] }; readonly platform?: NodeJS.Platform; readonly createServer?: typeof net.createServer; }
15
+ export interface ChildHostControllerOptions { readonly runtime: PiRuntimeIdentity; readonly backend: ExecutionBackend; readonly runId: string; readonly childId: string; readonly cwd: string; readonly title?: string; readonly signal: AbortSignal; }
16
+ export interface ChildHostControllerDependencies { readonly fs?: typeof fs; readonly ensureJiti?: (root: string) => string | undefined; readonly childHostCommand?: () => { command: string; args: readonly string[] }; readonly platform?: NodeJS.Platform; readonly createServer?: typeof net.createServer; }
16
17
 
17
18
  const HOST_ENVIRONMENT_KEYS = ["PATH", "HOME", "TMPDIR", "SystemRoot"] as const;
18
- function childHostCommand(ensureJiti: () => string | undefined): { command: string; args: readonly string[] } {
19
- const jiti = ensureJiti();
20
- if (!jiti) throw new Error("upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed");
21
- return { command: process.execPath, args: [jiti, path.resolve(path.dirname(fileURLToPath(import.meta.url)), "child-host-runtime.ts")] };
19
+ function childHostCommand(runtime: PiRuntimeIdentity, ensureJiti: (root: string) => string | undefined): { command: string; args: readonly string[] } {
20
+ getPiSpawnCommand([], runtime);
21
+ const jiti = ensureJiti(runtime.piPackageRoot);
22
+ if (!jiti) throw new PiRuntimeLaunchError(`jiti for selected Pi installation ${runtime.piPackageRoot} could not be found`);
23
+ return { command: runtime.nodeExecPath, args: [jiti, path.resolve(path.dirname(fileURLToPath(import.meta.url)), "child-host-runtime.ts")] };
22
24
  }
23
25
  function disconnected(phase: string): Error { return new Error(`child host disconnected ${phase}`); }
24
26
  function aborted(): Error { return new Error("child host launch aborted"); }
@@ -90,7 +92,7 @@ export async function createChildHostController(options: ChildHostControllerOpti
90
92
  }
91
93
  if (message.kind === "attempt_started" && active?.attemptId === message.attemptId && !active.started) { active.started = true; continue; }
92
94
  if (message.kind === "attempt_spawn_error" && active?.attemptId === message.attemptId) {
93
- const pending = active; active = undefined; pending.reject(new Error("child host attempt spawn error")); continue;
95
+ const pending = active; active = undefined; pending.reject(new PiRuntimeLaunchError(`child host attempt spawn error for ${options.runtime.nodeExecPath} ${options.runtime.piEntryPath}`)); continue;
94
96
  }
95
97
  if (message.kind === "attempt_exited" && active?.attemptId === message.attemptId && active.started) {
96
98
  const pending = active; active = undefined; pending.resolve({ status: message.status, signal: message.signal }); continue;
@@ -115,9 +117,9 @@ export async function createChildHostController(options: ChildHostControllerOpti
115
117
  await new Promise<void>((resolve, reject) => server!.listen(socketPath!, () => resolve()).once("error", reject));
116
118
  fileSystem.chmodSync(socketPath, 0o600);
117
119
  if (options.signal.aborted) throw aborted();
118
- const host = dependencies.childHostCommand?.() ?? childHostCommand(dependencies.ensureJiti ?? ensureJitiCliPath);
119
120
  const environment: Record<string, string> = { [PI_COHORT_CHILD_HOST_CONFIG]: configPath };
120
121
  for (const key of HOST_ENVIRONMENT_KEYS) if (process.env[key]) environment[key] = process.env[key]!;
122
+ const host = dependencies.childHostCommand?.() ?? childHostCommand(options.runtime, dependencies.ensureJiti ?? ensureJitiCliPath);
121
123
  lease = await options.backend.launch({
122
124
  command: host.command,
123
125
  args: host.args,
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { discoverAgentsAll, type AgentSource } from "../agents/agents.ts";
4
+ import type { PiRuntimeIdentity } from "../runs/shared/pi-spawn.ts";
4
5
  import { isAsyncAvailable } from "../runs/background/async-execution.ts";
5
6
  import { discoverAvailableSkills, type SkillSource } from "../agents/skills.ts";
6
7
  import {
@@ -20,12 +21,14 @@ interface DoctorPaths {
20
21
  }
21
22
 
22
23
  interface DoctorDeps {
23
- isAsyncAvailable: () => boolean;
24
+ isAsyncAvailable: (runtime: PiRuntimeIdentity) => boolean;
24
25
  discoverAgentsAll: typeof discoverAgentsAll;
25
26
  discoverAvailableSkills: typeof discoverAvailableSkills;
26
27
  }
27
28
 
28
29
  interface DoctorReportInput {
30
+ runtime?: PiRuntimeIdentity;
31
+ runtimeError?: string;
29
32
  cwd: string;
30
33
  config: ExtensionConfig;
31
34
  state: SubagentState;
@@ -189,7 +192,8 @@ export function buildDoctorReport(input: DoctorReportInput): string {
189
192
  "",
190
193
  "Runtime",
191
194
  `- cwd: ${input.cwd}`,
192
- lineFromCheck("async support", () => `- async support: ${deps.isAsyncAvailable() ? "available" : "unavailable"}`),
195
+ ...(input.runtimeError ? [`- runtime capture: failed - ${input.runtimeError}`] : input.runtime ? [] : ["- runtime capture: not available"]),
196
+ lineFromCheck("async support", () => `- async support: ${input.runtime && deps.isAsyncAvailable(input.runtime) ? "available" : "unavailable"}`),
193
197
  ...formatSessionLines(input),
194
198
  "",
195
199
  "Filesystem",
@@ -10,7 +10,7 @@ import { readNestedControlRequests, resolveNestedRouteFromEnv, writeNestedContro
10
10
  import { SubagentParams } from "./schemas.ts";
11
11
  import { loadConfig } from "./config.ts";
12
12
  import { deriveForwardedFlags } from "../runs/shared/forward-flags.ts";
13
- import type { Details, SubagentState } from "../shared/types.ts";
13
+ import { RUN_STATE_ROOT_DIR, type Details, type SubagentState } from "../shared/types.ts";
14
14
  import { emptyGrandTotal } from "./grand-total.ts";
15
15
 
16
16
  function getSubagentSessionRoot(parentSessionFile: string | null): string {
@@ -19,7 +19,7 @@ function getSubagentSessionRoot(parentSessionFile: string | null): string {
19
19
  const sessionsDir = path.dirname(parentSessionFile);
20
20
  return path.join(sessionsDir, baseName);
21
21
  }
22
- return fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
22
+ return path.join(RUN_STATE_ROOT_DIR, "sessions");
23
23
  }
24
24
 
25
25
  function expandTilde(p: string): string {
@@ -43,6 +43,7 @@ import {
43
43
  type Details,
44
44
  type SubagentState,
45
45
  ASYNC_DIR,
46
+ RUN_STATE_ROOT_DIR,
46
47
  DEFAULT_ARTIFACT_CONFIG,
47
48
  GRAND_TOTAL_STATUS_KEY,
48
49
  RESULTS_DIR,
@@ -68,7 +69,7 @@ export { loadConfig } from "./config.ts";
68
69
  * If parent session is ~/.pi/agent/sessions/abc123.jsonl,
69
70
  * returns ~/.pi/agent/sessions/abc123/ as the base.
70
71
  * Callers add runId to create the actual session root: abc123/{runId}/
71
- * Falls back to a unique temp directory if no parent session.
72
+ * Without a parent, uses the durable sessions base; callers append the run UUID.
72
73
  */
73
74
  function getSubagentSessionRoot(parentSessionFile: string | null): string {
74
75
  if (parentSessionFile) {
@@ -76,19 +77,19 @@ function getSubagentSessionRoot(parentSessionFile: string | null): string {
76
77
  const sessionsDir = path.dirname(parentSessionFile);
77
78
  return path.join(sessionsDir, baseName);
78
79
  }
79
- return fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
80
+ return path.join(RUN_STATE_ROOT_DIR, "sessions");
80
81
  }
81
82
 
82
83
  function expandTilde(p: string): string {
83
84
  return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
84
85
  }
85
86
 
86
- /** Create a directory and verify it is actually accessible. */
87
- function ensureAccessibleDir(dirPath: string): void {
87
+ function ensureAccessibleDir(dirPath: string, durable = false): void {
88
88
  mkdirWithEpermRetry(dirPath);
89
89
  try {
90
90
  fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
91
- } catch {
91
+ } catch (error) {
92
+ if (durable) throw error;
92
93
  try {
93
94
  fs.rmSync(dirPath, { recursive: true, force: true });
94
95
  } catch {
@@ -255,8 +256,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
255
256
  }
256
257
  }
257
258
 
259
+ ensureAccessibleDir(RUN_STATE_ROOT_DIR, true);
258
260
  ensureAccessibleDir(RESULTS_DIR);
259
- ensureAccessibleDir(ASYNC_DIR);
261
+ ensureAccessibleDir(ASYNC_DIR, true);
260
262
  cleanupOldChainDirs();
261
263
 
262
264
  const config = loadConfig();
@@ -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,