pi-subagents 0.11.3 → 0.11.4

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
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.4] - 2026-03-19
6
+
7
+ ### Added
8
+ - Added explicit execution context mode for tool calls: `context: "fresh" | "fork"` (default: `fresh`).
9
+ - Added true forked-context execution for single, parallel, and chain runs. In `fork` mode each child run now starts from a real branched session file created from the parent session's current leaf.
10
+ - Added `--fork` slash-command flag for `/run`, `/chain`, and `/parallel` to forward `context: "fork"`.
11
+ - Added regression coverage for fork execution/session wiring and fork badge rendering, including slash command forwarding tests.
12
+
13
+ ### Changed
14
+ - Session argument wiring now supports `--session <file>` in addition to `--session-dir`, enabling exact leaf-preserving forks without summary injection.
15
+ - Async runner step payloads now carry per-step session files so background single/chain/parallel executions can also honor `context: "fork"`.
16
+ - Clarified docs for foreground vs background semantics so `--bg` behavior is explicit.
17
+
18
+ ### Fixed
19
+ - `context: "fork"` now fails fast with explicit errors when parent session state is unavailable (missing persisted session, missing current leaf, or failed branch extraction), with no silent fallback to `fresh`.
20
+ - Fork-session creation errors are now surfaced as tool errors instead of bubbling as uncaught exceptions during execution.
21
+ - Session directory preparation now fails loudly with actionable errors (instead of silently swallowing mkdir failures).
22
+ - Async launch now fails with explicit errors when the async run directory cannot be created.
23
+ - Share logs now correctly include forked session files even when no session directory exists.
24
+ - Tool-call and result rendering now explicitly show `[fork]` when `context: "fork"` is used, including empty-result responses.
25
+ - `subagent_status` now surfaces async result-file read failures instead of returning a misleading missing-status message.
26
+
5
27
  ## [0.11.3] - 2026-03-17
6
28
 
7
29
  ### Changed
package/README.md CHANGED
@@ -172,7 +172,24 @@ Add `--bg` at the end of any slash command to run in the background:
172
172
  /parallel scout "scan frontend" -> scout "scan backend" -> scout "scan infra" --bg
173
173
  ```
174
174
 
175
- Background tasks run asynchronously and notify you when complete. Check status with `subagent_status`.
175
+ Without `--bg`, the run is foreground: the tool call stays active and streams progress until completion. With `--bg`, the run is launched asynchronously: control returns immediately, and completion arrives later via notification. In both cases subagents run as separate processes. Check status with `subagent_status`.
176
+
177
+ ### Forked Context Execution
178
+
179
+ Add `--fork` at the end of `/run`, `/chain`, or `/parallel` to run with `context: "fork"`:
180
+
181
+ ```
182
+ /run reviewer "review this diff" --fork
183
+ /chain scout "analyze this branch" -> planner "plan next steps" --fork
184
+ /parallel scout "audit frontend" -> reviewer "audit backend" --fork
185
+ ```
186
+
187
+ You can combine `--fork` and `--bg` in any order:
188
+
189
+ ```
190
+ /run reviewer "review this diff" --fork --bg
191
+ /run reviewer "review this diff" --bg --fork
192
+ ```
176
193
 
177
194
  ## Agents Manager
178
195
 
@@ -285,7 +302,9 @@ Chains can be created from the Agents Manager template picker ("Blank Chain"), o
285
302
  | Chain | Yes | `{ chain: [{agent, task}...] }` with `{task}`, `{previous}`, `{chain_dir}` variables |
286
303
  | Parallel | Yes | `{ tasks: [{agent, task}...] }` - via TUI toggle or converted to chain for async |
287
304
 
288
- All modes support background/async execution. For programmatic async, use `clarify: false, async: true`. For interactive async, use `clarify: true` and press `b` in the TUI to toggle background mode before running. Chains with parallel steps (`{ parallel: [...] }`) run concurrently with configurable `concurrency` and `failFast` options.
305
+ Execution context defaults to `context: "fresh"`, which starts each child run from a clean session. Set `context: "fork"` to start each child from a real branched session created from the parent's current leaf.
306
+
307
+ All modes support foreground and background execution. Foreground is the default (the call waits and streams progress). For programmatic background launch, use `clarify: false, async: true`. For interactive background launch, use `clarify: true` and press `b` in the TUI before running. Chains with parallel steps (`{ parallel: [...] }`) run concurrently with configurable `concurrency` and `failFast` options.
289
308
 
290
309
  **Clarify TUI for single/parallel:**
291
310
 
@@ -397,9 +416,15 @@ Skills are specialized instructions loaded from SKILL.md files and injected into
397
416
  { agent: "scout", task: "find todos", maxOutput: { lines: 1000 } }
398
417
  { agent: "scout", task: "investigate", output: false } // disable file output
399
418
 
400
- // Parallel (sync only)
419
+ // Single agent from parent-session fork (real branched session at current leaf)
420
+ { agent: "worker", task: "continue this thread", context: "fork" }
421
+
422
+ // Parallel
401
423
  { tasks: [{ agent: "scout", task: "a" }, { agent: "scout", task: "b" }] }
402
424
 
425
+ // Parallel with forked context (each task gets its own isolated fork)
426
+ { tasks: [{ agent: "scout", task: "audit frontend" }, { agent: "reviewer", task: "audit backend" }], context: "fork" }
427
+
403
428
  // Chain with TUI clarification (default)
404
429
  { chain: [
405
430
  { agent: "scout", task: "Gather context for auth refactor" },
@@ -408,6 +433,12 @@ Skills are specialized instructions loaded from SKILL.md files and injected into
408
433
  { agent: "reviewer" }
409
434
  ]}
410
435
 
436
+ // Chain with forked context (each step gets its own isolated fork of the same parent leaf)
437
+ { chain: [
438
+ { agent: "scout", task: "Analyze current branch decisions" },
439
+ { agent: "planner", task: "Plan from {previous}" }
440
+ ], context: "fork" }
441
+
411
442
  // Chain without TUI (enables async)
412
443
  { chain: [...], clarify: false, async: true }
413
444
 
@@ -529,6 +560,7 @@ Notes:
529
560
  | `model` | string | agent default | Override model for single agent |
530
561
  | `tasks` | `{agent, task, cwd?, skill?}[]` | - | Parallel tasks (sync only) |
531
562
  | `chain` | ChainItem[] | - | Sequential steps with behavior overrides (see below) |
563
+ | `context` | `"fresh" \| "fork"` | `fresh` | Execution context mode. `fork` uses a real branched session from the parent's current leaf for each child run |
532
564
  | `chainDir` | string | `<tmpdir>/pi-chain-runs/` | Persistent directory for chain artifacts (default auto-cleaned after 24h) |
533
565
  | `clarify` | boolean | true (chains) | Show TUI to preview/edit chain; implies sync mode |
534
566
  | `agentScope` | `"user" \| "project" \| "both"` | `both` | Agent discovery scope (project wins on name collisions) |
@@ -540,6 +572,8 @@ Notes:
540
572
  | `share` | boolean | false | Upload session to GitHub Gist (see [Session Sharing](#session-sharing)) |
541
573
  | `sessionDir` | string | - | Override session log directory (takes precedence over `defaultSessionDir` and parent-session-derived path) |
542
574
 
575
+ `context: "fork"` fails fast when the parent session is not persisted, the current leaf is missing, or a branched child session cannot be created. It never silently downgrades to `fresh`.
576
+
543
577
  **ChainItem** can be either a sequential step or a parallel step:
544
578
 
545
579
  *Sequential step fields:*
@@ -651,6 +685,8 @@ Files per task:
651
685
 
652
686
  Session files (JSONL) are stored under a per-run session directory. Directory selection follows the same precedence as session root resolution: explicit `sessionDir` > `config.defaultSessionDir` > parent-session-derived path. The session file path is shown in output.
653
687
 
688
+ When `context: "fork"` is used, each child run starts with `--session <branched-session-file>` produced from the parent's current leaf. This is a real session fork, not injected summary text.
689
+
654
690
  ## Session Sharing
655
691
 
656
692
  When `share: true` is passed, the extension will:
@@ -12,7 +12,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
12
  import type { AgentConfig } from "./agents.js";
13
13
  import { applyThinkingSuffix } from "./pi-args.js";
14
14
  import { injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.js";
15
- import { isParallelStep, resolveStepBehavior, type ChainStep, type ParallelStep, type SequentialStep, type StepOverrides } from "./settings.js";
15
+ import { isParallelStep, resolveStepBehavior, type ChainStep, type SequentialStep, type StepOverrides } from "./settings.js";
16
16
  import type { RunnerStep } from "./parallel-utils.js";
17
17
  import { resolvePiPackageRoot } from "./pi-spawn.js";
18
18
  import { buildSkillInjection, normalizeSkillInput, resolveSkills } from "./skills.js";
@@ -40,7 +40,9 @@ const jitiCliPath: string | undefined = (() => {
40
40
  try {
41
41
  const p = candidate();
42
42
  if (fs.existsSync(p)) return p;
43
- } catch {}
43
+ } catch {
44
+ // Candidate not available in this install, continue probing.
45
+ }
44
46
  }
45
47
  return undefined;
46
48
  })();
@@ -62,6 +64,7 @@ export interface AsyncChainParams {
62
64
  shareEnabled: boolean;
63
65
  sessionRoot?: string;
64
66
  chainSkills?: string[];
67
+ sessionFilesByFlatIndex?: (string | undefined)[];
65
68
  }
66
69
 
67
70
  export interface AsyncSingleParams {
@@ -75,6 +78,7 @@ export interface AsyncSingleParams {
75
78
  artifactConfig: ArtifactConfig;
76
79
  shareEnabled: boolean;
77
80
  sessionRoot?: string;
81
+ sessionFile?: string;
78
82
  skills?: string[];
79
83
  output?: string | false;
80
84
  }
@@ -119,7 +123,18 @@ export function executeAsyncChain(
119
123
  id: string,
120
124
  params: AsyncChainParams,
121
125
  ): AsyncExecutionResult {
122
- const { chain, agents, ctx, cwd, maxOutput, artifactsDir, artifactConfig, shareEnabled, sessionRoot } = params;
126
+ const {
127
+ chain,
128
+ agents,
129
+ ctx,
130
+ cwd,
131
+ maxOutput,
132
+ artifactsDir,
133
+ artifactConfig,
134
+ shareEnabled,
135
+ sessionRoot,
136
+ sessionFilesByFlatIndex,
137
+ } = params;
123
138
  const chainSkills = params.chainSkills ?? [];
124
139
 
125
140
  // Validate all agents exist before building steps
@@ -141,10 +156,17 @@ export function executeAsyncChain(
141
156
  const asyncDir = path.join(ASYNC_DIR, id);
142
157
  try {
143
158
  fs.mkdirSync(asyncDir, { recursive: true });
144
- } catch {}
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ return {
162
+ content: [{ type: "text", text: `Failed to create async run directory '${asyncDir}': ${message}` }],
163
+ isError: true,
164
+ details: { mode: "chain" as const, results: [] },
165
+ };
166
+ }
145
167
 
146
168
  /** Build a resolved runner step from a SequentialStep */
147
- const buildSeqStep = (s: SequentialStep) => {
169
+ const buildSeqStep = (s: SequentialStep, sessionFile?: string) => {
148
170
  const a = agents.find((x) => x.name === s.agent)!;
149
171
  const stepSkillInput = normalizeSkillInput(s.skill);
150
172
  const stepOverrides: StepOverrides = { skills: stepSkillInput };
@@ -174,9 +196,17 @@ export function executeAsyncChain(
174
196
  systemPrompt,
175
197
  skills: resolvedSkills.map((r) => r.name),
176
198
  outputPath,
199
+ sessionFile,
177
200
  };
178
201
  };
179
202
 
203
+ let flatStepIndex = 0;
204
+ const nextSessionFile = (): string | undefined => {
205
+ const sessionFile = sessionFilesByFlatIndex?.[flatStepIndex];
206
+ flatStepIndex++;
207
+ return sessionFile;
208
+ };
209
+
180
210
  // Build runner steps — sequential steps become flat objects,
181
211
  // parallel steps become { parallel: [...], concurrency?, failFast? }
182
212
  const steps: RunnerStep[] = chain.map((s) => {
@@ -189,12 +219,12 @@ export function executeAsyncChain(
189
219
  skill: t.skill,
190
220
  model: t.model,
191
221
  output: t.output,
192
- })),
222
+ }, nextSessionFile())),
193
223
  concurrency: s.concurrency,
194
224
  failFast: s.failFast,
195
225
  };
196
226
  }
197
- return buildSeqStep(s as SequentialStep);
227
+ return buildSeqStep(s as SequentialStep, nextSessionFile());
198
228
  });
199
229
 
200
230
  const runnerCwd = cwd ?? ctx.cwd;
@@ -258,7 +288,19 @@ export function executeAsyncSingle(
258
288
  id: string,
259
289
  params: AsyncSingleParams,
260
290
  ): AsyncExecutionResult {
261
- const { agent, task, agentConfig, ctx, cwd, maxOutput, artifactsDir, artifactConfig, shareEnabled, sessionRoot } = params;
291
+ const {
292
+ agent,
293
+ task,
294
+ agentConfig,
295
+ ctx,
296
+ cwd,
297
+ maxOutput,
298
+ artifactsDir,
299
+ artifactConfig,
300
+ shareEnabled,
301
+ sessionRoot,
302
+ sessionFile,
303
+ } = params;
262
304
  const skillNames = params.skills ?? agentConfig.skills ?? [];
263
305
  const { resolved: resolvedSkills } = resolveSkills(skillNames, ctx.cwd);
264
306
  let systemPrompt = agentConfig.systemPrompt?.trim() || null;
@@ -270,7 +312,14 @@ export function executeAsyncSingle(
270
312
  const asyncDir = path.join(ASYNC_DIR, id);
271
313
  try {
272
314
  fs.mkdirSync(asyncDir, { recursive: true });
273
- } catch {}
315
+ } catch (error) {
316
+ const message = error instanceof Error ? error.message : String(error);
317
+ return {
318
+ content: [{ type: "text", text: `Failed to create async run directory '${asyncDir}': ${message}` }],
319
+ isError: true,
320
+ details: { mode: "single" as const, results: [] },
321
+ };
322
+ }
274
323
 
275
324
  const runnerCwd = cwd ?? ctx.cwd;
276
325
  const outputPath = resolveSingleOutputPath(params.output, ctx.cwd, cwd);
@@ -290,6 +339,7 @@ export function executeAsyncSingle(
290
339
  systemPrompt,
291
340
  skills: resolvedSkills.map((r) => r.name),
292
341
  outputPath,
342
+ sessionFile,
293
343
  },
294
344
  ],
295
345
  resultPath: path.join(RESULTS_DIR, `${id}.json`),
@@ -70,6 +70,7 @@ export interface ChainExecutionParams {
70
70
  cwd?: string;
71
71
  shareEnabled: boolean;
72
72
  sessionDirForIndex: (idx?: number) => string | undefined;
73
+ sessionFileForIndex?: (idx?: number) => string | undefined;
73
74
  artifactsDir: string;
74
75
  artifactConfig: ArtifactConfig;
75
76
  includeProgress?: boolean;
@@ -103,6 +104,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
103
104
  cwd,
104
105
  shareEnabled,
105
106
  sessionDirForIndex,
107
+ sessionFileForIndex,
106
108
  artifactsDir,
107
109
  artifactConfig,
108
110
  includeProgress,
@@ -333,6 +335,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
333
335
  runId,
334
336
  index: globalTaskIndex + taskIndex,
335
337
  sessionDir: sessionDirForIndex(globalTaskIndex + taskIndex),
338
+ sessionFile: sessionFileForIndex?.(globalTaskIndex + taskIndex),
336
339
  share: shareEnabled,
337
340
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
338
341
  artifactConfig,
@@ -489,6 +492,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
489
492
  runId,
490
493
  index: globalTaskIndex,
491
494
  sessionDir: sessionDirForIndex(globalTaskIndex),
495
+ sessionFile: sessionFileForIndex?.(globalTaskIndex),
492
496
  share: shareEnabled,
493
497
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
494
498
  artifactConfig,
package/execution.ts CHANGED
@@ -56,7 +56,7 @@ export async function runSync(
56
56
  }
57
57
 
58
58
  const shareEnabled = options.share === true;
59
- const sessionEnabled = Boolean(options.sessionDir) || shareEnabled;
59
+ const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
60
60
  const effectiveModel = modelOverride ?? agent.model;
61
61
  const modelArg = applyThinkingSuffix(effectiveModel, agent.thinking);
62
62
 
@@ -74,6 +74,7 @@ export async function runSync(
74
74
  task,
75
75
  sessionEnabled,
76
76
  sessionDir: options.sessionDir,
77
+ sessionFile: options.sessionFile,
77
78
  model: effectiveModel,
78
79
  thinking: agent.thinking,
79
80
  tools: agent.tools,
@@ -263,7 +264,9 @@ export async function runSync(
263
264
  }
264
265
  scheduleUpdate();
265
266
  }
266
- } catch {}
267
+ } catch {
268
+ // Non-JSON stdout lines are expected; only structured events are parsed.
269
+ }
267
270
  };
268
271
 
269
272
  let stderrBuf = "";
@@ -307,7 +310,9 @@ export async function runSync(
307
310
  if (closeJsonlWriter) {
308
311
  try {
309
312
  await closeJsonlWriter();
310
- } catch {}
313
+ } catch {
314
+ // JSONL artifact flush is best effort.
315
+ }
311
316
  }
312
317
 
313
318
  cleanupTempDir(tempDir);
@@ -379,8 +384,9 @@ export async function runSync(
379
384
  }
380
385
  }
381
386
 
382
- if (shareEnabled && options.sessionDir) {
383
- const sessionFile = findLatestSessionFile(options.sessionDir);
387
+ if (shareEnabled) {
388
+ const sessionFile = options.sessionFile
389
+ ?? (options.sessionDir ? findLatestSessionFile(options.sessionDir) : null);
384
390
  if (sessionFile) {
385
391
  result.sessionFile = sessionFile;
386
392
  // HTML export disabled - module resolution issues with global pi installation
@@ -0,0 +1,56 @@
1
+ export type SubagentExecutionContext = "fresh" | "fork";
2
+
3
+ export interface ForkableSessionManager {
4
+ getSessionFile(): string | undefined;
5
+ getLeafId(): string | null;
6
+ createBranchedSession(leafId: string): string | undefined;
7
+ }
8
+
9
+ export interface ForkContextResolver {
10
+ sessionFileForIndex(index?: number): string | undefined;
11
+ }
12
+
13
+ export function resolveSubagentContext(value: unknown): SubagentExecutionContext {
14
+ return value === "fork" ? "fork" : "fresh";
15
+ }
16
+
17
+ export function createForkContextResolver(
18
+ sessionManager: ForkableSessionManager,
19
+ requestedContext: unknown,
20
+ ): ForkContextResolver {
21
+ if (resolveSubagentContext(requestedContext) !== "fork") {
22
+ return {
23
+ sessionFileForIndex: () => undefined,
24
+ };
25
+ }
26
+
27
+ const parentSessionFile = sessionManager.getSessionFile();
28
+ if (!parentSessionFile) {
29
+ throw new Error("Forked subagent context requires a persisted parent session.");
30
+ }
31
+
32
+ const leafId = sessionManager.getLeafId();
33
+ if (!leafId) {
34
+ throw new Error("Forked subagent context requires a current leaf to fork from.");
35
+ }
36
+
37
+ const cachedSessionFiles = new Map<number, string>();
38
+
39
+ return {
40
+ sessionFileForIndex(index = 0): string | undefined {
41
+ const cached = cachedSessionFiles.get(index);
42
+ if (cached) return cached;
43
+ try {
44
+ const sessionFile = sessionManager.createBranchedSession(leafId);
45
+ if (!sessionFile) {
46
+ throw new Error("Session manager did not return a session file.");
47
+ }
48
+ cachedSessionFiles.set(index, sessionFile);
49
+ return sessionFile;
50
+ } catch (error) {
51
+ const cause = error instanceof Error ? error : new Error(String(error));
52
+ throw new Error(`Failed to create forked subagent session: ${cause.message}`, { cause });
53
+ }
54
+ },
55
+ };
56
+ }
package/index.ts CHANGED
@@ -59,7 +59,9 @@ function loadConfig(): ExtensionConfig {
59
59
  if (fs.existsSync(configPath)) {
60
60
  return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ExtensionConfig;
61
61
  }
62
- } catch {}
62
+ } catch (error) {
63
+ console.error(`Failed to load subagent config from '${configPath}':`, error);
64
+ }
63
65
  return {};
64
66
  }
65
67
 
@@ -81,7 +83,9 @@ function ensureAccessibleDir(dirPath: string): void {
81
83
  } catch {
82
84
  try {
83
85
  fs.rmSync(dirPath, { recursive: true, force: true });
84
- } catch {}
86
+ } catch {
87
+ // Best effort: retry mkdir/access even if cleanup fails.
88
+ }
85
89
  fs.mkdirSync(dirPath, { recursive: true });
86
90
  fs.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
87
91
  }
@@ -143,6 +147,7 @@ EXECUTION (use exactly ONE mode):
143
147
  • SINGLE: { agent, task } - one task
144
148
  • CHAIN: { chain: [{agent:"scout"}, {agent:"planner"}] } - sequential pipeline
145
149
  • PARALLEL: { tasks: [{agent,task}, ...] } - concurrent execution
150
+ • Optional context: { context: "fresh" | "fork" } (default: "fresh")
146
151
 
147
152
  CHAIN TEMPLATE VARIABLES (use in task strings):
148
153
  • {task} - The original task/request from the user
@@ -179,20 +184,21 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
179
184
  }
180
185
  const isParallel = (args.tasks?.length ?? 0) > 0;
181
186
  const asyncLabel = args.async === true && !isParallel ? theme.fg("warning", " [async]") : "";
187
+ const contextLabel = args.context === "fork" ? theme.fg("warning", " [fork]") : "";
182
188
  if (args.chain?.length)
183
189
  return new Text(
184
- `${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}`,
190
+ `${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}${contextLabel}`,
185
191
  0,
186
192
  0,
187
193
  );
188
194
  if (isParallel)
189
195
  return new Text(
190
- `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${args.tasks!.length})`,
196
+ `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${args.tasks!.length})${contextLabel}`,
191
197
  0,
192
198
  0,
193
199
  );
194
200
  return new Text(
195
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent || "?")}${asyncLabel}`,
201
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent || "?")}${asyncLabel}${contextLabel}`,
196
202
  0,
197
203
  0,
198
204
  );
@@ -277,7 +283,14 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
277
283
  const lines = [`Run: ${data.id ?? params.id}`, `State: ${status}`, `Result: ${resultPath}`];
278
284
  if (data.summary) lines.push("", data.summary);
279
285
  return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
280
- } catch {}
286
+ } catch (error) {
287
+ const message = error instanceof Error ? error.message : String(error);
288
+ return {
289
+ content: [{ type: "text", text: `Failed to read async result file: ${message}` }],
290
+ isError: true,
291
+ details: { mode: "single" as const, results: [] },
292
+ };
293
+ }
281
294
  }
282
295
 
283
296
  return {
@@ -311,7 +324,9 @@ MANAGEMENT (use action field — omit agent/task/chain/tasks):
311
324
  if (sessionFile) {
312
325
  cleanupOldArtifacts(getArtifactsDir(sessionFile), DEFAULT_ARTIFACT_CONFIG.cleanupDays);
313
326
  }
314
- } catch {}
327
+ } catch {
328
+ // Cleanup failures should not block session lifecycle events.
329
+ }
315
330
  };
316
331
 
317
332
  const resetSessionState = (ctx: ExtensionContext) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/parallel-utils.ts CHANGED
@@ -16,6 +16,7 @@ export interface RunnerSubagentStep {
16
16
  systemPrompt?: string | null;
17
17
  skills?: string[];
18
18
  outputPath?: string;
19
+ sessionFile?: string;
19
20
  }
20
21
 
21
22
  /** Parallel step group — multiple agents running concurrently */
package/pi-args.ts CHANGED
@@ -10,6 +10,7 @@ export interface BuildPiArgsInput {
10
10
  task: string;
11
11
  sessionEnabled: boolean;
12
12
  sessionDir?: string;
13
+ sessionFile?: string;
13
14
  model?: string;
14
15
  thinking?: string;
15
16
  tools?: string[];
@@ -36,14 +37,16 @@ export function applyThinkingSuffix(model: string | undefined, thinking: string
36
37
  export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
37
38
  const args = [...input.baseArgs];
38
39
 
39
- if (!input.sessionEnabled) {
40
- args.push("--no-session");
41
- }
42
- if (input.sessionDir) {
43
- try {
40
+ if (input.sessionFile) {
41
+ args.push("--session", input.sessionFile);
42
+ } else {
43
+ if (!input.sessionEnabled) {
44
+ args.push("--no-session");
45
+ }
46
+ if (input.sessionDir) {
44
47
  fs.mkdirSync(input.sessionDir, { recursive: true });
45
- } catch {}
46
- args.push("--session-dir", input.sessionDir);
48
+ args.push("--session-dir", input.sessionDir);
49
+ }
47
50
  }
48
51
 
49
52
  const modelArg = applyThinkingSuffix(input.model, input.thinking);
@@ -118,5 +121,7 @@ export function cleanupTempDir(tempDir: string | null | undefined): void {
118
121
  if (!tempDir) return;
119
122
  try {
120
123
  fs.rmSync(tempDir, { recursive: true, force: true });
121
- } catch {}
124
+ } catch {
125
+ // Temp cleanup is best effort.
126
+ }
122
127
  }
package/render.ts CHANGED
@@ -184,7 +184,8 @@ export function renderSubagentResult(
184
184
  if (!d || !d.results.length) {
185
185
  const t = result.content[0];
186
186
  const text = t?.type === "text" ? t.text : "(no output)";
187
- return new Text(truncLine(text, getTermWidth() - 4), 0, 0);
187
+ const contextPrefix = d?.context === "fork" ? `${theme.fg("warning", "[fork]")} ` : "";
188
+ return new Text(truncLine(`${contextPrefix}${text}`, getTermWidth() - 4), 0, 0);
188
189
  }
189
190
 
190
191
  const mdTheme = getMarkdownTheme();
@@ -197,6 +198,7 @@ export function renderSubagentResult(
197
198
  : r.exitCode === 0
198
199
  ? theme.fg("success", "ok")
199
200
  : theme.fg("error", "X");
201
+ const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
200
202
  const output = r.truncation?.text || getFinalOutput(r.messages);
201
203
 
202
204
  const progressInfo = isRunning && r.progress
@@ -207,7 +209,7 @@ export function renderSubagentResult(
207
209
 
208
210
  const w = getTermWidth() - 4;
209
211
  const c = new Container();
210
- c.addChild(new Text(truncLine(`${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${progressInfo}`, w), 0, 0));
212
+ c.addChild(new Text(truncLine(`${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${contextBadge}${progressInfo}`, w), 0, 0));
211
213
  c.addChild(new Spacer(1));
212
214
  const taskMaxLen = Math.max(20, w - 8);
213
215
  const taskPreview = r.task.length > taskMaxLen
@@ -285,6 +287,7 @@ export function renderSubagentResult(
285
287
  : "";
286
288
 
287
289
  const modeLabel = d.mode;
290
+ const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : "";
288
291
  // For parallel-in-chain, show task count (results) for consistency with step display
289
292
  // For sequential chains, show logical step count
290
293
  const hasParallelInChain = d.chainAgents?.some((a) => a.startsWith("["));
@@ -324,7 +327,7 @@ export function renderSubagentResult(
324
327
  const c = new Container();
325
328
  c.addChild(
326
329
  new Text(
327
- truncLine(`${icon} ${theme.fg("toolTitle", theme.bold(modeLabel))}${stepInfo}${summaryStr}`, w),
330
+ truncLine(`${icon} ${theme.fg("toolTitle", theme.bold(modeLabel))}${contextBadge}${stepInfo}${summaryStr}`, w),
328
331
  0,
329
332
  0,
330
333
  ),
package/schemas.ts CHANGED
@@ -76,6 +76,10 @@ export const SubagentParams = Type.Object({
76
76
  })),
77
77
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task}, ...]" })),
78
78
  chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
79
+ context: Type.Optional(Type.String({
80
+ enum: ["fresh", "fork"],
81
+ description: "Execution context mode: 'fresh' (default) starts clean, 'fork' starts from a real fork of the parent session leaf",
82
+ })),
79
83
  chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: <tmpdir>/pi-chain-runs/ (auto-cleaned after 24h)" })),
80
84
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
81
85
  agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
package/slash-commands.ts CHANGED
@@ -49,11 +49,26 @@ const parseAgentToken = (token: string): { name: string; config: InlineConfig }
49
49
  return { name: token.slice(0, bracket), config: parseInlineConfig(token.slice(bracket + 1, end !== -1 ? end : undefined)) };
50
50
  };
51
51
 
52
- const extractBgFlag = (args: string): { args: string; bg: boolean } => {
53
- if (args.endsWith(" --bg") || args === "--bg") {
54
- return { args: args.slice(0, args.length - (args === "--bg" ? 4 : 5)).trim(), bg: true };
52
+ const extractExecutionFlags = (rawArgs: string): { args: string; bg: boolean; fork: boolean } => {
53
+ let args = rawArgs.trim();
54
+ let bg = false;
55
+ let fork = false;
56
+
57
+ while (true) {
58
+ if (args.endsWith(" --bg") || args === "--bg") {
59
+ bg = true;
60
+ args = args === "--bg" ? "" : args.slice(0, -5).trim();
61
+ continue;
62
+ }
63
+ if (args.endsWith(" --fork") || args === "--fork") {
64
+ fork = true;
65
+ args = args === "--fork" ? "" : args.slice(0, -7).trim();
66
+ continue;
67
+ }
68
+ break;
55
69
  }
56
- return { args, bg: false };
70
+
71
+ return { args, bg, fork };
57
72
  };
58
73
 
59
74
  function setupDirectRun(ctx: ExtensionContext, getSubagentSessionRoot: (parentSessionFile: string | null) => string) {
@@ -62,7 +77,10 @@ function setupDirectRun(ctx: ExtensionContext, getSubagentSessionRoot: (parentSe
62
77
  const sessionRoot = path.join(getSubagentSessionRoot(parentSessionFile), runId);
63
78
  try {
64
79
  fs.mkdirSync(sessionRoot, { recursive: true });
65
- } catch {}
80
+ } catch (error) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ throw new Error(`Failed to create session directory '${sessionRoot}': ${message}`);
83
+ }
66
84
  return {
67
85
  runId,
68
86
  shareEnabled: false,
@@ -130,7 +148,7 @@ async function openAgentManager(
130
148
  const id = randomUUID();
131
149
  const asyncCtx = { pi, cwd: ctx.cwd, currentSessionId: ctx.sessionManager.getSessionId() ?? id };
132
150
  const asyncSessionRoot = getSubagentSessionRoot(ctx.sessionManager.getSessionFile() ?? null);
133
- try { fs.mkdirSync(asyncSessionRoot, { recursive: true }); } catch {}
151
+ fs.mkdirSync(asyncSessionRoot, { recursive: true });
134
152
  executeAsyncChain(id, {
135
153
  chain: r.requestedAsync.chain,
136
154
  agents,
@@ -268,16 +286,16 @@ export function registerSlashCommands(
268
286
  });
269
287
 
270
288
  pi.registerCommand("run", {
271
- description: "Run a subagent directly: /run agent[output=file] task [--bg]",
289
+ description: "Run a subagent directly: /run agent[output=file] task [--bg] [--fork]",
272
290
  getArgumentCompletions: makeAgentCompletions(state, false),
273
291
  handler: async (args, ctx) => {
274
- const { args: cleanedArgs, bg } = extractBgFlag(args);
292
+ const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
275
293
  const input = cleanedArgs.trim();
276
294
  const firstSpace = input.indexOf(" ");
277
- if (firstSpace === -1) { ctx.ui.notify("Usage: /run <agent> <task> [--bg]", "error"); return; }
295
+ if (firstSpace === -1) { ctx.ui.notify("Usage: /run <agent> <task> [--bg] [--fork]", "error"); return; }
278
296
  const { name: agentName, config: inline } = parseAgentToken(input.slice(0, firstSpace));
279
297
  const task = input.slice(firstSpace + 1).trim();
280
- if (!task) { ctx.ui.notify("Usage: /run <agent> <task> [--bg]", "error"); return; }
298
+ if (!task) { ctx.ui.notify("Usage: /run <agent> <task> [--bg] [--fork]", "error"); return; }
281
299
 
282
300
  const agents = discoverAgents(state.baseCwd, "both").agents;
283
301
  if (!agents.find((a) => a.name === agentName)) { ctx.ui.notify(`Unknown agent: ${agentName}`, "error"); return; }
@@ -291,15 +309,16 @@ export function registerSlashCommands(
291
309
  if (inline.skill !== undefined) params.skill = inline.skill;
292
310
  if (inline.model) params.model = inline.model;
293
311
  if (bg) params.async = true;
312
+ if (fork) params.context = "fork";
294
313
  pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify({ ...params, agentScope: "both" })}`);
295
314
  },
296
315
  });
297
316
 
298
317
  pi.registerCommand("chain", {
299
- description: "Run agents in sequence: /chain scout \"task\" -> planner [--bg]",
318
+ description: "Run agents in sequence: /chain scout \"task\" -> planner [--bg] [--fork]",
300
319
  getArgumentCompletions: makeAgentCompletions(state, true),
301
320
  handler: async (args, ctx) => {
302
- const { args: cleanedArgs, bg } = extractBgFlag(args);
321
+ const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
303
322
  const parsed = parseAgentArgs(state, cleanedArgs, "chain", ctx);
304
323
  if (!parsed) return;
305
324
  const chain = parsed.steps.map(({ name, config, task: stepTask }, i) => ({
@@ -313,15 +332,16 @@ export function registerSlashCommands(
313
332
  }));
314
333
  const params: Record<string, unknown> = { chain, task: parsed.task, clarify: false, agentScope: "both" };
315
334
  if (bg) params.async = true;
335
+ if (fork) params.context = "fork";
316
336
  pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify(params)}`);
317
337
  },
318
338
  });
319
339
 
320
340
  pi.registerCommand("parallel", {
321
- description: "Run agents in parallel: /parallel scout \"task1\" -> reviewer \"task2\" [--bg]",
341
+ description: "Run agents in parallel: /parallel scout \"task1\" -> reviewer \"task2\" [--bg] [--fork]",
322
342
  getArgumentCompletions: makeAgentCompletions(state, true),
323
343
  handler: async (args, ctx) => {
324
- const { args: cleanedArgs, bg } = extractBgFlag(args);
344
+ const { args: cleanedArgs, bg, fork } = extractExecutionFlags(args);
325
345
  const parsed = parseAgentArgs(state, cleanedArgs, "parallel", ctx);
326
346
  if (!parsed) return;
327
347
  if (parsed.steps.length > MAX_PARALLEL) { ctx.ui.notify(`Max ${MAX_PARALLEL} parallel tasks`, "error"); return; }
@@ -336,6 +356,7 @@ export function registerSlashCommands(
336
356
  }));
337
357
  const params: Record<string, unknown> = { chain: [{ parallel: tasks }], task: parsed.task, clarify: false, agentScope: "both" };
338
358
  if (bg) params.async = true;
359
+ if (fork) params.context = "fork";
339
360
  pi.sendUserMessage(`Call the subagent tool with these exact parameters: ${JSON.stringify(params)}`);
340
361
  },
341
362
  });
@@ -21,6 +21,7 @@ import {
21
21
  } from "./settings.js";
22
22
  import { discoverAvailableSkills, normalizeSkillInput } from "./skills.js";
23
23
  import { executeAsyncChain, executeAsyncSingle, isAsyncAvailable } from "./async-execution.js";
24
+ import { createForkContextResolver } from "./fork-context.js";
24
25
  import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.js";
25
26
  import { getFinalOutput, mapConcurrent } from "./utils.js";
26
27
  import {
@@ -55,6 +56,7 @@ interface SubagentParamsLike {
55
56
  task?: string;
56
57
  chain?: ChainStep[];
57
58
  tasks?: TaskParam[];
59
+ context?: "fresh" | "fork";
58
60
  async?: boolean;
59
61
  clarify?: boolean;
60
62
  share?: boolean;
@@ -91,6 +93,7 @@ interface ExecutionContextData {
91
93
  shareEnabled: boolean;
92
94
  sessionRoot: string;
93
95
  sessionDirForIndex: (idx?: number) => string;
96
+ sessionFileForIndex: (idx?: number) => string | undefined;
94
97
  artifactConfig: ArtifactConfig;
95
98
  artifactsDir: string;
96
99
  parallelDowngraded: boolean;
@@ -167,8 +170,71 @@ function validateExecutionInput(
167
170
  return null;
168
171
  }
169
172
 
173
+ function getRequestedModeLabel(params: SubagentParamsLike): Details["mode"] {
174
+ if ((params.chain?.length ?? 0) > 0) return "chain";
175
+ if ((params.tasks?.length ?? 0) > 0) return "parallel";
176
+ if (params.agent && params.task) return "single";
177
+ return "single";
178
+ }
179
+
180
+ function withForkContext(
181
+ result: AgentToolResult<Details>,
182
+ context: SubagentParamsLike["context"],
183
+ ): AgentToolResult<Details> {
184
+ if (context !== "fork" || !result.details) return result;
185
+ return {
186
+ ...result,
187
+ details: {
188
+ ...result.details,
189
+ context: "fork",
190
+ },
191
+ };
192
+ }
193
+
194
+ function toExecutionErrorResult(params: SubagentParamsLike, error: unknown): AgentToolResult<Details> {
195
+ const message = error instanceof Error ? error.message : String(error);
196
+ return withForkContext(
197
+ {
198
+ content: [{ type: "text", text: message }],
199
+ isError: true,
200
+ details: { mode: getRequestedModeLabel(params), results: [] },
201
+ },
202
+ params.context,
203
+ );
204
+ }
205
+
206
+ function collectChainSessionFiles(
207
+ chain: ChainStep[],
208
+ sessionFileForIndex: (idx?: number) => string | undefined,
209
+ ): (string | undefined)[] {
210
+ const sessionFiles: (string | undefined)[] = [];
211
+ let flatIndex = 0;
212
+ for (const step of chain) {
213
+ if (isParallelStep(step)) {
214
+ for (let i = 0; i < step.parallel.length; i++) {
215
+ sessionFiles.push(sessionFileForIndex(flatIndex));
216
+ flatIndex++;
217
+ }
218
+ continue;
219
+ }
220
+ sessionFiles.push(sessionFileForIndex(flatIndex));
221
+ flatIndex++;
222
+ }
223
+ return sessionFiles;
224
+ }
225
+
170
226
  function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentToolResult<Details> | null {
171
- const { params, agents, ctx, shareEnabled, sessionRoot, artifactConfig, artifactsDir, effectiveAsync } = data;
227
+ const {
228
+ params,
229
+ agents,
230
+ ctx,
231
+ shareEnabled,
232
+ sessionRoot,
233
+ sessionFileForIndex,
234
+ artifactConfig,
235
+ artifactsDir,
236
+ effectiveAsync,
237
+ } = data;
172
238
  const hasChain = (params.chain?.length ?? 0) > 0;
173
239
  const hasSingle = Boolean(params.agent && params.task);
174
240
  if (!effectiveAsync) return null;
@@ -197,6 +263,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
197
263
  shareEnabled,
198
264
  sessionRoot,
199
265
  chainSkills,
266
+ sessionFilesByFlatIndex: collectChainSessionFiles(params.chain as ChainStep[], sessionFileForIndex),
200
267
  });
201
268
  }
202
269
 
@@ -204,13 +271,15 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
204
271
  const a = agents.find((x) => x.name === params.agent);
205
272
  if (!a) {
206
273
  return {
207
- content: [{ type: "text", text: `Unknown: ${params.agent}` }],
274
+ content: [{ type: "text", text: `Unknown agent: ${params.agent}` }],
208
275
  isError: true,
209
276
  details: { mode: "single" as const, results: [] },
210
277
  };
211
278
  }
212
279
  const rawOutput = params.output !== undefined ? params.output : a.output;
213
280
  const effectiveOutput: string | false | undefined = rawOutput === true ? a.output : (rawOutput as string | false | undefined);
281
+ const normalizedSkills = normalizeSkillInput(params.skill);
282
+ const skills = normalizedSkills === false ? [] : normalizedSkills;
214
283
  return executeAsyncSingle(id, {
215
284
  agent: params.agent!,
216
285
  task: params.task!,
@@ -222,12 +291,8 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
222
291
  artifactConfig,
223
292
  shareEnabled,
224
293
  sessionRoot,
225
- skills: (() => {
226
- const normalized = normalizeSkillInput(params.skill);
227
- if (normalized === false) return [];
228
- if (normalized === undefined) return undefined;
229
- return normalized;
230
- })(),
294
+ sessionFile: sessionFileForIndex(0),
295
+ skills,
231
296
  output: effectiveOutput,
232
297
  });
233
298
  }
@@ -236,7 +301,20 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
236
301
  }
237
302
 
238
303
  async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Promise<AgentToolResult<Details>> {
239
- const { params, agents, ctx, signal, runId, shareEnabled, sessionDirForIndex, artifactsDir, artifactConfig, onUpdate, sessionRoot } = data;
304
+ const {
305
+ params,
306
+ agents,
307
+ ctx,
308
+ signal,
309
+ runId,
310
+ shareEnabled,
311
+ sessionDirForIndex,
312
+ sessionFileForIndex,
313
+ artifactsDir,
314
+ artifactConfig,
315
+ onUpdate,
316
+ sessionRoot,
317
+ } = data;
240
318
  const normalized = normalizeSkillInput(params.skill);
241
319
  const chainSkills = normalized === false ? [] : (normalized ?? []);
242
320
  const chainResult = await executeChain({
@@ -249,6 +327,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
249
327
  cwd: params.cwd,
250
328
  shareEnabled,
251
329
  sessionDirForIndex,
330
+ sessionFileForIndex,
252
331
  artifactsDir,
253
332
  artifactConfig,
254
333
  includeProgress: params.includeProgress,
@@ -279,6 +358,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
279
358
  shareEnabled,
280
359
  sessionRoot,
281
360
  chainSkills: chainResult.requestedAsync.chainSkills,
361
+ sessionFilesByFlatIndex: collectChainSessionFiles(chainResult.requestedAsync.chain, sessionFileForIndex),
282
362
  });
283
363
  }
284
364
 
@@ -286,7 +366,21 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
286
366
  }
287
367
 
288
368
  async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps): Promise<AgentToolResult<Details>> {
289
- const { params, agents, ctx, signal, runId, sessionDirForIndex, shareEnabled, artifactConfig, artifactsDir, parallelDowngraded, onUpdate, sessionRoot } = data;
369
+ const {
370
+ params,
371
+ agents,
372
+ ctx,
373
+ signal,
374
+ runId,
375
+ sessionDirForIndex,
376
+ sessionFileForIndex,
377
+ shareEnabled,
378
+ artifactConfig,
379
+ artifactsDir,
380
+ parallelDowngraded,
381
+ onUpdate,
382
+ sessionRoot,
383
+ } = data;
290
384
  const allProgress: AgentProgress[] = [];
291
385
  const allArtifactPaths: ArtifactPaths[] = [];
292
386
  const tasks = params.tasks!;
@@ -385,6 +479,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
385
479
  shareEnabled,
386
480
  sessionRoot,
387
481
  chainSkills: [],
482
+ sessionFilesByFlatIndex: tasks.map((_, index) => sessionFileForIndex(index)),
388
483
  });
389
484
  }
390
485
  }
@@ -401,6 +496,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
401
496
  runId,
402
497
  index: i,
403
498
  sessionDir: sessionDirForIndex(i),
499
+ sessionFile: sessionFileForIndex(i),
404
500
  share: shareEnabled,
405
501
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
406
502
  artifactConfig,
@@ -465,7 +561,20 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
465
561
  }
466
562
 
467
563
  async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Promise<AgentToolResult<Details>> {
468
- const { params, agents, ctx, signal, runId, sessionDirForIndex, shareEnabled, artifactConfig, artifactsDir, onUpdate, sessionRoot } = data;
564
+ const {
565
+ params,
566
+ agents,
567
+ ctx,
568
+ signal,
569
+ runId,
570
+ sessionDirForIndex,
571
+ sessionFileForIndex,
572
+ shareEnabled,
573
+ artifactConfig,
574
+ artifactsDir,
575
+ onUpdate,
576
+ sessionRoot,
577
+ } = data;
469
578
  const allProgress: AgentProgress[] = [];
470
579
  const allArtifactPaths: ArtifactPaths[] = [];
471
580
  const agentConfig = agents.find((a) => a.name === params.agent);
@@ -541,6 +650,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
541
650
  artifactConfig,
542
651
  shareEnabled,
543
652
  sessionRoot,
653
+ sessionFile: sessionFileForIndex(0),
544
654
  skills: skillOverride === false ? [] : skillOverride,
545
655
  output: effectiveOutput,
546
656
  });
@@ -551,17 +661,19 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
551
661
  const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, params.cwd);
552
662
  task = injectSingleOutputInstruction(task, outputPath);
553
663
 
554
- const effectiveSkills = skillOverride === false
555
- ? []
556
- : skillOverride === undefined
557
- ? undefined
558
- : skillOverride;
664
+ let effectiveSkills: string[] | undefined;
665
+ if (skillOverride === false) {
666
+ effectiveSkills = [];
667
+ } else {
668
+ effectiveSkills = skillOverride;
669
+ }
559
670
 
560
671
  const r = await runSync(ctx.cwd, agents, params.agent!, task, {
561
672
  cwd: params.cwd,
562
673
  signal,
563
674
  runId,
564
675
  sessionDir: sessionDirForIndex(0),
676
+ sessionFile: sessionFileForIndex(0),
565
677
  share: shareEnabled,
566
678
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
567
679
  artifactConfig,
@@ -659,31 +771,26 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
659
771
  const agents = deps.discoverAgents(ctx.cwd, scope).agents;
660
772
  const runId = randomUUID().slice(0, 8);
661
773
  const shareEnabled = params.share === true;
662
- const sessionRoot = params.sessionDir
663
- ? path.resolve(deps.expandTilde(params.sessionDir))
664
- : path.join(
665
- deps.config.defaultSessionDir
666
- ? path.resolve(deps.expandTilde(deps.config.defaultSessionDir))
667
- : deps.getSubagentSessionRoot(parentSessionFile),
668
- runId,
669
- );
670
- try {
671
- fs.mkdirSync(sessionRoot, { recursive: true });
672
- } catch {}
673
- const sessionDirForIndex = (idx?: number) =>
674
- path.join(sessionRoot, `run-${idx ?? 0}`);
675
-
676
774
  const hasChain = (params.chain?.length ?? 0) > 0;
677
775
  const hasTasks = (params.tasks?.length ?? 0) > 0;
678
776
  const hasSingle = Boolean(params.agent && params.task);
679
777
 
778
+ const validationError = validateExecutionInput(params, agents, hasChain, hasTasks, hasSingle);
779
+ if (validationError) return validationError;
780
+
781
+ let sessionFileForIndex: (idx?: number) => string | undefined = () => undefined;
782
+ try {
783
+ sessionFileForIndex = createForkContextResolver(ctx.sessionManager, params.context).sessionFileForIndex;
784
+ } catch (error) {
785
+ return toExecutionErrorResult(params, error);
786
+ }
787
+
680
788
  const requestedAsync = params.async ?? deps.asyncByDefault;
681
789
  const parallelDowngraded = hasTasks && requestedAsync;
682
- const effectiveAsync = requestedAsync && !hasTasks && (
683
- hasChain
684
- ? params.clarify === false
685
- : params.clarify !== true
686
- );
790
+ let effectiveAsync = false;
791
+ if (requestedAsync && !hasTasks) {
792
+ effectiveAsync = hasChain ? params.clarify === false : params.clarify !== true;
793
+ }
687
794
 
688
795
  const artifactConfig: ArtifactConfig = {
689
796
  ...DEFAULT_ARTIFACT_CONFIG,
@@ -691,45 +798,72 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
691
798
  };
692
799
  const artifactsDir = effectiveAsync ? deps.tempArtifactsDir : getArtifactsDir(parentSessionFile);
693
800
 
694
- const validationError = validateExecutionInput(params, agents, hasChain, hasTasks, hasSingle);
695
- if (validationError) return validationError;
801
+ let sessionRoot: string;
802
+ if (params.sessionDir) {
803
+ sessionRoot = path.resolve(deps.expandTilde(params.sessionDir));
804
+ } else {
805
+ const baseSessionRoot = deps.config.defaultSessionDir
806
+ ? path.resolve(deps.expandTilde(deps.config.defaultSessionDir))
807
+ : deps.getSubagentSessionRoot(parentSessionFile);
808
+ sessionRoot = path.join(baseSessionRoot, runId);
809
+ }
810
+ try {
811
+ fs.mkdirSync(sessionRoot, { recursive: true });
812
+ } catch (error) {
813
+ const message = error instanceof Error ? error.message : String(error);
814
+ return toExecutionErrorResult(
815
+ params,
816
+ new Error(`Failed to create session directory '${sessionRoot}': ${message}`),
817
+ );
818
+ }
819
+ const sessionDirForIndex = (idx?: number) =>
820
+ path.join(sessionRoot, `run-${idx ?? 0}`);
821
+
822
+ const onUpdateWithContext = onUpdate
823
+ ? (r: AgentToolResult<Details>) => onUpdate(withForkContext(r, params.context))
824
+ : undefined;
696
825
 
697
826
  const execData: ExecutionContextData = {
698
827
  params,
699
828
  ctx,
700
829
  signal,
701
- onUpdate,
830
+ onUpdate: onUpdateWithContext,
702
831
  agents,
703
832
  runId,
704
833
  shareEnabled,
705
834
  sessionRoot,
706
835
  sessionDirForIndex,
836
+ sessionFileForIndex,
707
837
  artifactConfig,
708
838
  artifactsDir,
709
839
  parallelDowngraded,
710
840
  effectiveAsync,
711
841
  };
712
842
 
713
- const asyncResult = runAsyncPath(execData, deps);
714
- if (asyncResult) return asyncResult;
843
+ try {
844
+ const asyncResult = runAsyncPath(execData, deps);
845
+ if (asyncResult) return withForkContext(asyncResult, params.context);
715
846
 
716
- if (hasChain && params.chain) {
717
- return runChainPath(execData, deps);
718
- }
847
+ if (hasChain && params.chain) {
848
+ return withForkContext(await runChainPath(execData, deps), params.context);
849
+ }
719
850
 
720
- if (hasTasks && params.tasks) {
721
- return runParallelPath(execData, deps);
722
- }
851
+ if (hasTasks && params.tasks) {
852
+ return withForkContext(await runParallelPath(execData, deps), params.context);
853
+ }
723
854
 
724
- if (hasSingle) {
725
- return runSinglePath(execData, deps);
855
+ if (hasSingle) {
856
+ return withForkContext(await runSinglePath(execData, deps), params.context);
857
+ }
858
+ } catch (error) {
859
+ return toExecutionErrorResult(params, error);
726
860
  }
727
861
 
728
- return {
862
+ return withForkContext({
729
863
  content: [{ type: "text", text: "Invalid params" }],
730
864
  isError: true,
731
865
  details: { mode: "single" as const, results: [] },
732
- };
866
+ }, params.context);
733
867
  };
734
868
 
735
869
  return { execute };
@@ -64,6 +64,7 @@ function findLatestSessionFile(sessionDir: string): string | null {
64
64
  files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
65
65
  return files[0] ?? null;
66
66
  } catch {
67
+ // Session lookup is optional metadata.
67
68
  return null;
68
69
  }
69
70
  }
@@ -89,10 +90,13 @@ function parseSessionTokens(sessionDir: string): TokenUsage | null {
89
90
  input += entry.usage.inputTokens ?? entry.usage.input ?? 0;
90
91
  output += entry.usage.outputTokens ?? entry.usage.output ?? 0;
91
92
  }
92
- } catch {}
93
+ } catch {
94
+ // Ignore malformed lines while scanning usage entries.
95
+ }
93
96
  }
94
97
  return { input, output, total: input + output };
95
98
  } catch {
99
+ // Usage extraction should not fail the run.
96
100
  return null;
97
101
  }
98
102
  }
@@ -143,7 +147,9 @@ function resolvePiPackageRootFallback(): string {
143
147
  try {
144
148
  const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
145
149
  if (pkg.name === "@mariozechner/pi-coding-agent") return dir;
146
- } catch {}
150
+ } catch {
151
+ // Keep walking up until a readable package.json is found.
152
+ }
147
153
  dir = path.dirname(dir);
148
154
  }
149
155
  throw new Error("Could not resolve @mariozechner/pi-coding-agent package root");
@@ -277,11 +283,14 @@ async function runSingleStep(
277
283
  ): Promise<{ agent: string; output: string; exitCode: number | null; artifactPaths?: ArtifactPaths }> {
278
284
  const placeholderRegex = new RegExp(ctx.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
279
285
  const task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
286
+ const sessionEnabled = Boolean(step.sessionFile) || ctx.sessionEnabled;
287
+ const sessionDir = step.sessionFile ? undefined : ctx.sessionDir;
280
288
  const { args, env, tempDir } = buildPiArgs({
281
289
  baseArgs: ["-p"],
282
290
  task,
283
- sessionEnabled: ctx.sessionEnabled,
284
- sessionDir: ctx.sessionDir,
291
+ sessionEnabled,
292
+ sessionDir,
293
+ sessionFile: step.sessionFile,
285
294
  model: step.model,
286
295
  tools: step.tools,
287
296
  extensions: step.extensions,
@@ -349,15 +358,18 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
349
358
  const results: StepResult[] = [];
350
359
  const overallStartTime = Date.now();
351
360
  const shareEnabled = config.share === true;
352
- const sessionEnabled = Boolean(config.sessionDir) || shareEnabled;
353
361
  const asyncDir = config.asyncDir;
354
362
  const statusPath = path.join(asyncDir, "status.json");
355
363
  const eventsPath = path.join(asyncDir, "events.jsonl");
356
364
  const logPath = path.join(asyncDir, `subagent-log-${id}.md`);
357
365
  let previousCumulativeTokens: TokenUsage = { input: 0, output: 0, total: 0 };
366
+ let latestSessionFile: string | undefined;
358
367
 
359
368
  // Flatten steps for status tracking (parallel groups expand to individual entries)
360
369
  const flatSteps = flattenSteps(steps);
370
+ const sessionEnabled = Boolean(config.sessionDir)
371
+ || shareEnabled
372
+ || flatSteps.some((step) => Boolean(step.sessionFile));
361
373
  const statusPayload: {
362
374
  runId: string;
363
375
  mode: "single" | "chain";
@@ -480,6 +492,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
480
492
  outputFile: path.join(asyncDir, `output-${fi}.log`),
481
493
  piPackageRoot: config.piPackageRoot,
482
494
  });
495
+ if (task.sessionFile) {
496
+ latestSessionFile = task.sessionFile;
497
+ }
483
498
 
484
499
  const taskEndTime = Date.now();
485
500
  const taskDuration = taskEndTime - taskStartTime;
@@ -580,6 +595,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
580
595
  outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
581
596
  piPackageRoot: config.piPackageRoot,
582
597
  });
598
+ if (seqStep.sessionFile) {
599
+ latestSessionFile = seqStep.sessionFile;
600
+ }
583
601
 
584
602
  previousOutput = singleResult.output;
585
603
  results.push({
@@ -652,11 +670,17 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
652
670
  let gistUrl: string | undefined;
653
671
  let shareError: string | undefined;
654
672
 
655
- if (shareEnabled && config.sessionDir) {
656
- sessionFile = findLatestSessionFile(config.sessionDir) ?? undefined;
673
+ if (shareEnabled) {
674
+ sessionFile = config.sessionDir
675
+ ? (findLatestSessionFile(config.sessionDir) ?? undefined)
676
+ : undefined;
677
+ if (!sessionFile && latestSessionFile) {
678
+ sessionFile = latestSessionFile;
679
+ }
657
680
  if (sessionFile) {
658
681
  try {
659
- const htmlPath = await exportSessionHtml(sessionFile, config.sessionDir, config.piPackageRoot);
682
+ const exportDir = config.sessionDir ?? path.dirname(sessionFile);
683
+ const htmlPath = await exportSessionHtml(sessionFile, exportDir, config.piPackageRoot);
660
684
  const share = createShareLink(htmlPath);
661
685
  if ("error" in share) shareError = share.error;
662
686
  else {
@@ -671,11 +695,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
671
695
  }
672
696
  }
673
697
 
698
+ const effectiveSessionFile = sessionFile ?? latestSessionFile;
674
699
  const runEndedAt = Date.now();
675
700
  statusPayload.state = results.every((r) => r.success) ? "complete" : "failed";
676
701
  statusPayload.endedAt = runEndedAt;
677
702
  statusPayload.lastUpdate = runEndedAt;
678
- statusPayload.sessionFile = sessionFile;
703
+ statusPayload.sessionFile = effectiveSessionFile;
679
704
  statusPayload.shareUrl = shareUrl;
680
705
  statusPayload.gistUrl = gistUrl;
681
706
  statusPayload.shareError = shareError;
@@ -710,7 +735,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
710
735
  summary,
711
736
  truncated,
712
737
  artifactsDir,
713
- sessionFile,
738
+ sessionFile: effectiveSessionFile,
714
739
  shareUrl,
715
740
  shareError,
716
741
  });
@@ -740,7 +765,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
740
765
  cwd,
741
766
  asyncDir,
742
767
  sessionId: config.sessionId,
743
- sessionFile,
768
+ sessionFile: effectiveSessionFile,
744
769
  shareUrl,
745
770
  gistUrl,
746
771
  shareError,
@@ -760,7 +785,9 @@ if (configArg) {
760
785
  const config = JSON.parse(configJson) as SubagentRunConfig;
761
786
  try {
762
787
  fs.unlinkSync(configArg);
763
- } catch {}
788
+ } catch {
789
+ // Temp config cleanup is best effort.
790
+ }
764
791
  runSubagent(config).catch((runErr) => {
765
792
  console.error("Subagent runner error:", runErr);
766
793
  process.exit(1);
package/types.ts CHANGED
@@ -101,6 +101,7 @@ export interface SingleResult {
101
101
 
102
102
  export interface Details {
103
103
  mode: "single" | "parallel" | "chain" | "management";
104
+ context?: "fresh" | "fork";
104
105
  results: SingleResult[];
105
106
  asyncId?: string;
106
107
  asyncDir?: string;
@@ -226,6 +227,7 @@ export interface RunSyncOptions {
226
227
  runId: string;
227
228
  index?: number;
228
229
  sessionDir?: string;
230
+ sessionFile?: string;
229
231
  share?: boolean;
230
232
  /** Override the agent's default model (format: "provider/id" or just "id") */
231
233
  modelOverride?: string;