oira666_pi-subagent 0.2.3 → 0.2.5

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
@@ -9,10 +9,25 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import {
13
+ createAssistantMessageEventStream,
14
+ streamSimple as streamModelSimple,
15
+ } from "@mariozechner/pi-ai";
12
16
  import { Type } from "@sinclair/typebox";
13
17
  import { type AgentConfig, discoverAgents } from "./agents.js";
14
18
  import { renderCall, renderResult } from "./render.js";
15
19
  import { runAgentSubprocess, executeParallelSubprocess } from "./runner.js";
20
+ import {
21
+ SUBAGENT_RESUME_DISABLE_ENV,
22
+ SUBAGENT_RESUME_PROMPT_ENV,
23
+ buildSubagentSessionDir,
24
+ findLatestResumableSubagentCall,
25
+ getDefaultSubagentSessionRoot,
26
+ isFinishedResult,
27
+ parseBooleanEnv,
28
+ sameTasks,
29
+ type ResumableSubagentCall,
30
+ } from "./resume.js";
16
31
  import {
17
32
  DEFAULT_MAX_PARALLEL_TASKS,
18
33
  SUBAGENT_MAX_PARALLEL_TASKS_ENV,
@@ -339,11 +354,82 @@ function getProjectAgentSessionKey(projectAgentsDir: string | null): string {
339
354
  return projectAgentsDir ?? "(unknown-project-agents-dir)";
340
355
  }
341
356
 
357
+ function ensureSubagentToolActive(pi: ExtensionAPI): void {
358
+ const activeTools = pi.getActiveTools();
359
+ if (!activeTools.includes("subagent")) {
360
+ pi.setActiveTools([...activeTools, "subagent"]);
361
+ }
362
+ }
363
+
364
+ function hasCliInitialPrompt(argv: string[]): boolean {
365
+ for (let i = 2; i < argv.length; i++) {
366
+ const arg = argv[i];
367
+ if (arg === "-p" || arg === "--print") return true;
368
+ }
369
+ return false;
370
+ }
371
+
372
+ const RESUME_PROVIDER = "pi-subagent-resume";
373
+ const RESUME_MODEL_ID = "synthetic-tool-call";
374
+ const RESUME_STATE_KEY = "__piSubagentResumeState";
375
+
376
+ type SyntheticResumeState = {
377
+ plan: ResumableSubagentCall | null;
378
+ phase: "tool" | "final";
379
+ };
380
+
381
+ function clearSyntheticResumeState(): void {
382
+ const state = getSyntheticResumeState();
383
+ state.plan = null;
384
+ state.phase = "tool";
385
+ }
386
+
387
+ function getSyntheticResumeState(): SyntheticResumeState {
388
+ const g = globalThis as any;
389
+ if (!g[RESUME_STATE_KEY]) {
390
+ g[RESUME_STATE_KEY] = { plan: null, phase: "tool" } satisfies SyntheticResumeState;
391
+ }
392
+ return g[RESUME_STATE_KEY] as SyntheticResumeState;
393
+ }
394
+
395
+ function emptyModelUsage() {
396
+ return {
397
+ input: 0,
398
+ output: 0,
399
+ cacheRead: 0,
400
+ cacheWrite: 0,
401
+ totalTokens: 0,
402
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
403
+ };
404
+ }
405
+
406
+ function getMessageText(message: any): string {
407
+ const content = message?.content;
408
+ if (typeof content === "string") return content;
409
+ if (!Array.isArray(content)) return "";
410
+ return content
411
+ .map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
412
+ .join("");
413
+ }
414
+
415
+ function isSyntheticResumePrompt(context: any, taskCount: number): boolean {
416
+ const messages = Array.isArray(context?.messages) ? context.messages : [];
417
+ const lastUser = [...messages].reverse().find((message) => message?.role === "user");
418
+ return getMessageText(lastUser).trim() === `Resuming ${taskCount} subagents...`;
419
+ }
420
+
421
+ function formatModelFlag(model: any): string | undefined {
422
+ if (!model?.id || !model?.provider) return undefined;
423
+ return `${model.provider}/${model.id}`;
424
+ }
425
+
342
426
  // ---------------------------------------------------------------------------
343
427
  // Extension entry point
344
428
  // ---------------------------------------------------------------------------
345
429
 
346
430
  export default function (pi: ExtensionAPI) {
431
+ let resumeModelRegistry: any | undefined;
432
+
347
433
  pi.registerFlag("subagent-max-depth", {
348
434
  description: "Maximum allowed subagent delegation depth (default: 3).",
349
435
  type: "string",
@@ -354,6 +440,97 @@ export default function (pi: ExtensionAPI) {
354
440
  type: "boolean",
355
441
  });
356
442
 
443
+ pi.registerProvider(RESUME_PROVIDER, {
444
+ baseUrl: "http://127.0.0.1/pi-subagent-resume",
445
+ api: "openai-responses",
446
+ apiKey: "pi-subagent-resume-noop-key",
447
+ streamSimple: async (model, context, options) => {
448
+ const stream = createAssistantMessageEventStream();
449
+ const state = getSyntheticResumeState();
450
+ const plan = state.plan;
451
+ const phase = state.phase;
452
+ if (plan && phase === "tool" && isSyntheticResumePrompt(context, plan.tasks.length)) {
453
+ state.phase = "final";
454
+ const toolCall = {
455
+ type: "toolCall" as const,
456
+ id: `resume_subagent_${Date.now()}`,
457
+ name: "subagent",
458
+ arguments: { tasks: plan.tasks },
459
+ };
460
+ const message = {
461
+ role: "assistant" as const,
462
+ content: [toolCall],
463
+ api: model.api,
464
+ provider: model.provider,
465
+ model: model.id,
466
+ usage: emptyModelUsage(),
467
+ stopReason: "toolUse" as const,
468
+ timestamp: Date.now(),
469
+ };
470
+ queueMicrotask(() => {
471
+ stream.push({ type: "start", partial: message });
472
+ stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
473
+ stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: message });
474
+ stream.push({ type: "done", reason: "toolUse", message });
475
+ stream.end(message);
476
+ });
477
+ return stream;
478
+ }
479
+
480
+ if (phase === "final" && modelToRestoreAfterResume) {
481
+ const restore = modelToRestoreAfterResume;
482
+ const auth = resumeModelRegistry
483
+ ? await resumeModelRegistry.getApiKeyAndHeaders(restore)
484
+ : { ok: true, apiKey: undefined, headers: undefined };
485
+ if (!auth.ok) {
486
+ throw new Error(auth.error);
487
+ }
488
+ return streamModelSimple(restore, context, {
489
+ ...options,
490
+ apiKey: auth.apiKey,
491
+ headers: auth.headers,
492
+ });
493
+ }
494
+
495
+ if (!(plan && phase === "tool")) {
496
+ state.plan = null;
497
+ state.phase = "tool";
498
+ }
499
+ const message = {
500
+ role: "assistant" as const,
501
+ content: [],
502
+ api: model.api,
503
+ provider: model.provider,
504
+ model: model.id,
505
+ usage: emptyModelUsage(),
506
+ stopReason: "stop" as const,
507
+ timestamp: Date.now(),
508
+ };
509
+ queueMicrotask(() => {
510
+ stream.push({ type: "start", partial: message });
511
+ stream.push({ type: "done", reason: "stop", message });
512
+ stream.end(message);
513
+ });
514
+ return stream;
515
+ },
516
+ models: [
517
+ {
518
+ id: RESUME_MODEL_ID,
519
+ name: "Pi Subagent Resume",
520
+ api: "openai-responses",
521
+ reasoning: false,
522
+ input: ["text"],
523
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
524
+ // Keep this large so Pi's pre-prompt auto-compaction does not call the
525
+ // synthetic provider before the visible resume prompt is appended. That
526
+ // would consume the tool-call phase during compaction and the real
527
+ // resume turn would only see the final text message.
528
+ contextWindow: 1_000_000,
529
+ maxTokens: 16,
530
+ },
531
+ ],
532
+ });
533
+
357
534
  const depthConfig = resolveDelegationDepthConfig(pi);
358
535
  const { currentDepth, maxDepth, canDelegate, ancestorAgentStack, preventCycles } =
359
536
  depthConfig;
@@ -362,14 +539,35 @@ export default function (pi: ExtensionAPI) {
362
539
  DEFAULT_MAX_PARALLEL_TASKS;
363
540
 
364
541
  let discoveredAgents: AgentConfig[] = [];
542
+ let currentSessionId = "ephemeral";
543
+ let currentSubagentSessionRoot = "";
544
+ let pendingResumePlan: ResumableSubagentCall | null = null;
545
+ let modelToRestoreAfterResume: any | undefined;
365
546
  const approvedProjectAgentDirsForSession = new Set<string>();
366
547
 
548
+ async function restoreModelAfterResumeFailure(ctx?: { ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void } }) {
549
+ const restore = modelToRestoreAfterResume;
550
+ modelToRestoreAfterResume = undefined;
551
+ pendingResumePlan = null;
552
+ clearSyntheticResumeState();
553
+ if (!restore) return;
554
+ try {
555
+ await pi.setModel(restore);
556
+ } catch (err) {
557
+ const message = err instanceof Error ? err.message : String(err);
558
+ ctx?.ui?.notify?.(`Failed to restore model after subagent resume error: ${message}`, "error");
559
+ console.error("[pi-subagent] Failed to restore model after resume error:", err);
560
+ }
561
+ }
562
+
367
563
  // Auto-discover agents on session start
368
- pi.on("session_start", async (_event, ctx) => {
564
+ pi.on("session_start", async (event, ctx) => {
369
565
  if (!canDelegate) return;
370
566
  try {
371
567
  const discovery = discoverAgents(ctx.cwd, "both");
372
568
  discoveredAgents = discovery.agents;
569
+ currentSessionId = ctx.sessionManager.getSessionId?.() ?? "ephemeral";
570
+ currentSubagentSessionRoot = getDefaultSubagentSessionRoot(ctx);
373
571
 
374
572
  if (discoveredAgents.length > 0 && ctx.hasUI) {
375
573
  const list = discoveredAgents
@@ -380,11 +578,56 @@ export default function (pi: ExtensionAPI) {
380
578
  "info",
381
579
  );
382
580
  }
581
+
582
+ const resumeDisabled = parseBooleanEnv(process.env[SUBAGENT_RESUME_DISABLE_ENV]) === true;
583
+ if (resumeDisabled || (event.reason !== "resume" && event.reason !== "startup")) return;
584
+
585
+ const plan = findLatestResumableSubagentCall(ctx);
586
+ if (!plan) return;
587
+
588
+ let shouldResume = true;
589
+ const shouldPrompt = parseBooleanEnv(process.env[SUBAGENT_RESUME_PROMPT_ENV]) !== false;
590
+ if (ctx.hasUI && shouldPrompt) {
591
+ shouldResume = await ctx.ui.confirm(
592
+ "Resume subagents?",
593
+ `The resumed session has an unfinished subagent call (${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"}). Resume it from saved subagent sessions?`,
594
+ );
595
+ }
596
+ if (!shouldResume) return;
597
+
598
+ pendingResumePlan = plan;
599
+ const resumeState = getSyntheticResumeState();
600
+ resumeState.plan = plan;
601
+ resumeState.phase = "tool";
602
+ modelToRestoreAfterResume = ctx.model;
603
+ resumeModelRegistry = ctx.modelRegistry;
604
+ ensureSubagentToolActive(pi);
605
+ const resumeModel = ctx.modelRegistry.find(RESUME_PROVIDER, RESUME_MODEL_ID);
606
+ if (!resumeModel || !(await pi.setModel(resumeModel))) {
607
+ ctx.ui.notify("Failed to switch to synthetic subagent resume model.", "error");
608
+ await restoreModelAfterResumeFailure(ctx);
609
+ return;
610
+ }
611
+
612
+ // In print/json subprocesses there is already an initial CLI prompt about
613
+ // to be sent. That prompt will be answered by the synthetic provider with
614
+ // a real assistant subagent tool call. In interactive mode, submit a short
615
+ // visible prompt that triggers the same synthetic provider path.
616
+ if (hasCliInitialPrompt(process.argv)) {
617
+ if (ctx.hasUI) ctx.ui.notify(`Resuming ${plan.tasks.length} subagents...`, "info");
618
+ } else {
619
+ pi.sendUserMessage(`Resuming ${plan.tasks.length} subagents...`);
620
+ }
383
621
  } catch (err) {
384
622
  console.error("[pi-subagent] Error in session_start:", err);
623
+ await restoreModelAfterResumeFailure(ctx);
385
624
  }
386
625
  });
387
626
 
627
+ pi.on("agent_end", async () => {
628
+ await restoreModelAfterResumeFailure();
629
+ });
630
+
388
631
  // Inject available agents into the system prompt
389
632
  pi.on("before_agent_start", async (event) => {
390
633
  try {
@@ -453,7 +696,7 @@ calls one after another. Do NOT put dependent tasks in the same array.
453
696
  ].join("\n"),
454
697
  parameters: SubagentParams,
455
698
 
456
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
699
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
457
700
  try {
458
701
  const discovery = discoverAgents(ctx.cwd, "both");
459
702
  const { agents } = discovery;
@@ -558,6 +801,14 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
558
801
  }
559
802
  }
560
803
 
804
+ const resumePlan =
805
+ pendingResumePlan && sameTasks(pendingResumePlan.tasks, tasks)
806
+ ? pendingResumePlan
807
+ : null;
808
+ if (resumePlan) {
809
+ pendingResumePlan = null;
810
+ }
811
+
561
812
  if (tasks.length === 1) {
562
813
  const [task] = tasks;
563
814
  return executeSingle(
@@ -569,6 +820,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
569
820
  signal,
570
821
  onUpdate,
571
822
  makeDetails,
823
+ resumePlan?.details?.results[0],
824
+ getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, 0),
825
+ !!resumePlan,
826
+ formatModelFlag(modelToRestoreAfterResume),
572
827
  );
573
828
  }
574
829
 
@@ -579,6 +834,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
579
834
  signal,
580
835
  onUpdate,
581
836
  makeDetails,
837
+ resumePlan?.details?.results,
838
+ (index) => getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, index),
839
+ !!resumePlan,
840
+ formatModelFlag(modelToRestoreAfterResume),
582
841
  );
583
842
  } catch (err) {
584
843
  const msg = err instanceof Error ? err.message : String(err);
@@ -592,12 +851,21 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
592
851
 
593
852
  },
594
853
 
595
- renderCall: (args, theme) => renderCall(args, theme),
854
+ renderCall: (args, theme, context) => renderCall(args, theme, context),
596
855
  renderResult: (result, { expanded }, theme) =>
597
856
  renderResult(result, expanded, theme),
598
857
  });
599
858
  }
600
859
 
860
+ function getSessionDirForTask(toolCallId: string, index: number): string {
861
+ const root = currentSubagentSessionRoot || pathlessSubagentRootFallback();
862
+ return buildSubagentSessionDir(root, currentSessionId, toolCallId, index);
863
+ }
864
+
865
+ function pathlessSubagentRootFallback(): string {
866
+ return `${process.env.HOME ?? "."}/.pi/agent/sessions-subagents`;
867
+ }
868
+
601
869
  // -----------------------------------------------------------------------
602
870
  // Mode implementations
603
871
  // -----------------------------------------------------------------------
@@ -611,7 +879,23 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
611
879
  signal: AbortSignal | undefined,
612
880
  onUpdate: ((partial: any) => void) | undefined,
613
881
  makeDetails: ReturnType<typeof makeDetailsFactory>,
882
+ previousResult: SingleResult | undefined,
883
+ sessionDir: string,
884
+ resumeExistingSession: boolean,
885
+ fallbackModel?: string,
614
886
  ) {
887
+ if (previousResult && isFinishedResult(previousResult)) {
888
+ return {
889
+ content: [
890
+ {
891
+ type: "text" as const,
892
+ text: getFinalOutput(previousResult.messages) || "(no output)",
893
+ },
894
+ ],
895
+ details: makeDetails("single")([previousResult]),
896
+ };
897
+ }
898
+
615
899
  const result = await runAgentSubprocess({
616
900
  cwd: defaultCwd,
617
901
  agents,
@@ -625,6 +909,11 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
625
909
  signal,
626
910
  onUpdate,
627
911
  makeDetails: makeDetails("single"),
912
+ sessionDir: previousResult?.sessionDir ?? sessionDir,
913
+ sessionRoot: currentSubagentSessionRoot,
914
+ resumeSession: resumeExistingSession,
915
+ initialResult: previousResult,
916
+ fallbackModel,
628
917
  });
629
918
 
630
919
  if (isResultError(result)) {
@@ -662,6 +951,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
662
951
  signal: AbortSignal | undefined,
663
952
  onUpdate: ((partial: any) => void) | undefined,
664
953
  makeDetails: ReturnType<typeof makeDetailsFactory>,
954
+ resumeResults: SingleResult[] | undefined,
955
+ getSessionDir: (index: number) => string,
956
+ resumeExistingSessions: boolean,
957
+ fallbackModel?: string,
665
958
  ) {
666
959
  return executeParallelSubprocess(
667
960
  tasks,
@@ -674,6 +967,11 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
674
967
  signal,
675
968
  onUpdate,
676
969
  makeDetails("parallel"),
970
+ resumeResults,
971
+ (index) => getSessionDir(index),
972
+ resumeExistingSessions,
973
+ currentSubagentSessionRoot,
974
+ fallbackModel,
677
975
  );
678
976
  }
679
977
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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
 
@@ -365,14 +400,23 @@ function topLevelSummary(details: SubagentDetails, counts: TreeCounts): string {
365
400
  // renderCall — shown while the tool is being invoked
366
401
  // ---------------------------------------------------------------------------
367
402
 
368
- export function renderCall(args: Record<string, any>, theme: { fg: ThemeFg; bold: (s: string) => string }): Text {
403
+ export function renderCall(
404
+ args: Record<string, any>,
405
+ theme: { fg: ThemeFg; bold: (s: string) => string },
406
+ context?: { isPartial?: boolean; isError?: boolean },
407
+ ): Text {
369
408
  const tasks = Array.isArray(args.tasks) ? args.tasks : [];
370
409
  const count = tasks.length;
410
+ const icon = context?.isPartial === false
411
+ ? context.isError
412
+ ? theme.fg("error", "❌")
413
+ : theme.fg("success", "✅")
414
+ : theme.fg("warning", "⏳");
371
415
  let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `${count} task${count === 1 ? "" : "s"}`)}`;
372
416
  for (const task of tasks.slice(0, 6)) {
373
417
  const agent = typeof task?.agent === "string" ? task.agent : "...";
374
418
  const preview = typeof task?.task === "string" ? ` ${truncate(task.task, 56)}` : "";
375
- text += `\n ${theme.fg("warning", "⏳")} ${theme.fg("accent", agent)}${theme.fg("dim", preview)}`;
419
+ text += `\n ${icon} ${theme.fg("accent", agent)}${theme.fg("dim", preview)}`;
376
420
  }
377
421
  if (tasks.length > 6) text += `\n ${theme.fg("muted", `... +${tasks.length - 6} more`)}`;
378
422
  return new Text(text, 0, 0);
package/resume.ts ADDED
@@ -0,0 +1,182 @@
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
+ export const SUBAGENT_SESSION_ROOT_ENV = "PI_SUBAGENT_SESSION_ROOT";
10
+
11
+ type SessionEntry = ReturnType<ExtensionContext["sessionManager"]["getEntries"]>[number];
12
+
13
+ export interface ResumableSubagentCall {
14
+ previousToolCallId: string;
15
+ tasks: Array<{ agent: string; task: string; cwd?: string }>;
16
+ details?: SubagentDetails;
17
+ }
18
+
19
+ export function parseBooleanEnv(raw: unknown): boolean | null {
20
+ if (typeof raw === "boolean") return raw;
21
+ if (typeof raw !== "string") return null;
22
+ const normalized = raw.trim().toLowerCase();
23
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
24
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
25
+ return null;
26
+ }
27
+
28
+ export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
29
+ const inheritedRoot = process.env[SUBAGENT_SESSION_ROOT_ENV];
30
+ if (inheritedRoot) return inheritedRoot;
31
+
32
+ const mainSessionDir = ctx.sessionManager.getSessionDir?.();
33
+ if (typeof mainSessionDir === "string" && mainSessionDir.length > 0) {
34
+ return path.join(path.dirname(mainSessionDir), "sessions-subagents");
35
+ }
36
+ return path.join(os.homedir(), ".pi", "agent", "sessions-subagents");
37
+ }
38
+
39
+ export function buildSubagentSessionDir(
40
+ root: string,
41
+ parentSessionId: string,
42
+ toolCallId: string,
43
+ index: number,
44
+ ): string {
45
+ const safeParent = parentSessionId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
46
+ const safeTool = toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
47
+ return path.join(root, safeParent, safeTool, String(index));
48
+ }
49
+
50
+ export function ensureDir(dir: string): void {
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ }
53
+
54
+ function branchEntries(ctx: ExtensionContext): SessionEntry[] {
55
+ const leafId = ctx.sessionManager.getLeafId?.();
56
+ if (leafId) {
57
+ const branch = ctx.sessionManager.getBranch?.(leafId);
58
+ if (Array.isArray(branch)) return branch as SessionEntry[];
59
+ }
60
+ const entries = ctx.sessionManager.getEntries?.();
61
+ return Array.isArray(entries) ? entries as SessionEntry[] : [];
62
+ }
63
+
64
+ function getSubagentToolCalls(message: any): Array<{ id: string; args: any }> {
65
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
66
+ const calls: Array<{ id: string; args: any }> = [];
67
+ for (const part of message.content) {
68
+ if (part?.type === "toolCall" && part.name === "subagent" && typeof part.id === "string") {
69
+ calls.push({ id: part.id, args: part.arguments });
70
+ }
71
+ }
72
+ return calls;
73
+ }
74
+
75
+ function normalizeTasks(args: any): Array<{ agent: string; task: string; cwd?: string }> | null {
76
+ const rawTasks = args?.tasks;
77
+ if (!Array.isArray(rawTasks) || rawTasks.length === 0) return null;
78
+ const tasks: Array<{ agent: string; task: string; cwd?: string }> = [];
79
+ for (const task of rawTasks) {
80
+ if (typeof task?.agent !== "string" || typeof task?.task !== "string") return null;
81
+ tasks.push({
82
+ agent: task.agent,
83
+ task: task.task,
84
+ ...(typeof task.cwd === "string" ? { cwd: task.cwd } : {}),
85
+ });
86
+ }
87
+ return tasks;
88
+ }
89
+
90
+ function hasUnfinishedResults(details: SubagentDetails | undefined): boolean {
91
+ if (!details) return true;
92
+ return details.results.some((result) => result.exitCode === -1 || isResultError(result));
93
+ }
94
+
95
+ function messageHasNonEmptyText(message: any): boolean {
96
+ const content = message?.content;
97
+ if (typeof content === "string") return content.trim().length > 0;
98
+ if (!Array.isArray(content)) return false;
99
+ return content.some((part) => part?.type === "text" && typeof part.text === "string" && part.text.trim().length > 0);
100
+ }
101
+
102
+ function messageHasToolCall(message: any): boolean {
103
+ return Array.isArray(message?.content) && message.content.some((part: any) => part?.type === "toolCall");
104
+ }
105
+
106
+ function isIgnorableTrailingAbortMessage(entry: any): boolean {
107
+ if (entry?.type !== "message") return true;
108
+ const message = entry.message;
109
+ if (!message) return true;
110
+
111
+ // Pi may append a final aborted/error assistant message after it has already
112
+ // closed an interrupted tool with a synthetic toolResult. That message is not
113
+ // user-visible progress after the subagent activity, so it must not prevent
114
+ // resume detection.
115
+ if (
116
+ message.role === "assistant" &&
117
+ (message.stopReason === "aborted" || message.stopReason === "error") &&
118
+ !messageHasNonEmptyText(message) &&
119
+ !messageHasToolCall(message)
120
+ ) {
121
+ return true;
122
+ }
123
+
124
+ return false;
125
+ }
126
+
127
+ function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder: number): boolean {
128
+ for (let i = activityOrder + 1; i < entries.length; i++) {
129
+ if (!isIgnorableTrailingAbortMessage(entries[i])) return false;
130
+ }
131
+ return true;
132
+ }
133
+
134
+ export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
135
+ const entries = branchEntries(ctx);
136
+ const calls = new Map<string, { tasks: Array<{ agent: string; task: string; cwd?: string }>; order: number }>();
137
+ const results = new Map<string, { details?: SubagentDetails; isError: boolean; order: number }>();
138
+
139
+ entries.forEach((entry: any, order) => {
140
+ if (entry?.type !== "message") return;
141
+ const msg = entry.message;
142
+ for (const call of getSubagentToolCalls(msg)) {
143
+ const tasks = normalizeTasks(call.args);
144
+ if (tasks) calls.set(call.id, { tasks, order });
145
+ }
146
+ if (msg?.role === "toolResult" && msg.toolName === "subagent" && typeof msg.toolCallId === "string") {
147
+ results.set(msg.toolCallId, {
148
+ details: isSubagentDetails(msg.details) ? msg.details : undefined,
149
+ isError: msg.isError === true,
150
+ order,
151
+ });
152
+ }
153
+ });
154
+
155
+ const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
156
+ for (const [toolCallId, call] of calls) {
157
+ const result = results.get(toolCallId);
158
+ const unfinished = !result || result.isError || hasUnfinishedResults(result.details);
159
+ if (!unfinished) continue;
160
+ candidates.push({
161
+ previousToolCallId: toolCallId,
162
+ tasks: call.tasks,
163
+ details: result?.details,
164
+ activityOrder: result?.order ?? call.order,
165
+ });
166
+ }
167
+
168
+ const latest = candidates.at(-1);
169
+ if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
170
+ return latest;
171
+ }
172
+
173
+ export function sameTasks(
174
+ a: Array<{ agent: string; task: string; cwd?: string }>,
175
+ b: Array<{ agent: string; task: string; cwd?: string }>,
176
+ ): boolean {
177
+ return JSON.stringify(a) === JSON.stringify(b);
178
+ }
179
+
180
+ export function isFinishedResult(result: SingleResult | undefined): boolean {
181
+ return !!result && result.exitCode === 0 && !isResultError(result);
182
+ }
package/runner.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  getFinalOutput,
22
22
  getNestedSubagentErrorSummary,
23
23
  } from "./types.js";
24
+ import { SUBAGENT_SESSION_ROOT_ENV } from "./resume.js";
24
25
  import {
25
26
  DEFAULT_MAX_PARALLEL_TASKS,
26
27
  DEFAULT_MAX_CONCURRENCY,
@@ -51,6 +52,7 @@ const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
51
52
  const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
52
53
  const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
53
54
  const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
55
+ const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
54
56
  // PI_OFFLINE intentionally removed: setting it on child processes blocks all API
55
57
  // calls and renders subagents unable to do any LLM work. Children inherit the
56
58
  // parent's PI_OFFLINE value via process.env spread if needed.
@@ -81,25 +83,6 @@ function cleanupTempDir(dir: string | null): void {
81
83
  }
82
84
  }
83
85
 
84
- function resolveWorkingDirectory(input: string, fallbackBase: string): string {
85
- if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
86
- if (path.isAbsolute(input)) return input;
87
- return path.resolve(fallbackBase, input);
88
- }
89
-
90
- function getInvalidWorkingDirectoryMessage(input: string, resolved: string): string | null {
91
- try {
92
- const stat = fs.statSync(resolved);
93
- if (!stat.isDirectory()) {
94
- return `Invalid cwd "${input}" resolved to "${resolved}", but it is not a directory.`;
95
- }
96
- return null;
97
- } catch (err) {
98
- const detail = err instanceof Error ? err.message : String(err);
99
- return `Invalid cwd "${input}" resolved to "${resolved}": ${detail}`;
100
- }
101
- }
102
-
103
86
  function getCurrentPiCliScript(): string | null {
104
87
  const script = process.argv[1];
105
88
  if (!script) return null;
@@ -324,7 +307,7 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
324
307
  result.usage.cost += usage.cost?.total || 0;
325
308
  result.usage.contextTokens = usage.totalTokens || 0;
326
309
  }
327
- if (!result.model && msg.model) result.model = msg.model;
310
+ if (msg.model && msg.model !== "synthetic-tool-call") result.model = msg.model;
328
311
  if (msg.stopReason) result.stopReason = msg.stopReason;
329
312
  if (msg.errorMessage) result.errorMessage = msg.errorMessage;
330
313
  }
@@ -384,18 +367,23 @@ function buildPiArgs(
384
367
  agent: AgentConfig,
385
368
  systemPromptPath: string | null,
386
369
  task: string,
370
+ sessionDir: string | undefined,
371
+ resumeSession: boolean,
372
+ fallbackModelOverride?: string,
387
373
  ): string[] {
388
374
  const args: string[] = [
389
375
  "--mode",
390
376
  "json",
391
377
  ..._inheritedCliArgs.extensionArgs,
392
378
  ..._inheritedCliArgs.alwaysProxy,
393
- "-p",
394
- "--no-session",
395
379
  ];
396
380
 
381
+ if (sessionDir) args.push("--session-dir", sessionDir);
382
+ if (resumeSession) args.push("--continue");
383
+ args.push("-p");
384
+
397
385
  // Agent config takes priority; fall back to parent CLI value
398
- const model = agent.model ?? _inheritedCliArgs.fallbackModel;
386
+ const model = agent.model ?? fallbackModelOverride ?? process.env[SUBAGENT_FALLBACK_MODEL_ENV] ?? _inheritedCliArgs.fallbackModel;
399
387
  if (model) args.push("--model", model);
400
388
 
401
389
  const thinking = agent.thinking ?? _inheritedCliArgs.fallbackThinking;
@@ -420,7 +408,11 @@ function buildPiArgs(
420
408
  }
421
409
 
422
410
  if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
423
- args.push(`Task: ${task}`);
411
+ args.push(
412
+ resumeSession
413
+ ? `Continue the previous task from where you left off. Original task: ${task}`
414
+ : `Task: ${task}`,
415
+ );
424
416
  return args;
425
417
  }
426
418
 
@@ -453,6 +445,16 @@ export interface RunAgentOptions {
453
445
  onUpdate?: OnUpdateCallback;
454
446
  /** Factory to wrap results into SubagentDetails. */
455
447
  makeDetails: (results: SingleResult[]) => SubagentDetails;
448
+ /** Dedicated session directory for this subagent process. */
449
+ sessionDir?: string;
450
+ /** Top-level root for all subagent session directories in this delegation tree. */
451
+ sessionRoot?: string;
452
+ /** Continue the most recent session in sessionDir instead of creating a new one. */
453
+ resumeSession?: boolean;
454
+ /** Previously captured state for this same subagent, used to render resumed nested trees. */
455
+ initialResult?: SingleResult;
456
+ /** Fallback model to use when the agent config does not pin one. */
457
+ fallbackModel?: string;
456
458
  }
457
459
 
458
460
  /**
@@ -474,6 +476,11 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
474
476
  signal,
475
477
  onUpdate,
476
478
  makeDetails,
479
+ sessionDir,
480
+ sessionRoot,
481
+ resumeSession = false,
482
+ initialResult,
483
+ fallbackModel,
477
484
  } = opts;
478
485
 
479
486
  const agent = agents.find((a) => a.name === agentName);
@@ -491,6 +498,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
491
498
  completedTurns: 0,
492
499
  turnInProgress: false,
493
500
  liveLog: [],
501
+ sessionDir: opts.sessionDir,
494
502
  };
495
503
  }
496
504
 
@@ -499,14 +507,16 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
499
507
  agentSource: agent.source,
500
508
  task,
501
509
  exitCode: -1,
502
- messages: [],
503
- stderr: "",
504
- usage: emptyUsage(),
505
- toolCalls: {},
506
- model: agent.model,
507
- completedTurns: 0,
510
+ messages: initialResult?.messages ? [...initialResult.messages] : [],
511
+ stderr: initialResult?.stderr ?? "",
512
+ usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
513
+ toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
514
+ model: initialResult?.model ?? agent.model,
515
+ completedTurns: initialResult?.completedTurns ?? 0,
508
516
  turnInProgress: false,
509
- liveLog: [],
517
+ liveToolExecutions: initialResult?.liveToolExecutions,
518
+ liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
519
+ sessionDir,
510
520
  };
511
521
 
512
522
  const emitUpdate = () => {
@@ -523,18 +533,6 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
523
533
 
524
534
  emitUpdate();
525
535
 
526
- const requestedCwd = taskCwd ?? cwd;
527
- const resolvedCwd = resolveWorkingDirectory(requestedCwd, cwd);
528
- const invalidCwdMessage = getInvalidWorkingDirectoryMessage(requestedCwd, resolvedCwd);
529
- if (invalidCwdMessage) {
530
- result.exitCode = 1;
531
- result.stopReason = "error";
532
- result.errorMessage = invalidCwdMessage;
533
- result.stderr = invalidCwdMessage;
534
- emitUpdate();
535
- return result;
536
- }
537
-
538
536
  // Write system prompt to temp file if needed
539
537
  let promptTmpDir: string | null = null;
540
538
  let promptTmpPath: string | null = null;
@@ -549,6 +547,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
549
547
  agent,
550
548
  promptTmpPath,
551
549
  task,
550
+ sessionDir,
551
+ resumeSession,
552
+ fallbackModel,
552
553
  );
553
554
  let wasAborted = false;
554
555
 
@@ -564,7 +565,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
564
565
  const spawnCmd = currentPiCli ? process.execPath : "pi";
565
566
  const spawnArgs = currentPiCli ? [currentPiCli, ...piArgs] : piArgs;
566
567
  const proc = spawn(spawnCmd, spawnArgs, {
567
- cwd: resolvedCwd,
568
+ cwd: taskCwd ?? cwd,
568
569
  shell: false,
569
570
  stdio: ["ignore", "pipe", "pipe"],
570
571
  env: {
@@ -573,6 +574,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
573
574
  [SUBAGENT_MAX_DEPTH_ENV]: String(propagatedMaxDepth),
574
575
  [SUBAGENT_STACK_ENV]: JSON.stringify(propagatedStack),
575
576
  [SUBAGENT_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
577
+ ...(sessionRoot ? { [SUBAGENT_SESSION_ROOT_ENV]: sessionRoot } : {}),
578
+ ...(fallbackModel ? { [SUBAGENT_FALLBACK_MODEL_ENV]: fallbackModel } : {}),
576
579
  // PI_OFFLINE is NOT forced here — see explanation near PI_OFFLINE_ENV.
577
580
  },
578
581
  });
@@ -698,10 +701,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
698
701
  });
699
702
 
700
703
  proc.on("error", (err) => {
701
- const message = `Failed to spawn pi process in cwd "${resolvedCwd}": ${err.message}`;
702
- result.stderr += result.stderr ? `\n${message}` : message;
704
+ result.stderr += `Spawn error: ${err.message}`;
703
705
  result.stopReason = "error";
704
- result.errorMessage = message;
706
+ result.errorMessage = `Failed to spawn pi process: ${err.message}`;
705
707
  doResolve(1);
706
708
  });
707
709
 
@@ -768,6 +770,11 @@ export async function executeParallelSubprocess(
768
770
  signal: AbortSignal | undefined,
769
771
  onUpdate: OnUpdateCallback | undefined,
770
772
  makeDetails: (results: SingleResult[]) => SubagentDetails,
773
+ resumeResults?: SingleResult[],
774
+ getSessionDir?: (index: number, task: { agent: string; task: string; cwd?: string }) => string | undefined,
775
+ resumeExistingSessions = false,
776
+ sessionRoot?: string,
777
+ fallbackModel?: string,
771
778
  ): Promise<{
772
779
  content: Array<{ type: "text"; text: string }>;
773
780
  details: SubagentDetails;
@@ -802,7 +809,7 @@ export async function executeParallelSubprocess(
802
809
  };
803
810
  }
804
811
 
805
- const allResults: SingleResult[] = tasks.map((t) => ({
812
+ const allResults: SingleResult[] = tasks.map((t, index) => resumeResults?.[index] ?? ({
806
813
  agent: t.agent,
807
814
  agentSource: "unknown" as const,
808
815
  task: t.task,
@@ -814,6 +821,7 @@ export async function executeParallelSubprocess(
814
821
  completedTurns: 0,
815
822
  turnInProgress: false,
816
823
  liveLog: [],
824
+ sessionDir: getSessionDir?.(index, t),
817
825
  }));
818
826
 
819
827
  const emitProgress = () => {
@@ -842,6 +850,13 @@ export async function executeParallelSubprocess(
842
850
  let results: SingleResult[];
843
851
  try {
844
852
  results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
853
+ const previousResult = resumeResults?.[index];
854
+ if (previousResult?.exitCode === 0) {
855
+ allResults[index] = previousResult;
856
+ emitProgress();
857
+ return previousResult;
858
+ }
859
+ const sessionDir = previousResult?.sessionDir ?? getSessionDir?.(index, t);
845
860
  const result = await runAgentSubprocess({
846
861
  cwd: defaultCwd,
847
862
  agents,
@@ -853,6 +868,11 @@ export async function executeParallelSubprocess(
853
868
  maxDepth,
854
869
  preventCycles,
855
870
  signal,
871
+ sessionDir,
872
+ sessionRoot,
873
+ resumeSession: resumeExistingSessions && !!sessionDir,
874
+ initialResult: previousResult,
875
+ fallbackModel,
856
876
  onUpdate: (partial) => {
857
877
  if (partial.details?.results[0]) {
858
878
  allResults[index] = partial.details.results[0];
@@ -872,8 +892,7 @@ export async function executeParallelSubprocess(
872
892
  const successCount = results.filter((r) => r.exitCode === 0).length;
873
893
  const summaries = results.map((r) => {
874
894
  const output = getFinalOutput(r.messages);
875
- const error = r.errorMessage || r.stderr;
876
- return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || error || "(no output)"}`;
895
+ return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
877
896
  });
878
897
 
879
898
  return {
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). */