oira666_pi-subagent 0.2.10 → 0.2.12

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/index.ts CHANGED
@@ -20,6 +20,7 @@ import { runAgentSubprocess, executeParallelSubprocess } from "./runner.js";
20
20
  import {
21
21
  SUBAGENT_RESUME_DISABLE_ENV,
22
22
  SUBAGENT_RESUME_PROMPT_ENV,
23
+ branchEntries,
23
24
  buildSubagentSessionDir,
24
25
  findLatestResumableSubagentCall,
25
26
  getDefaultSubagentSessionRoot,
@@ -30,7 +31,10 @@ import {
30
31
  } from "./resume.js";
31
32
  import {
32
33
  DEFAULT_MAX_PARALLEL_TASKS,
34
+ RESUME_MODEL_ID,
35
+ RESUME_PROVIDER,
33
36
  SUBAGENT_MAX_PARALLEL_TASKS_ENV,
37
+ parseBoolean,
34
38
  parseNonNegativeInt,
35
39
  } from "./shared.js";
36
40
 
@@ -97,15 +101,6 @@ interface DelegationDepthConfig {
97
101
  preventCycles: boolean;
98
102
  }
99
103
 
100
- function parseBoolean(raw: unknown): boolean | null {
101
- if (typeof raw === "boolean") return raw;
102
- if (typeof raw !== "string") return null;
103
- const normalized = raw.trim().toLowerCase();
104
- if (["1", "true", "yes", "on"].includes(normalized)) return true;
105
- if (["0", "false", "no", "off"].includes(normalized)) return false;
106
- return null;
107
- }
108
-
109
104
  function parseProjectAgentConfirmationSetting(
110
105
  raw: unknown,
111
106
  ): ProjectAgentConfirmationSetting | null {
@@ -369,11 +364,9 @@ function hasCliInitialPrompt(argv: string[]): boolean {
369
364
  return false;
370
365
  }
371
366
 
372
- const RESUME_PROVIDER = "pi-subagent-resume";
373
- const RESUME_MODEL_ID = "synthetic-tool-call";
374
367
  const RESUME_STATE_KEY = "__piSubagentResumeState";
375
368
  const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
376
- const RESUME_INTERACTIVE_DELAY_MS = 250;
369
+ const RESUME_INTERACTIVE_DELAY_MS = 50;
377
370
 
378
371
  type SyntheticResumeState = {
379
372
  plan: ResumableSubagentCall | null;
@@ -428,15 +421,7 @@ function formatModelFlag(model: any): string | undefined {
428
421
  }
429
422
 
430
423
  function findLastNonResumeModel(ctx: any): any | undefined {
431
- const entries = (() => {
432
- const leafId = ctx.sessionManager?.getLeafId?.();
433
- if (leafId) {
434
- const branch = ctx.sessionManager?.getBranch?.(leafId);
435
- if (Array.isArray(branch)) return branch;
436
- }
437
- const all = ctx.sessionManager?.getEntries?.();
438
- return Array.isArray(all) ? all : [];
439
- })();
424
+ const entries = branchEntries(ctx);
440
425
 
441
426
  for (let i = entries.length - 1; i >= 0; i--) {
442
427
  const entry = entries[i];
@@ -472,6 +457,7 @@ function getRestorableModel(ctx: any): any | undefined {
472
457
  export default function (pi: ExtensionAPI) {
473
458
  let resumeModelRegistry: any | undefined;
474
459
  let lastRestorableModel: any | undefined;
460
+ let pendingInteractiveResumePrompt: string | null = null;
475
461
 
476
462
  async function streamWithRealModelFallback(context: any, options: any, fallback: any) {
477
463
  if (!fallback) return null;
@@ -661,7 +647,27 @@ export default function (pi: ExtensionAPI) {
661
647
  `The resumed session has an unfinished subagent call (${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"}). Resume it from saved subagent sessions?`,
662
648
  );
663
649
  }
664
- if (!shouldResume) return;
650
+ if (!shouldResume) {
651
+ if (ctx.model?.provider === RESUME_PROVIDER) {
652
+ if (restorableModel) {
653
+ await pi.setModel(restorableModel);
654
+ } else {
655
+ ctx.ui.notify(
656
+ `Subagent resume was declined, but the current model is the synthetic resume model and no real fallback model is available. Select a real model before continuing, or set ${SUBAGENT_FALLBACK_MODEL_ENV}=provider/model.`,
657
+ "error",
658
+ );
659
+ }
660
+ }
661
+ return;
662
+ }
663
+
664
+ if (!restorableModel && ctx.model?.provider === RESUME_PROVIDER) {
665
+ ctx.ui.notify(
666
+ `Cannot resume subagents while on the synthetic resume model because no real fallback model is available. Select a real model or set ${SUBAGENT_FALLBACK_MODEL_ENV}=provider/model.`,
667
+ "error",
668
+ );
669
+ return;
670
+ }
665
671
 
666
672
  pendingResumePlan = plan;
667
673
  const resumeState = getSyntheticResumeState();
@@ -685,20 +691,12 @@ export default function (pi: ExtensionAPI) {
685
691
  if (hasCliInitialPrompt(process.argv)) {
686
692
  if (ctx.hasUI) ctx.ui.notify(`Resuming ${plan.tasks.length} subagents...`, "info");
687
693
  } else {
688
- // Do not start the synthetic resume turn synchronously from
689
- // session_start. In interactive mode Pi may still be rebuilding the
690
- // resumed chat UI; if the tool starts before that finishes, later
691
- // renderSessionContext() can clear the live pending tool component and
692
- // all real-time tool updates disappear. A short macrotask delay lets
693
- // the normal "Resumed session" render settle first.
694
- setTimeout(() => {
695
- try {
696
- pi.sendUserMessage(`Resuming ${plan.tasks.length} subagents...`);
697
- } catch (err) {
698
- console.error("[pi-subagent] Failed to start deferred resume turn:", err);
699
- void restoreModelAfterResumeFailure(ctx);
700
- }
701
- }, RESUME_INTERACTIVE_DELAY_MS);
694
+ // Do not start the synthetic resume turn from session_start. Pi renders
695
+ // the resumed chat only after session_start/resources_discover complete;
696
+ // starting now lets that render wipe out the live tool component, so no
697
+ // real-time updates appear. Queue it for resources_discover instead,
698
+ // which is the last extension hook before the initial chat render.
699
+ pendingInteractiveResumePrompt = `Resuming ${plan.tasks.length} subagents...`;
702
700
  }
703
701
  } catch (err) {
704
702
  console.error("[pi-subagent] Error in session_start:", err);
@@ -710,6 +708,20 @@ export default function (pi: ExtensionAPI) {
710
708
  await restoreModelAfterResumeFailure();
711
709
  });
712
710
 
711
+ pi.on("resources_discover", (_event, ctx) => {
712
+ const prompt = pendingInteractiveResumePrompt;
713
+ if (!prompt) return;
714
+ pendingInteractiveResumePrompt = null;
715
+ setTimeout(() => {
716
+ try {
717
+ pi.sendUserMessage(prompt);
718
+ } catch (err) {
719
+ console.error("[pi-subagent] Failed to start deferred resume turn:", err);
720
+ void restoreModelAfterResumeFailure(ctx);
721
+ }
722
+ }, RESUME_INTERACTIVE_DELAY_MS);
723
+ });
724
+
713
725
  // Inject available agents into the system prompt
714
726
  pi.on("before_agent_start", async (event) => {
715
727
  try {
@@ -940,12 +952,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
940
952
  }
941
953
 
942
954
  function getSessionDirForTask(toolCallId: string, index: number): string {
943
- const root = currentSubagentSessionRoot || pathlessSubagentRootFallback();
944
- return buildSubagentSessionDir(root, currentSessionId, toolCallId, index);
945
- }
946
-
947
- function pathlessSubagentRootFallback(): string {
948
- return `${process.env.HOME ?? "."}/.pi/agent/sessions-subagents`;
955
+ if (!currentSubagentSessionRoot) {
956
+ throw new Error("Cannot create subagent session dir: subagent session root is not initialized.");
957
+ }
958
+ return buildSubagentSessionDir(currentSubagentSessionRoot, currentSessionId, toolCallId, index);
949
959
  }
950
960
 
951
961
  // -----------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/render.ts CHANGED
@@ -44,7 +44,7 @@ interface TreeCounts {
44
44
 
45
45
  interface PendingSubagentCall {
46
46
  toolCallId: string;
47
- tasks: Array<{ agent: string; task?: string }>;
47
+ tasks: Array<{ agent: string; task?: string; cwd?: string }>;
48
48
  }
49
49
 
50
50
  // ---------------------------------------------------------------------------
@@ -151,6 +151,7 @@ function extractPendingSubagentCalls(messages: SingleResult["messages"]): Pendin
151
151
  .map((task: any) => ({
152
152
  agent: task.agent,
153
153
  task: typeof task.task === "string" ? task.task : undefined,
154
+ cwd: typeof task.cwd === "string" ? task.cwd : undefined,
154
155
  }))
155
156
  : [];
156
157
  calls.push({
@@ -181,7 +182,7 @@ function buildNodesFromNestedResult(nested: NestedSubagentResult): TreeNode[] {
181
182
  }
182
183
 
183
184
  function subagentCallSignature(call: PendingSubagentCall): string {
184
- return JSON.stringify(call.tasks.map((task) => ({ agent: task.agent, task: task.task ?? "" })));
185
+ return JSON.stringify(call.tasks.map((task) => ({ agent: task.agent, task: task.task ?? "", cwd: task.cwd ?? "" })));
185
186
  }
186
187
 
187
188
  function nestedResultIsHealthy(nested: NestedSubagentResult | undefined): boolean {
package/resume.ts CHANGED
@@ -1,7 +1,6 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
1
  import * as path from "node:path";
4
2
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import { parseBoolean } from "./shared.js";
5
4
  import { isResultError, isSubagentDetails, type SingleResult, type SubagentDetails } from "./types.js";
6
5
 
7
6
  export const SUBAGENT_RESUME_PROMPT_ENV = "PI_SUBAGENT_RESUME_PROMPT";
@@ -16,14 +15,7 @@ export interface ResumableSubagentCall {
16
15
  details?: SubagentDetails;
17
16
  }
18
17
 
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
- }
18
+ export const parseBooleanEnv = parseBoolean;
27
19
 
28
20
  export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
29
21
  const inheritedRoot = process.env[SUBAGENT_SESSION_ROOT_ENV];
@@ -33,7 +25,8 @@ export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
33
25
  if (typeof mainSessionDir === "string" && mainSessionDir.length > 0) {
34
26
  return path.join(path.dirname(mainSessionDir), "sessions-subagents");
35
27
  }
36
- return path.join(os.homedir(), ".pi", "agent", "sessions-subagents");
28
+
29
+ throw new Error("Cannot determine subagent session root: sessionManager.getSessionDir() is unavailable.");
37
30
  }
38
31
 
39
32
  export function buildSubagentSessionDir(
@@ -47,11 +40,7 @@ export function buildSubagentSessionDir(
47
40
  return path.join(root, safeParent, safeTool, String(index));
48
41
  }
49
42
 
50
- export function ensureDir(dir: string): void {
51
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
- }
53
-
54
- function branchEntries(ctx: ExtensionContext): SessionEntry[] {
43
+ export function branchEntries(ctx: ExtensionContext): SessionEntry[] {
55
44
  const leafId = ctx.sessionManager.getLeafId?.();
56
45
  if (leafId) {
57
46
  const branch = ctx.sessionManager.getBranch?.(leafId);
@@ -87,9 +76,10 @@ function normalizeTasks(args: any): Array<{ agent: string; task: string; cwd?: s
87
76
  return tasks;
88
77
  }
89
78
 
90
- function hasUnfinishedResults(details: SubagentDetails | undefined): boolean {
79
+ function hasUnfinishedResults(details: SubagentDetails | undefined, expectedTaskCount: number): boolean {
91
80
  if (!details) return true;
92
- return details.results.some((result) => result.exitCode === -1 || isResultError(result));
81
+ if (details.results.length < expectedTaskCount) return true;
82
+ return details.results.slice(0, expectedTaskCount).some((result) => result.exitCode === -1 || isResultError(result));
93
83
  }
94
84
 
95
85
  function messageHasNonEmptyText(message: any): boolean {
@@ -155,7 +145,7 @@ export function findLatestResumableSubagentCall(ctx: ExtensionContext): Resumabl
155
145
  const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
156
146
  for (const [toolCallId, call] of calls) {
157
147
  const result = results.get(toolCallId);
158
- const unfinished = !result || result.isError || hasUnfinishedResults(result.details);
148
+ const unfinished = !result || result.isError || hasUnfinishedResults(result.details, call.tasks.length);
159
149
  if (!unfinished) continue;
160
150
  candidates.push({
161
151
  previousToolCallId: toolCallId,
@@ -165,7 +155,7 @@ export function findLatestResumableSubagentCall(ctx: ExtensionContext): Resumabl
165
155
  });
166
156
  }
167
157
 
168
- const latest = candidates.at(-1);
158
+ const latest = candidates.sort((a, b) => a.activityOrder - b.activityOrder).at(-1);
169
159
  if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
170
160
  return latest;
171
161
  }
@@ -174,7 +164,11 @@ export function sameTasks(
174
164
  a: Array<{ agent: string; task: string; cwd?: string }>,
175
165
  b: Array<{ agent: string; task: string; cwd?: string }>,
176
166
  ): boolean {
177
- return JSON.stringify(a) === JSON.stringify(b);
167
+ if (a.length !== b.length) return false;
168
+ return a.every((task, index) => {
169
+ const other = b[index];
170
+ return task.agent === other.agent && task.task === other.task && (task.cwd ?? undefined) === (other.cwd ?? undefined);
171
+ });
178
172
  }
179
173
 
180
174
  export function isFinishedResult(result: SingleResult | undefined): boolean {
package/runner.ts CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  DEFAULT_MAX_PARALLEL_TASKS,
27
27
  DEFAULT_MAX_CONCURRENCY,
28
28
  PARALLEL_HEARTBEAT_MS,
29
+ RESUME_MODEL_ID,
30
+ RESUME_PROVIDER,
29
31
  SUBAGENT_MAX_PARALLEL_TASKS_ENV,
30
32
  SUBAGENT_MAX_CONCURRENCY_ENV,
31
33
  parseNonNegativeInt,
@@ -49,23 +51,20 @@ function isTerminalStopReason(reason: string | undefined): boolean {
49
51
  return reason !== undefined && TERMINAL_STOP_REASONS.has(reason);
50
52
  }
51
53
 
52
- function endedWithEmptySyntheticResume(messages: Message[]): boolean {
54
+ function endedWithSyntheticResumeFailure(messages: Message[]): boolean {
53
55
  const lastAssistant = [...messages].reverse().find((message: any) => message?.role === "assistant") as any;
54
- return (
55
- lastAssistant?.provider === RESUME_PROVIDER &&
56
- lastAssistant?.model === RESUME_MODEL_ID &&
57
- lastAssistant?.stopReason === "stop" &&
58
- Array.isArray(lastAssistant.content) &&
59
- lastAssistant.content.length === 0
60
- );
56
+ if (lastAssistant?.provider !== RESUME_PROVIDER || lastAssistant?.model !== RESUME_MODEL_ID) return false;
57
+ const content = Array.isArray(lastAssistant.content) ? lastAssistant.content : [];
58
+ const handedOffToRealModel = messages.some((message: any) => message?.role === "assistant" && message.provider !== RESUME_PROVIDER);
59
+ const hasToolCall = content.some((part: any) => part?.type === "toolCall");
60
+ return !handedOffToRealModel && !hasToolCall;
61
61
  }
62
62
  const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
63
63
  const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
64
64
  const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
65
65
  const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
66
66
  const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
67
- const RESUME_PROVIDER = "pi-subagent-resume";
68
- const RESUME_MODEL_ID = "synthetic-tool-call";
67
+
69
68
  // PI_OFFLINE intentionally removed: setting it on child processes blocks all API
70
69
  // calls and renders subagents unable to do any LLM work. Children inherit the
71
70
  // parent's PI_OFFLINE value via process.env spread if needed.
@@ -292,6 +291,26 @@ function pushLiveLog(result: SingleResult, entry: LiveLogEntry): void {
292
291
  if (result.liveLog.length > MAX_LIVE_LOG_ENTRIES) result.liveLog.shift();
293
292
  }
294
293
 
294
+ function messageDedupKey(message: Message): string {
295
+ const anyMessage = message as any;
296
+ if (typeof anyMessage.id === "string") return `id:${anyMessage.id}`;
297
+ return JSON.stringify({
298
+ role: anyMessage.role,
299
+ provider: anyMessage.provider,
300
+ model: anyMessage.model,
301
+ stopReason: anyMessage.stopReason,
302
+ toolCallId: anyMessage.toolCallId,
303
+ toolName: anyMessage.toolName,
304
+ content: anyMessage.content,
305
+ usage: anyMessage.usage,
306
+ });
307
+ }
308
+
309
+ function hasMessage(result: SingleResult, message: Message): boolean {
310
+ const key = messageDedupKey(message);
311
+ return result.messages.some((existing) => messageDedupKey(existing) === key);
312
+ }
313
+
295
314
  export function processJsonLine(line: string, result: SingleResult): boolean {
296
315
  if (!line.trim()) return false;
297
316
 
@@ -307,6 +326,7 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
307
326
 
308
327
  if (event.type === "message_end" && event.message) {
309
328
  const msg = event.message as Message;
329
+ if (hasMessage(result, msg)) return true;
310
330
  result.messages.push(msg);
311
331
 
312
332
  if (msg.role === "assistant") {
@@ -328,7 +348,8 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
328
348
  }
329
349
 
330
350
  if (event.type === "tool_result_end" && event.message) {
331
- result.messages.push(event.message as Message);
351
+ const msg = event.message as Message;
352
+ if (!hasMessage(result, msg)) result.messages.push(msg);
332
353
  return true;
333
354
  }
334
355
 
@@ -515,6 +536,27 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
515
536
  };
516
537
  }
517
538
 
539
+ if (resumeSession && sessionDir && (!fs.existsSync(sessionDir) || !fs.statSync(sessionDir).isDirectory())) {
540
+ const errorMessage = `Cannot resume subagent session: session directory does not exist: ${sessionDir}`;
541
+ return {
542
+ agent: agentName,
543
+ agentSource: agent.source,
544
+ task,
545
+ exitCode: 1,
546
+ messages: initialResult?.messages ? [...initialResult.messages] : [],
547
+ stderr: errorMessage,
548
+ usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
549
+ toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
550
+ model: initialResult?.model ?? agent.model,
551
+ stopReason: "error",
552
+ errorMessage,
553
+ completedTurns: initialResult?.completedTurns ?? 0,
554
+ turnInProgress: false,
555
+ liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
556
+ sessionDir,
557
+ };
558
+ }
559
+
518
560
  const result: SingleResult = {
519
561
  agent: agentName,
520
562
  agentSource: agent.source,
@@ -743,7 +785,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
743
785
  if (!result.stderr.trim()) result.stderr = "Subagent was aborted.";
744
786
  }
745
787
 
746
- if (result.exitCode === 0 && endedWithEmptySyntheticResume(result.messages)) {
788
+ if (result.exitCode === 0 && endedWithSyntheticResumeFailure(result.messages)) {
747
789
  result.exitCode = 1;
748
790
  result.stopReason = "error";
749
791
  result.errorMessage = "Subagent resume failed before the real model continued.";
package/shared.ts CHANGED
@@ -13,11 +13,22 @@ export const DEFAULT_MAX_CONCURRENCY = 8;
13
13
  export const PARALLEL_HEARTBEAT_MS = 1000;
14
14
  export const SUBAGENT_MAX_PARALLEL_TASKS_ENV = "PI_SUBAGENT_MAX_PARALLEL_TASKS";
15
15
  export const SUBAGENT_MAX_CONCURRENCY_ENV = "PI_SUBAGENT_MAX_CONCURRENCY";
16
+ export const RESUME_PROVIDER = "pi-subagent-resume";
17
+ export const RESUME_MODEL_ID = "synthetic-tool-call";
16
18
 
17
19
  // ---------------------------------------------------------------------------
18
20
  // Shared helpers
19
21
  // ---------------------------------------------------------------------------
20
22
 
23
+ export function parseBoolean(raw: unknown): boolean | null {
24
+ if (typeof raw === "boolean") return raw;
25
+ if (typeof raw !== "string") return null;
26
+ const normalized = raw.trim().toLowerCase();
27
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
28
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
29
+ return null;
30
+ }
31
+
21
32
  /** Parse a string into a non-negative safe integer, or null on failure. */
22
33
  export function parseNonNegativeInt(raw: unknown): number | null {
23
34
  if (typeof raw !== "string") return null;