pi-open-agents 0.1.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.
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Subagent executor — spawns and manages child pi processes.
3
+ *
4
+ * This is the core execution engine for subagent delegation.
5
+ * Based on pi-subagents' subagent-executor.ts, adapted for:
6
+ * - Unified AgentDefinition
7
+ * - PI_OPEN_AGENTS_* env vars (renamed from PI_SUBAGENT_*)
8
+ * - Unified thinking defaults (inherits from settings, not hardcoded 'off')
9
+ */
10
+
11
+ import {
12
+ AuthStorage,
13
+ ModelRegistry,
14
+ SessionManager,
15
+ withFileMutationQueue,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import { spawn } from "node:child_process";
18
+ import * as fs from "node:fs/promises";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ import type { AgentDefinition } from "../types.ts";
24
+ import { byteLength, keepTailByBytes, safeFilePart, truncateHeadContent } from "../utils/format.ts";
25
+ import { buildSubagentPrompt } from "./prompt.ts";
26
+ import { buildSubagentEnv, type SubagentEnvVars } from "./env.ts";
27
+
28
+ // ─── Types ───────────────────────────────────────────────────────────────────
29
+
30
+ export interface AgentUsage {
31
+ input: number;
32
+ output: number;
33
+ cacheRead: number;
34
+ cacheWrite: number;
35
+ cost: number;
36
+ contextTokens: number;
37
+ contextWindow?: number;
38
+ }
39
+
40
+ export interface AgentToolLog {
41
+ id: string;
42
+ name: string;
43
+ args: Record<string, unknown>;
44
+ status: "running" | "done";
45
+ nested?: AgentProgress;
46
+ }
47
+
48
+ export type SubagentSessionMode = "none" | "fork";
49
+
50
+ export interface SubagentSessionInfo {
51
+ requested: SubagentSessionMode;
52
+ effective: SubagentSessionMode;
53
+ warning?: string;
54
+ file?: string;
55
+ }
56
+
57
+ export interface AgentProgress {
58
+ agent: string;
59
+ status: "running" | "done" | "error";
60
+ output: string;
61
+ tools: AgentToolLog[];
62
+ usage: AgentUsage;
63
+ startedAt: number;
64
+ elapsedMs: number;
65
+ model?: string;
66
+ session?: SubagentSessionInfo;
67
+ }
68
+
69
+ export interface AgentResult extends AgentProgress {
70
+ isError: boolean;
71
+ exitCode: number;
72
+ stderr: string;
73
+ }
74
+
75
+ export interface PiResolution {
76
+ command: string;
77
+ entryPoint: string;
78
+ }
79
+
80
+ export interface ProcessInvocation {
81
+ command: string;
82
+ args: string[];
83
+ cwd: string;
84
+ env: NodeJS.ProcessEnv;
85
+ }
86
+
87
+ export interface ProcessHandlers {
88
+ stdout(chunk: string): void;
89
+ stderr(chunk: string): void;
90
+ }
91
+
92
+ export type ProcessRunner = (
93
+ invocation: ProcessInvocation,
94
+ handlers: ProcessHandlers,
95
+ signal?: AbortSignal,
96
+ ) => Promise<{ exitCode: number }>;
97
+
98
+ export interface ExecutorFs {
99
+ makeTempDir(prefix: string): Promise<string>;
100
+ writeFile(filePath: string, content: string): Promise<void>;
101
+ removeDir(dir: string): Promise<void>;
102
+ }
103
+
104
+ export interface RunSubagentOptions {
105
+ agent: AgentDefinition;
106
+ task: string;
107
+ cwd: string;
108
+ signal?: AbortSignal;
109
+ onProgress?: (progress: AgentProgress) => void;
110
+ depth?: number;
111
+ availableAgents?: string[];
112
+ tempRoot?: string;
113
+ outputArchiveDir?: string;
114
+ agentDir?: string;
115
+ session?: SubagentSessionInfo;
116
+ resolvePi?: () => Promise<PiResolution> | PiResolution;
117
+ runner?: ProcessRunner;
118
+ fs?: ExecutorFs;
119
+ now?: () => number;
120
+ }
121
+
122
+ // ─── Constants ───────────────────────────────────────────────────────────────
123
+
124
+ const TASK_FILE_THRESHOLD = 8000;
125
+ const OUTPUT_MAX_BYTES = 50 * 1024;
126
+ const OUTPUT_MAX_LINES = 2000;
127
+
128
+ // ─── Default FS ──────────────────────────────────────────────────────────────
129
+
130
+ const defaultExecutorFs: ExecutorFs = {
131
+ makeTempDir(prefix) {
132
+ return fs.mkdtemp(prefix);
133
+ },
134
+ async writeFile(filePath, content) {
135
+ await withFileMutationQueue(filePath, async () => {
136
+ await fs.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 });
137
+ });
138
+ },
139
+ async removeDir(dir) {
140
+ await fs.rm(dir, { recursive: true, force: true });
141
+ },
142
+ };
143
+
144
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
145
+
146
+ function emptyUsage(): AgentUsage {
147
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0 };
148
+ }
149
+
150
+ export function subagentSessionDir(
151
+ cwd: string,
152
+ agentDir = path.join(os.homedir(), ".pi", "agent"),
153
+ ): string {
154
+ const safeProject = `--${path
155
+ .resolve(cwd)
156
+ .replace(/^[/\\]/, "")
157
+ .replace(/[/\\:]/g, "-")}--`;
158
+ return path.join(agentDir, "sessions", safeProject, "subagents");
159
+ }
160
+
161
+ export function resolvePiEntryPoint(): PiResolution {
162
+ const packageEntryPoint = fileURLToPath(
163
+ import.meta.resolve("@earendil-works/pi-coding-agent"),
164
+ );
165
+ const packageRoot = path.dirname(path.dirname(packageEntryPoint));
166
+ return {
167
+ command: process.execPath,
168
+ entryPoint: path.join(packageRoot, "dist", "cli.js"),
169
+ };
170
+ }
171
+
172
+ export const defaultRunner: ProcessRunner = (invocation, handlers, signal) =>
173
+ new Promise((resolve, reject) => {
174
+ const child = spawn(invocation.command, invocation.args, {
175
+ cwd: invocation.cwd,
176
+ env: invocation.env,
177
+ stdio: ["ignore", "pipe", "pipe"],
178
+ });
179
+
180
+ child.stdout.on("data", (chunk) => handlers.stdout(String(chunk)));
181
+ child.stderr.on("data", (chunk) => handlers.stderr(String(chunk)));
182
+ child.on("error", reject);
183
+ child.on("close", (code) => resolve({ exitCode: code ?? 0 }));
184
+
185
+ if (signal) {
186
+ const abort = () => child.kill("SIGTERM");
187
+ if (signal.aborted) abort();
188
+ else signal.addEventListener("abort", abort, { once: true });
189
+ }
190
+ });
191
+
192
+ /**
193
+ * Compute which subagents an agent is allowed to spawn.
194
+ */
195
+ export function availableSubagentsForAgent(
196
+ agent: AgentDefinition,
197
+ candidateNames?: string[],
198
+ ): string[] {
199
+ const names = candidateNames ?? agent.allowedAgents ?? [];
200
+ const allowed = agent.allowedAgents ? new Set(agent.allowedAgents) : undefined;
201
+ const seen = new Set<string>();
202
+
203
+ return names.filter((name) => {
204
+ if (seen.has(name)) return false;
205
+ if (allowed && !allowed.has(name)) return false;
206
+ seen.add(name);
207
+ return true;
208
+ });
209
+ }
210
+
211
+ // ─── JSON Event Parsing ──────────────────────────────────────────────────────
212
+
213
+ type ContextWindowLookup = {
214
+ find: (provider: string, modelId: string) => { contextWindow?: number } | undefined;
215
+ };
216
+
217
+ function textFromMessage(message: unknown): string | undefined {
218
+ if (!message || typeof message !== "object") return undefined;
219
+ const content = (message as { content?: unknown }).content;
220
+ if (!Array.isArray(content)) return undefined;
221
+ for (const part of content) {
222
+ if (part && typeof part === "object" && (part as { type?: unknown }).type === "text") {
223
+ const text = (part as { text?: unknown }).text;
224
+ if (typeof text === "string") return text;
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ function contextWindowFromMessage(
231
+ message: unknown,
232
+ modelRegistry?: ContextWindowLookup,
233
+ ): number | undefined {
234
+ if (!message || typeof message !== "object" || !modelRegistry) return undefined;
235
+ const provider = (message as { provider?: unknown }).provider;
236
+ const model = (message as { model?: unknown }).model;
237
+ if (typeof provider !== "string" || typeof model !== "string") return undefined;
238
+ return modelRegistry.find(provider, model)?.contextWindow;
239
+ }
240
+
241
+ function usageFromMessage(
242
+ message: unknown,
243
+ modelRegistry?: ContextWindowLookup,
244
+ ): Partial<AgentUsage> | undefined {
245
+ if (!message || typeof message !== "object") return undefined;
246
+ const usage = (message as { usage?: unknown }).usage;
247
+ if (!usage || typeof usage !== "object") return undefined;
248
+ const typed = usage as Record<string, unknown>;
249
+ const cost =
250
+ typed.cost && typeof typed.cost === "object"
251
+ ? (typed.cost as Record<string, unknown>)
252
+ : undefined;
253
+
254
+ return {
255
+ input: typeof typed.input === "number" ? typed.input : undefined,
256
+ output: typeof typed.output === "number" ? typed.output : undefined,
257
+ cacheRead: typeof typed.cacheRead === "number" ? typed.cacheRead : undefined,
258
+ cacheWrite: typeof typed.cacheWrite === "number" ? typed.cacheWrite : undefined,
259
+ cost: typeof cost?.total === "number" ? cost.total : undefined,
260
+ contextTokens: typeof typed.totalTokens === "number" ? typed.totalTokens : undefined,
261
+ contextWindow:
262
+ typeof typed.contextWindow === "number"
263
+ ? typed.contextWindow
264
+ : contextWindowFromMessage(message, modelRegistry),
265
+ };
266
+ }
267
+
268
+ function modelFromMessage(message: unknown): string | undefined {
269
+ if (!message || typeof message !== "object") return undefined;
270
+ const model = (message as { model?: unknown }).model;
271
+ return typeof model === "string" ? model : undefined;
272
+ }
273
+
274
+ function updateUsage(target: AgentUsage, update: Partial<AgentUsage> | undefined) {
275
+ if (!update) return;
276
+ target.input += update.input ?? 0;
277
+ target.output += update.output ?? 0;
278
+ target.cacheRead += update.cacheRead ?? 0;
279
+ target.cacheWrite += update.cacheWrite ?? 0;
280
+ target.cost += update.cost ?? 0;
281
+ target.contextTokens = update.contextTokens ?? target.contextTokens;
282
+ target.contextWindow = update.contextWindow ?? target.contextWindow;
283
+ }
284
+
285
+ function replaceUsage(target: AgentUsage, source: AgentUsage) {
286
+ target.input = source.input;
287
+ target.output = source.output;
288
+ target.cacheRead = source.cacheRead;
289
+ target.cacheWrite = source.cacheWrite;
290
+ target.cost = source.cost;
291
+ target.contextTokens = source.contextTokens;
292
+ target.contextWindow = source.contextWindow;
293
+ }
294
+
295
+ function usageFromMessages(
296
+ messages: unknown,
297
+ modelRegistry?: ContextWindowLookup,
298
+ ): AgentUsage | undefined {
299
+ if (!Array.isArray(messages)) return undefined;
300
+ const aggregate = emptyUsage();
301
+ let sawUsage = false;
302
+
303
+ for (const message of messages) {
304
+ if (!message || typeof message !== "object") continue;
305
+ if ((message as { role?: unknown }).role !== "assistant") continue;
306
+ const update = usageFromMessage(message, modelRegistry);
307
+ if (!update) continue;
308
+ sawUsage = true;
309
+ updateUsage(aggregate, update);
310
+ }
311
+
312
+ return sawUsage ? aggregate : undefined;
313
+ }
314
+
315
+ function lastAssistantModel(messages: unknown): string | undefined {
316
+ if (!Array.isArray(messages)) return undefined;
317
+ for (let i = messages.length - 1; i >= 0; i--) {
318
+ const message = messages[i];
319
+ if (!message || typeof message !== "object") continue;
320
+ if ((message as { role?: unknown }).role !== "assistant") continue;
321
+ const model = modelFromMessage(message);
322
+ if (model) return model;
323
+ }
324
+ return undefined;
325
+ }
326
+
327
+ function progressFromPartialResult(partialResult: unknown): AgentProgress | undefined {
328
+ if (!partialResult || typeof partialResult !== "object") return undefined;
329
+ const details = (partialResult as { details?: unknown }).details;
330
+ if (!details || typeof details !== "object") return undefined;
331
+ const agent = (details as { agent?: unknown }).agent;
332
+ const status = (details as { status?: unknown }).status;
333
+ if (typeof agent !== "string") return undefined;
334
+ if (status !== "running" && status !== "done" && status !== "error") return undefined;
335
+ return details as AgentProgress;
336
+ }
337
+
338
+ function buildTaskArgument(task: string, taskFilePath: string | undefined): string {
339
+ return taskFilePath ? `Task: @${taskFilePath}` : `Task: ${task}`;
340
+ }
341
+
342
+ // ─── Main Executor ───────────────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Run a subagent in a child pi process.
346
+ *
347
+ * Steps:
348
+ * 1. Build system prompt (agent body + skills)
349
+ * 2. Build CLI args from AgentDefinition
350
+ * 3. Spawn child pi process with scoped tools, model, thinking
351
+ * 4. Stream JSON events from stdout
352
+ * 5. Render progress via callback
353
+ * 6. Return result
354
+ */
355
+ export async function runSubagent(options: RunSubagentOptions): Promise<AgentResult> {
356
+ const fileSystem = options.fs ?? defaultExecutorFs;
357
+ const resolvePi = options.resolvePi ?? resolvePiEntryPoint;
358
+ const runner = options.runner ?? defaultRunner;
359
+ const now = options.now ?? Date.now;
360
+ const startedAt = now();
361
+ const tempPrefix = path.join(options.tempRoot ?? os.tmpdir(), "pi-open-agents-");
362
+ const tempDir = await fileSystem.makeTempDir(tempPrefix);
363
+
364
+ const usage = emptyUsage();
365
+ const tools: AgentToolLog[] = [];
366
+ let output = "";
367
+ let stderr = "";
368
+ let model = options.agent.model;
369
+ let stdoutBuffer = "";
370
+
371
+ const progress = (status: AgentProgress["status"]): AgentProgress => ({
372
+ agent: options.agent.name,
373
+ status,
374
+ output,
375
+ tools: [...tools],
376
+ usage: { ...usage },
377
+ startedAt,
378
+ elapsedMs: now() - startedAt,
379
+ model,
380
+ session: options.session ?? { requested: "none", effective: "none" },
381
+ });
382
+
383
+ const emit = (status: AgentProgress["status"] = "running") =>
384
+ options.onProgress?.(progress(status));
385
+
386
+ try {
387
+ // ── Build system prompt ──
388
+ const promptFilePath = path.join(tempDir, "system-prompt.md");
389
+ const promptResult = await buildSubagentPrompt({
390
+ agent: options.agent,
391
+ cwd: options.cwd,
392
+ agentDir: options.agentDir,
393
+ });
394
+
395
+ for (const source of promptResult.skills.skippedPackages) {
396
+ console.warn(`[pi-open-agents] package not installed, skipping skills: ${source}`);
397
+ }
398
+ for (const warning of promptResult.skills.warnings) {
399
+ console.warn(`[pi-open-agents] ${warning}`);
400
+ }
401
+ for (const name of promptResult.skills.missing) {
402
+ console.warn(`[pi-open-agents] skill not found: ${name}`);
403
+ }
404
+ await fileSystem.writeFile(promptFilePath, promptResult.prompt);
405
+
406
+ // ── Write task to file if too long ──
407
+ let taskFilePath: string | undefined;
408
+ if (options.task.length > TASK_FILE_THRESHOLD) {
409
+ taskFilePath = path.join(tempDir, "task.md");
410
+ await fileSystem.writeFile(taskFilePath, options.task);
411
+ }
412
+
413
+ // ── Resolve pi CLI entry point ──
414
+ const pi = await resolvePi();
415
+
416
+ // ── Build model registry for cost lookups ──
417
+ const modelRegistry = ModelRegistry.create(
418
+ AuthStorage.create(
419
+ options.agentDir ? path.join(options.agentDir, "auth.json") : undefined,
420
+ ),
421
+ options.agentDir ? path.join(options.agentDir, "models.json") : undefined,
422
+ );
423
+
424
+ // ── Build CLI args ──
425
+ const args = [
426
+ pi.entryPoint,
427
+ "--mode", "json",
428
+ "-p",
429
+ "--no-skills",
430
+ "--no-prompt-templates",
431
+ ];
432
+
433
+ // System prompt mode controls context files
434
+ if (options.agent.systemPrompt === "replace-all") {
435
+ args.push("--no-context-files");
436
+ }
437
+
438
+ // Model override
439
+ if (options.agent.model) {
440
+ args.push("--model", options.agent.model);
441
+ }
442
+
443
+ // Thinking level
444
+ args.push("--thinking", options.agent.thinking);
445
+
446
+ // Tools whitelist
447
+ if (options.agent.tools && options.agent.tools.length > 0) {
448
+ args.push("--tools", options.agent.tools.join(","));
449
+ }
450
+
451
+ // System prompt file (append or replace)
452
+ args.push(
453
+ options.agent.systemPrompt === "append" ? "--append-system-prompt" : "--system-prompt",
454
+ promptFilePath,
455
+ );
456
+
457
+ // Session directory
458
+ args.push("--session-dir", subagentSessionDir(options.cwd, options.agentDir));
459
+
460
+ // Session fork
461
+ if (options.session?.effective === "fork" && options.session.file) {
462
+ args.push("--session", options.session.file);
463
+ }
464
+
465
+ // Task
466
+ args.push(buildTaskArgument(options.task, taskFilePath));
467
+
468
+ // ── Build env vars ──
469
+ const envVars: SubagentEnvVars = {
470
+ depth: options.depth ?? 1,
471
+ maxDepth: options.agent.maxDepth,
472
+ agentName: options.agent.name,
473
+ systemPromptMode: options.agent.systemPrompt,
474
+ session: options.session?.effective ?? "none",
475
+ allowedAgents: availableSubagentsForAgent(options.agent, options.availableAgents),
476
+ debug: false,
477
+ };
478
+ const childEnv: NodeJS.ProcessEnv = {
479
+ ...process.env,
480
+ ...buildSubagentEnv(envVars),
481
+ };
482
+
483
+ // ── Process JSON events from stdout ──
484
+ const processLine = (line: string) => {
485
+ if (!line.trim()) return;
486
+ let event: Record<string, unknown>;
487
+ try {
488
+ event = JSON.parse(line) as Record<string, unknown>;
489
+ } catch {
490
+ return;
491
+ }
492
+
493
+ if (event.type === "tool_execution_start") {
494
+ tools.push({
495
+ id: String(event.toolCallId ?? tools.length),
496
+ name: String(event.toolName ?? "tool"),
497
+ args:
498
+ event.args && typeof event.args === "object"
499
+ ? (event.args as Record<string, unknown>)
500
+ : {},
501
+ status: "running",
502
+ });
503
+ emit();
504
+ return;
505
+ }
506
+
507
+ if (event.type === "tool_execution_update") {
508
+ const id = String(event.toolCallId ?? "");
509
+ const tool = tools.find((item) => item.id === id);
510
+ const nested = progressFromPartialResult(event.partialResult);
511
+ if (tool && nested) tool.nested = nested;
512
+ emit();
513
+ return;
514
+ }
515
+
516
+ if (event.type === "tool_execution_end") {
517
+ const id = String(event.toolCallId ?? "");
518
+ const tool = tools.find((item) => item.id === id);
519
+ if (tool) tool.status = "done";
520
+ emit();
521
+ return;
522
+ }
523
+
524
+ if (event.type === "message_end" && event.message) {
525
+ const text = textFromMessage(event.message);
526
+ if (text !== undefined) output = text;
527
+ updateUsage(usage, usageFromMessage(event.message, modelRegistry));
528
+ model = modelFromMessage(event.message) ?? model;
529
+ emit();
530
+ return;
531
+ }
532
+
533
+ if (event.type === "agent_end") {
534
+ const aggregate = usageFromMessages(event.messages, modelRegistry);
535
+ if (aggregate) replaceUsage(usage, aggregate);
536
+ model = lastAssistantModel(event.messages) ?? model;
537
+ emit();
538
+ }
539
+ };
540
+
541
+ // ── Run the child process ──
542
+ const exit = await runner(
543
+ { command: pi.command, args, cwd: options.cwd, env: childEnv },
544
+ {
545
+ stdout(chunk) {
546
+ stdoutBuffer += chunk;
547
+ const lines = stdoutBuffer.split("\n");
548
+ stdoutBuffer = lines.pop() ?? "";
549
+ for (const line of lines) processLine(line);
550
+ },
551
+ stderr(chunk) {
552
+ stderr += chunk;
553
+ },
554
+ },
555
+ options.signal,
556
+ );
557
+
558
+ // Process any remaining buffered output
559
+ if (stdoutBuffer.trim()) processLine(stdoutBuffer);
560
+
561
+ const isError = exit.exitCode !== 0;
562
+ if (isError && !output) {
563
+ output = stderr || `Subagent exited with code ${exit.exitCode}`;
564
+ }
565
+
566
+ // ── Truncate large output ──
567
+ const truncated = truncateHeadContent(output, OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES);
568
+ if (truncated !== undefined) {
569
+ const originalOutput = output;
570
+ const fullOutputPath = path.join(
571
+ options.outputArchiveDir ?? os.tmpdir(),
572
+ `${safeFilePart(options.agent.name)}-${startedAt}-output.md`,
573
+ );
574
+ await fileSystem.writeFile(fullOutputPath, originalOutput);
575
+ output = `${truncated}\n\n[Output truncated: original ${originalOutput.split("\n").length} lines / ${byteLength(originalOutput)} bytes. Full output: ${fullOutputPath}]`;
576
+ }
577
+
578
+ const result: AgentResult = {
579
+ ...progress(isError ? "error" : "done"),
580
+ isError,
581
+ exitCode: exit.exitCode,
582
+ stderr,
583
+ };
584
+ emit(isError ? "error" : "done");
585
+ return result;
586
+ } catch (error) {
587
+ output = error instanceof Error ? error.message : String(error);
588
+ return {
589
+ ...progress("error"),
590
+ isError: true,
591
+ exitCode: 1,
592
+ stderr,
593
+ };
594
+ } finally {
595
+ await fileSystem.removeDir(tempDir);
596
+ }
597
+ }
598
+
599
+ // ─── Session Resolution ──────────────────────────────────────────────────────
600
+
601
+ import { existsSync } from "node:fs";
602
+
603
+ function freshSessionInfo(
604
+ requested: SubagentSessionMode,
605
+ warning?: string,
606
+ ): SubagentSessionInfo {
607
+ return {
608
+ requested,
609
+ effective: "none",
610
+ ...(warning ? { warning } : {}),
611
+ };
612
+ }
613
+
614
+ /**
615
+ * Resolve the session mode for a subagent.
616
+ *
617
+ * Handles graceful degradation from 'fork' to 'none' when prerequisites
618
+ * (same cwd, persisted session, leaf ID) are not met.
619
+ */
620
+ export function resolveSubagentSession(
621
+ requested: SubagentSessionMode | undefined,
622
+ childCwd: string,
623
+ parentSessionFile: string | undefined,
624
+ parentLeafId: string | null | undefined,
625
+ agentDir?: string,
626
+ ): SubagentSessionInfo {
627
+ const mode = requested ?? "none";
628
+ if (mode === "none") return freshSessionInfo(mode);
629
+
630
+ if (path.resolve(childCwd) !== path.resolve(process.cwd())) {
631
+ return freshSessionInfo(
632
+ mode,
633
+ "Requested fork session but cwd differs from the parent session; running with a fresh subagent session instead.",
634
+ );
635
+ }
636
+
637
+ if (!parentSessionFile) {
638
+ return freshSessionInfo(
639
+ mode,
640
+ "Requested fork session but the parent session is not persisted; running with a fresh subagent session instead.",
641
+ );
642
+ }
643
+
644
+ if (!existsSync(parentSessionFile)) {
645
+ return freshSessionInfo(
646
+ mode,
647
+ "Requested fork session but the parent session file was not materialized; running with a fresh subagent session instead.",
648
+ );
649
+ }
650
+
651
+ if (!parentLeafId) {
652
+ return freshSessionInfo(
653
+ mode,
654
+ "Requested fork session but the parent session has no current leaf; running with a fresh subagent session instead.",
655
+ );
656
+ }
657
+
658
+ try {
659
+ const forkSource = SessionManager.open(
660
+ parentSessionFile,
661
+ subagentSessionDir(childCwd, agentDir),
662
+ childCwd,
663
+ );
664
+ const sessionFile = forkSource.createBranchedSession(parentLeafId);
665
+ if (!sessionFile || !existsSync(sessionFile)) {
666
+ return freshSessionInfo(
667
+ mode,
668
+ "Requested fork session but the forked session file was not materialized; running with a fresh subagent session instead.",
669
+ );
670
+ }
671
+ return { requested: mode, effective: "fork", file: sessionFile };
672
+ } catch (error) {
673
+ const message = error instanceof Error ? error.message : String(error);
674
+ return freshSessionInfo(
675
+ mode,
676
+ `Requested fork session but creating the fork failed (${message}); running with a fresh subagent session instead.`,
677
+ );
678
+ }
679
+ }