oira666_pi-subagent 0.2.2 → 0.2.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/README.md CHANGED
@@ -24,6 +24,7 @@ pi remove npm:oira666_pi-subagent
24
24
 
25
25
  Each subagent runs as a **separate `pi` process** — fully isolated memory, its own model/tool loop.
26
26
  Processes are spawned via the operating system and communicate through JSON-line stdout.
27
+ Subagent sessions are persisted separately under a `sessions-subagents` directory (a sibling of Pi's normal `sessions` directory), so they can be resumed without mixing into the main session list.
27
28
 
28
29
  - Full OS-level isolation — a crashed subagent cannot affect the parent
29
30
  - True parallel execution across all CPU cores
@@ -119,6 +120,20 @@ pi --no-subagent-prevent-cycles # allow cycles (not recommended)
119
120
  | `PI_SUBAGENT_MAX_PARALLEL_TASKS` | `16` | Max tasks per single call |
120
121
  | `PI_SUBAGENT_MAX_CONCURRENCY` | `8` | Max subagents running simultaneously |
121
122
 
123
+ ## Subagent Session Resume
124
+
125
+ Subagent subprocesses save sessions in `sessions-subagents`. When a main Pi session is resumed and its latest branch contains an unfinished `subagent` tool call (aborted, errored, or closed by Pi's synthetic unfinished-tool error), the extension can resume that delegation from the saved subagent sessions.
126
+
127
+ - TUI mode asks: **Resume subagents?**
128
+ - Non-UI modes (`pi -p`, JSON/RPC) resume automatically.
129
+ - Already-finished subagents are reused as completed; unfinished ones continue from their own saved sessions.
130
+ - Nested subagents use the same mechanism recursively.
131
+
132
+ | Env Var | Default | Description |
133
+ | --- | --- | --- |
134
+ | `PI_SUBAGENT_RESUME_PROMPT` | `true` | Set to `false` to suppress the TUI yes/no prompt and auto-resume. |
135
+ | `PI_SUBAGENT_DISABLE_RESUME` | `false` | Set to `true` to disable automatic subagent resume detection entirely. |
136
+
122
137
  ## Agent Discovery
123
138
 
124
139
  | Env Var | Description |
package/index.ts CHANGED
@@ -13,6 +13,18 @@ import { Type } from "@sinclair/typebox";
13
13
  import { type AgentConfig, discoverAgents } from "./agents.js";
14
14
  import { renderCall, renderResult } from "./render.js";
15
15
  import { runAgentSubprocess, executeParallelSubprocess } from "./runner.js";
16
+ import {
17
+ SUBAGENT_RESUME_DISABLE_ENV,
18
+ SUBAGENT_RESUME_PROMPT_ENV,
19
+ buildSubagentSessionDir,
20
+ ensureDir,
21
+ findLatestResumableSubagentCall,
22
+ getDefaultSubagentSessionRoot,
23
+ isFinishedResult,
24
+ parseBooleanEnv,
25
+ sameTasks,
26
+ type ResumableSubagentCall,
27
+ } from "./resume.js";
16
28
  import {
17
29
  DEFAULT_MAX_PARALLEL_TASKS,
18
30
  SUBAGENT_MAX_PARALLEL_TASKS_ENV,
@@ -339,6 +351,21 @@ function getProjectAgentSessionKey(projectAgentsDir: string | null): string {
339
351
  return projectAgentsDir ?? "(unknown-project-agents-dir)";
340
352
  }
341
353
 
354
+ function ensureSubagentToolActive(pi: ExtensionAPI): void {
355
+ const activeTools = pi.getActiveTools();
356
+ if (!activeTools.includes("subagent")) {
357
+ pi.setActiveTools([...activeTools, "subagent"]);
358
+ }
359
+ }
360
+
361
+ function hasCliInitialPrompt(argv: string[]): boolean {
362
+ for (let i = 2; i < argv.length; i++) {
363
+ const arg = argv[i];
364
+ if (arg === "-p" || arg === "--print") return true;
365
+ }
366
+ return false;
367
+ }
368
+
342
369
  // ---------------------------------------------------------------------------
343
370
  // Extension entry point
344
371
  // ---------------------------------------------------------------------------
@@ -362,14 +389,19 @@ export default function (pi: ExtensionAPI) {
362
389
  DEFAULT_MAX_PARALLEL_TASKS;
363
390
 
364
391
  let discoveredAgents: AgentConfig[] = [];
392
+ let currentSessionId = "ephemeral";
393
+ let currentSubagentSessionRoot = "";
394
+ let pendingResumePlan: ResumableSubagentCall | null = null;
365
395
  const approvedProjectAgentDirsForSession = new Set<string>();
366
396
 
367
397
  // Auto-discover agents on session start
368
- pi.on("session_start", async (_event, ctx) => {
398
+ pi.on("session_start", async (event, ctx) => {
369
399
  if (!canDelegate) return;
370
400
  try {
371
401
  const discovery = discoverAgents(ctx.cwd, "both");
372
402
  discoveredAgents = discovery.agents;
403
+ currentSessionId = ctx.sessionManager.getSessionId?.() ?? "ephemeral";
404
+ currentSubagentSessionRoot = getDefaultSubagentSessionRoot(ctx);
373
405
 
374
406
  if (discoveredAgents.length > 0 && ctx.hasUI) {
375
407
  const list = discoveredAgents
@@ -380,6 +412,36 @@ export default function (pi: ExtensionAPI) {
380
412
  "info",
381
413
  );
382
414
  }
415
+
416
+ const resumeDisabled = parseBooleanEnv(process.env[SUBAGENT_RESUME_DISABLE_ENV]) === true;
417
+ if (resumeDisabled || (event.reason !== "resume" && event.reason !== "startup")) return;
418
+
419
+ const plan = findLatestResumableSubagentCall(ctx);
420
+ if (!plan) return;
421
+
422
+ let shouldResume = true;
423
+ const shouldPrompt = parseBooleanEnv(process.env[SUBAGENT_RESUME_PROMPT_ENV]) !== false;
424
+ if (ctx.hasUI && shouldPrompt) {
425
+ shouldResume = await ctx.ui.confirm(
426
+ "Resume subagents?",
427
+ `The resumed session has an unfinished subagent call (${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"}). Resume it from saved subagent sessions?`,
428
+ );
429
+ }
430
+ if (!shouldResume) return;
431
+
432
+ pendingResumePlan = plan;
433
+ ensureSubagentToolActive(pi);
434
+
435
+ // In print/json subprocesses there is already an initial CLI prompt about
436
+ // to be sent. Starting another prompt from session_start races with it and
437
+ // Pi correctly reports "agent is already processing". In that case we only
438
+ // seed pendingResumePlan; before_agent_start injects the exact subagent
439
+ // call instruction into the upcoming turn.
440
+ if (hasCliInitialPrompt(process.argv)) {
441
+ if (ctx.hasUI) ctx.ui.notify(`Resuming ${plan.tasks.length} subagents...`, "info");
442
+ } else {
443
+ pi.sendUserMessage(`Resuming ${plan.tasks.length} subagents...`);
444
+ }
383
445
  } catch (err) {
384
446
  console.error("[pi-subagent] Error in session_start:", err);
385
447
  }
@@ -394,9 +456,15 @@ export default function (pi: ExtensionAPI) {
394
456
  const agentList = discoveredAgents
395
457
  .map((a) => `- **${a.name}**: ${a.description}`)
396
458
  .join("\n");
459
+ if (pendingResumePlan) ensureSubagentToolActive(pi);
460
+ const resumeInstruction = pendingResumePlan
461
+ ? `\n\n## Interrupted Subagent Resume\n\nThe user approved resuming an interrupted subagent delegation. The \`subagent\` tool has been enabled for this turn. You MUST call the \`subagent\` tool exactly once now with these exact arguments and no other tool calls first:\n\n\`\`\`json\n${JSON.stringify({ tasks: pendingResumePlan.tasks }, null, 2)}\n\`\`\`\n`
462
+ : "";
463
+
397
464
  return {
398
465
  systemPrompt:
399
466
  event.systemPrompt +
467
+ resumeInstruction +
400
468
  `\n\n## Available Subagents
401
469
 
402
470
  The following subagents are available via the \`subagent\` tool:
@@ -453,7 +521,7 @@ calls one after another. Do NOT put dependent tasks in the same array.
453
521
  ].join("\n"),
454
522
  parameters: SubagentParams,
455
523
 
456
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
524
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
457
525
  try {
458
526
  const discovery = discoverAgents(ctx.cwd, "both");
459
527
  const { agents } = discovery;
@@ -558,6 +626,12 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
558
626
  }
559
627
  }
560
628
 
629
+ const resumePlan =
630
+ pendingResumePlan && sameTasks(pendingResumePlan.tasks, tasks)
631
+ ? pendingResumePlan
632
+ : null;
633
+ if (resumePlan) pendingResumePlan = null;
634
+
561
635
  if (tasks.length === 1) {
562
636
  const [task] = tasks;
563
637
  return executeSingle(
@@ -569,6 +643,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
569
643
  signal,
570
644
  onUpdate,
571
645
  makeDetails,
646
+ resumePlan?.details?.results[0],
647
+ getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, 0),
648
+ !!resumePlan,
572
649
  );
573
650
  }
574
651
 
@@ -579,6 +656,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
579
656
  signal,
580
657
  onUpdate,
581
658
  makeDetails,
659
+ resumePlan?.details?.results,
660
+ (index) => getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, index),
661
+ !!resumePlan,
582
662
  );
583
663
  } catch (err) {
584
664
  const msg = err instanceof Error ? err.message : String(err);
@@ -598,6 +678,17 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
598
678
  });
599
679
  }
600
680
 
681
+ function getSessionDirForTask(toolCallId: string, index: number): string {
682
+ const root = currentSubagentSessionRoot || pathlessSubagentRootFallback();
683
+ const dir = buildSubagentSessionDir(root, currentSessionId, toolCallId, index);
684
+ ensureDir(dir);
685
+ return dir;
686
+ }
687
+
688
+ function pathlessSubagentRootFallback(): string {
689
+ return `${process.env.HOME ?? "."}/.pi/agent/sessions-subagents`;
690
+ }
691
+
601
692
  // -----------------------------------------------------------------------
602
693
  // Mode implementations
603
694
  // -----------------------------------------------------------------------
@@ -611,7 +702,22 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
611
702
  signal: AbortSignal | undefined,
612
703
  onUpdate: ((partial: any) => void) | undefined,
613
704
  makeDetails: ReturnType<typeof makeDetailsFactory>,
705
+ previousResult: SingleResult | undefined,
706
+ sessionDir: string,
707
+ resumeExistingSession: boolean,
614
708
  ) {
709
+ if (previousResult && isFinishedResult(previousResult)) {
710
+ return {
711
+ content: [
712
+ {
713
+ type: "text" as const,
714
+ text: getFinalOutput(previousResult.messages) || "(no output)",
715
+ },
716
+ ],
717
+ details: makeDetails("single")([previousResult]),
718
+ };
719
+ }
720
+
615
721
  const result = await runAgentSubprocess({
616
722
  cwd: defaultCwd,
617
723
  agents,
@@ -625,6 +731,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
625
731
  signal,
626
732
  onUpdate,
627
733
  makeDetails: makeDetails("single"),
734
+ sessionDir: previousResult?.sessionDir ?? sessionDir,
735
+ resumeSession: resumeExistingSession,
736
+ initialResult: previousResult,
628
737
  });
629
738
 
630
739
  if (isResultError(result)) {
@@ -662,6 +771,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
662
771
  signal: AbortSignal | undefined,
663
772
  onUpdate: ((partial: any) => void) | undefined,
664
773
  makeDetails: ReturnType<typeof makeDetailsFactory>,
774
+ resumeResults: SingleResult[] | undefined,
775
+ getSessionDir: (index: number) => string,
776
+ resumeExistingSessions: boolean,
665
777
  ) {
666
778
  return executeParallelSubprocess(
667
779
  tasks,
@@ -674,6 +786,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
674
786
  signal,
675
787
  onUpdate,
676
788
  makeDetails("parallel"),
789
+ resumeResults,
790
+ (index) => getSessionDir(index),
791
+ resumeExistingSessions,
677
792
  );
678
793
  }
679
794
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -8,6 +8,7 @@
8
8
  "index.ts",
9
9
  "agents.ts",
10
10
  "runner.ts",
11
+ "resume.ts",
11
12
  "shared.ts",
12
13
  "render.ts",
13
14
  "types.ts",
package/render.ts CHANGED
@@ -180,21 +180,56 @@ function buildNodesFromNestedResult(nested: NestedSubagentResult): TreeNode[] {
180
180
  return nested.details.results.map((result) => buildResultNode(result));
181
181
  }
182
182
 
183
+ function subagentCallSignature(call: PendingSubagentCall): string {
184
+ return JSON.stringify(call.tasks.map((task) => ({ agent: task.agent, task: task.task ?? "" })));
185
+ }
186
+
187
+ function nestedResultIsHealthy(nested: NestedSubagentResult | undefined): boolean {
188
+ if (!nested || nested.isError) return false;
189
+ return nested.details.results.every((result) => !isResultError(result));
190
+ }
191
+
183
192
  function buildNestedChildren(result: SingleResult): TreeNode[] {
193
+ const parentIsRunning = result.exitCode === -1;
184
194
  const completedByToolCallId = new Map<string, NestedSubagentResult>();
185
195
  for (const nested of getNestedSubagentResults(result.messages)) {
186
196
  completedByToolCallId.set(nested.toolCallId, nested);
187
197
  }
188
198
 
199
+ const calls = extractPendingSubagentCalls(result.messages);
200
+ const laterResumeBySignature = new Map<string, number>();
201
+ calls.forEach((call, index) => {
202
+ const completed = completedByToolCallId.get(call.toolCallId);
203
+ // A resumed call has the same task signature as the interrupted call but a
204
+ // newer toolCallId. Prefer that newer running/successful tree over the old
205
+ // synthetic/aborted result so resumed nested subagents render in-place.
206
+ if (!completed || nestedResultIsHealthy(completed)) {
207
+ laterResumeBySignature.set(subagentCallSignature(call), index);
208
+ }
209
+ });
210
+
189
211
  const nodes: TreeNode[] = [];
190
- for (const call of extractPendingSubagentCalls(result.messages)) {
212
+ calls.forEach((call, index) => {
191
213
  const completed = completedByToolCallId.get(call.toolCallId);
214
+ const newerEquivalent = laterResumeBySignature.get(subagentCallSignature(call));
215
+ if (
216
+ newerEquivalent !== undefined &&
217
+ newerEquivalent > index &&
218
+ (!completed || completed.isError || !nestedResultIsHealthy(completed))
219
+ ) {
220
+ return;
221
+ }
222
+
192
223
  if (completed && isSubagentDetails(completed.details)) {
193
224
  nodes.push(...buildNodesFromNestedResult(completed));
194
- continue;
225
+ return;
195
226
  }
196
- nodes.push(...buildPendingNodes(call));
197
- }
227
+ // Unmatched subagent tool calls are useful while the parent is still
228
+ // running (they show live pending children). Once the parent finished,
229
+ // unmatched calls are stale history from an interrupted/resumed session and
230
+ // must not keep the whole tree in a perpetual "running" state.
231
+ if (parentIsRunning) nodes.push(...buildPendingNodes(call));
232
+ });
198
233
  return nodes;
199
234
  }
200
235
 
package/resume.ts ADDED
@@ -0,0 +1,178 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import { isResultError, isSubagentDetails, type SingleResult, type SubagentDetails } from "./types.js";
6
+
7
+ export const SUBAGENT_RESUME_PROMPT_ENV = "PI_SUBAGENT_RESUME_PROMPT";
8
+ export const SUBAGENT_RESUME_DISABLE_ENV = "PI_SUBAGENT_DISABLE_RESUME";
9
+
10
+ type SessionEntry = ReturnType<ExtensionContext["sessionManager"]["getEntries"]>[number];
11
+
12
+ export interface ResumableSubagentCall {
13
+ previousToolCallId: string;
14
+ tasks: Array<{ agent: string; task: string; cwd?: string }>;
15
+ details?: SubagentDetails;
16
+ }
17
+
18
+ export function parseBooleanEnv(raw: unknown): boolean | null {
19
+ if (typeof raw === "boolean") return raw;
20
+ if (typeof raw !== "string") return null;
21
+ const normalized = raw.trim().toLowerCase();
22
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
23
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
24
+ return null;
25
+ }
26
+
27
+ export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
28
+ const mainSessionDir = ctx.sessionManager.getSessionDir?.();
29
+ if (typeof mainSessionDir === "string" && mainSessionDir.length > 0) {
30
+ return path.join(path.dirname(mainSessionDir), "sessions-subagents");
31
+ }
32
+ return path.join(os.homedir(), ".pi", "agent", "sessions-subagents");
33
+ }
34
+
35
+ export function buildSubagentSessionDir(
36
+ root: string,
37
+ parentSessionId: string,
38
+ toolCallId: string,
39
+ index: number,
40
+ ): string {
41
+ const safeParent = parentSessionId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
42
+ const safeTool = toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
43
+ return path.join(root, safeParent, safeTool, String(index));
44
+ }
45
+
46
+ export function ensureDir(dir: string): void {
47
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ }
49
+
50
+ function branchEntries(ctx: ExtensionContext): SessionEntry[] {
51
+ const leafId = ctx.sessionManager.getLeafId?.();
52
+ if (leafId) {
53
+ const branch = ctx.sessionManager.getBranch?.(leafId);
54
+ if (Array.isArray(branch)) return branch as SessionEntry[];
55
+ }
56
+ const entries = ctx.sessionManager.getEntries?.();
57
+ return Array.isArray(entries) ? entries as SessionEntry[] : [];
58
+ }
59
+
60
+ function getSubagentToolCalls(message: any): Array<{ id: string; args: any }> {
61
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
62
+ const calls: Array<{ id: string; args: any }> = [];
63
+ for (const part of message.content) {
64
+ if (part?.type === "toolCall" && part.name === "subagent" && typeof part.id === "string") {
65
+ calls.push({ id: part.id, args: part.arguments });
66
+ }
67
+ }
68
+ return calls;
69
+ }
70
+
71
+ function normalizeTasks(args: any): Array<{ agent: string; task: string; cwd?: string }> | null {
72
+ const rawTasks = args?.tasks;
73
+ if (!Array.isArray(rawTasks) || rawTasks.length === 0) return null;
74
+ const tasks: Array<{ agent: string; task: string; cwd?: string }> = [];
75
+ for (const task of rawTasks) {
76
+ if (typeof task?.agent !== "string" || typeof task?.task !== "string") return null;
77
+ tasks.push({
78
+ agent: task.agent,
79
+ task: task.task,
80
+ ...(typeof task.cwd === "string" ? { cwd: task.cwd } : {}),
81
+ });
82
+ }
83
+ return tasks;
84
+ }
85
+
86
+ function hasUnfinishedResults(details: SubagentDetails | undefined): boolean {
87
+ if (!details) return true;
88
+ return details.results.some((result) => result.exitCode === -1 || isResultError(result));
89
+ }
90
+
91
+ function messageHasNonEmptyText(message: any): boolean {
92
+ const content = message?.content;
93
+ if (typeof content === "string") return content.trim().length > 0;
94
+ if (!Array.isArray(content)) return false;
95
+ return content.some((part) => part?.type === "text" && typeof part.text === "string" && part.text.trim().length > 0);
96
+ }
97
+
98
+ function messageHasToolCall(message: any): boolean {
99
+ return Array.isArray(message?.content) && message.content.some((part: any) => part?.type === "toolCall");
100
+ }
101
+
102
+ function isIgnorableTrailingAbortMessage(entry: any): boolean {
103
+ if (entry?.type !== "message") return true;
104
+ const message = entry.message;
105
+ if (!message) return true;
106
+
107
+ // Pi may append a final aborted/error assistant message after it has already
108
+ // closed an interrupted tool with a synthetic toolResult. That message is not
109
+ // user-visible progress after the subagent activity, so it must not prevent
110
+ // resume detection.
111
+ if (
112
+ message.role === "assistant" &&
113
+ (message.stopReason === "aborted" || message.stopReason === "error") &&
114
+ !messageHasNonEmptyText(message) &&
115
+ !messageHasToolCall(message)
116
+ ) {
117
+ return true;
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder: number): boolean {
124
+ for (let i = activityOrder + 1; i < entries.length; i++) {
125
+ if (!isIgnorableTrailingAbortMessage(entries[i])) return false;
126
+ }
127
+ return true;
128
+ }
129
+
130
+ export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
131
+ const entries = branchEntries(ctx);
132
+ const calls = new Map<string, { tasks: Array<{ agent: string; task: string; cwd?: string }>; order: number }>();
133
+ const results = new Map<string, { details?: SubagentDetails; isError: boolean; order: number }>();
134
+
135
+ entries.forEach((entry: any, order) => {
136
+ if (entry?.type !== "message") return;
137
+ const msg = entry.message;
138
+ for (const call of getSubagentToolCalls(msg)) {
139
+ const tasks = normalizeTasks(call.args);
140
+ if (tasks) calls.set(call.id, { tasks, order });
141
+ }
142
+ if (msg?.role === "toolResult" && msg.toolName === "subagent" && typeof msg.toolCallId === "string") {
143
+ results.set(msg.toolCallId, {
144
+ details: isSubagentDetails(msg.details) ? msg.details : undefined,
145
+ isError: msg.isError === true,
146
+ order,
147
+ });
148
+ }
149
+ });
150
+
151
+ const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
152
+ for (const [toolCallId, call] of calls) {
153
+ const result = results.get(toolCallId);
154
+ const unfinished = !result || result.isError || hasUnfinishedResults(result.details);
155
+ if (!unfinished) continue;
156
+ candidates.push({
157
+ previousToolCallId: toolCallId,
158
+ tasks: call.tasks,
159
+ details: result?.details,
160
+ activityOrder: result?.order ?? call.order,
161
+ });
162
+ }
163
+
164
+ const latest = candidates.at(-1);
165
+ if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
166
+ return latest;
167
+ }
168
+
169
+ export function sameTasks(
170
+ a: Array<{ agent: string; task: string; cwd?: string }>,
171
+ b: Array<{ agent: string; task: string; cwd?: string }>,
172
+ ): boolean {
173
+ return JSON.stringify(a) === JSON.stringify(b);
174
+ }
175
+
176
+ export function isFinishedResult(result: SingleResult | undefined): boolean {
177
+ return !!result && result.exitCode === 0 && !isResultError(result);
178
+ }
package/runner.ts CHANGED
@@ -365,16 +365,20 @@ function buildPiArgs(
365
365
  agent: AgentConfig,
366
366
  systemPromptPath: string | null,
367
367
  task: string,
368
+ sessionDir: string | undefined,
369
+ resumeSession: boolean,
368
370
  ): string[] {
369
371
  const args: string[] = [
370
372
  "--mode",
371
373
  "json",
372
374
  ..._inheritedCliArgs.extensionArgs,
373
375
  ..._inheritedCliArgs.alwaysProxy,
374
- "-p",
375
- "--no-session",
376
376
  ];
377
377
 
378
+ if (sessionDir) args.push("--session-dir", sessionDir);
379
+ if (resumeSession) args.push("--continue");
380
+ args.push("-p");
381
+
378
382
  // Agent config takes priority; fall back to parent CLI value
379
383
  const model = agent.model ?? _inheritedCliArgs.fallbackModel;
380
384
  if (model) args.push("--model", model);
@@ -401,7 +405,11 @@ function buildPiArgs(
401
405
  }
402
406
 
403
407
  if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
404
- args.push(`Task: ${task}`);
408
+ args.push(
409
+ resumeSession
410
+ ? `Continue the previous task from where you left off. Original task: ${task}`
411
+ : `Task: ${task}`,
412
+ );
405
413
  return args;
406
414
  }
407
415
 
@@ -434,6 +442,12 @@ export interface RunAgentOptions {
434
442
  onUpdate?: OnUpdateCallback;
435
443
  /** Factory to wrap results into SubagentDetails. */
436
444
  makeDetails: (results: SingleResult[]) => SubagentDetails;
445
+ /** Dedicated session directory for this subagent process. */
446
+ sessionDir?: string;
447
+ /** Continue the most recent session in sessionDir instead of creating a new one. */
448
+ resumeSession?: boolean;
449
+ /** Previously captured state for this same subagent, used to render resumed nested trees. */
450
+ initialResult?: SingleResult;
437
451
  }
438
452
 
439
453
  /**
@@ -455,6 +469,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
455
469
  signal,
456
470
  onUpdate,
457
471
  makeDetails,
472
+ sessionDir,
473
+ resumeSession = false,
474
+ initialResult,
458
475
  } = opts;
459
476
 
460
477
  const agent = agents.find((a) => a.name === agentName);
@@ -472,6 +489,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
472
489
  completedTurns: 0,
473
490
  turnInProgress: false,
474
491
  liveLog: [],
492
+ sessionDir: opts.sessionDir,
475
493
  };
476
494
  }
477
495
 
@@ -480,14 +498,16 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
480
498
  agentSource: agent.source,
481
499
  task,
482
500
  exitCode: -1,
483
- messages: [],
484
- stderr: "",
485
- usage: emptyUsage(),
486
- toolCalls: {},
487
- model: agent.model,
488
- completedTurns: 0,
501
+ messages: initialResult?.messages ? [...initialResult.messages] : [],
502
+ stderr: initialResult?.stderr ?? "",
503
+ usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
504
+ toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
505
+ model: initialResult?.model ?? agent.model,
506
+ completedTurns: initialResult?.completedTurns ?? 0,
489
507
  turnInProgress: false,
490
- liveLog: [],
508
+ liveToolExecutions: initialResult?.liveToolExecutions,
509
+ liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
510
+ sessionDir,
491
511
  };
492
512
 
493
513
  const emitUpdate = () => {
@@ -518,6 +538,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
518
538
  agent,
519
539
  promptTmpPath,
520
540
  task,
541
+ sessionDir,
542
+ resumeSession,
521
543
  );
522
544
  let wasAborted = false;
523
545
 
@@ -736,6 +758,9 @@ export async function executeParallelSubprocess(
736
758
  signal: AbortSignal | undefined,
737
759
  onUpdate: OnUpdateCallback | undefined,
738
760
  makeDetails: (results: SingleResult[]) => SubagentDetails,
761
+ resumeResults?: SingleResult[],
762
+ getSessionDir?: (index: number, task: { agent: string; task: string; cwd?: string }) => string | undefined,
763
+ resumeExistingSessions = false,
739
764
  ): Promise<{
740
765
  content: Array<{ type: "text"; text: string }>;
741
766
  details: SubagentDetails;
@@ -770,7 +795,7 @@ export async function executeParallelSubprocess(
770
795
  };
771
796
  }
772
797
 
773
- const allResults: SingleResult[] = tasks.map((t) => ({
798
+ const allResults: SingleResult[] = tasks.map((t, index) => resumeResults?.[index] ?? ({
774
799
  agent: t.agent,
775
800
  agentSource: "unknown" as const,
776
801
  task: t.task,
@@ -782,6 +807,7 @@ export async function executeParallelSubprocess(
782
807
  completedTurns: 0,
783
808
  turnInProgress: false,
784
809
  liveLog: [],
810
+ sessionDir: getSessionDir?.(index, t),
785
811
  }));
786
812
 
787
813
  const emitProgress = () => {
@@ -810,6 +836,13 @@ export async function executeParallelSubprocess(
810
836
  let results: SingleResult[];
811
837
  try {
812
838
  results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
839
+ const previousResult = resumeResults?.[index];
840
+ if (previousResult?.exitCode === 0) {
841
+ allResults[index] = previousResult;
842
+ emitProgress();
843
+ return previousResult;
844
+ }
845
+ const sessionDir = previousResult?.sessionDir ?? getSessionDir?.(index, t);
813
846
  const result = await runAgentSubprocess({
814
847
  cwd: defaultCwd,
815
848
  agents,
@@ -821,6 +854,9 @@ export async function executeParallelSubprocess(
821
854
  maxDepth,
822
855
  preventCycles,
823
856
  signal,
857
+ sessionDir,
858
+ resumeSession: resumeExistingSessions && !!sessionDir,
859
+ initialResult: previousResult,
824
860
  onUpdate: (partial) => {
825
861
  if (partial.details?.results[0]) {
826
862
  allResults[index] = partial.details.results[0];
package/types.ts CHANGED
@@ -45,6 +45,8 @@ export interface SingleResult {
45
45
  model?: string;
46
46
  stopReason?: string;
47
47
  errorMessage?: string;
48
+ /** Session directory used by this subagent process, when persisted. */
49
+ sessionDir?: string;
48
50
  /** Number of LLM turns completed so far in this agent run. */
49
51
  completedTurns: number;
50
52
  /** True while an LLM call is currently in flight (between turn_start and turn_end). */