oira666_pi-subagent 0.2.11 → 0.2.13

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,8 +364,6 @@ 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
369
  const RESUME_INTERACTIVE_DELAY_MS = 50;
@@ -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];
@@ -662,7 +647,27 @@ export default function (pi: ExtensionAPI) {
662
647
  `The resumed session has an unfinished subagent call (${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"}). Resume it from saved subagent sessions?`,
663
648
  );
664
649
  }
665
- 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
+ }
666
671
 
667
672
  pendingResumePlan = plan;
668
673
  const resumeState = getSyntheticResumeState();
@@ -947,12 +952,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
947
952
  }
948
953
 
949
954
  function getSessionDirForTask(toolCallId: string, index: number): string {
950
- const root = currentSubagentSessionRoot || pathlessSubagentRootFallback();
951
- return buildSubagentSessionDir(root, currentSessionId, toolCallId, index);
952
- }
953
-
954
- function pathlessSubagentRootFallback(): string {
955
- 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);
956
959
  }
957
960
 
958
961
  // -----------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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,40 @@ 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
+
314
+ function resultHasStarted(result: SingleResult | undefined): boolean {
315
+ if (!result) return false;
316
+ return result.messages.length > 0 || result.completedTurns > 0 || result.liveLog.length > 0 || Object.keys(result.toolCalls).length > 0;
317
+ }
318
+
319
+ function sessionDirExists(dir: string | undefined): boolean {
320
+ if (!dir) return false;
321
+ try {
322
+ return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+
295
328
  export function processJsonLine(line: string, result: SingleResult): boolean {
296
329
  if (!line.trim()) return false;
297
330
 
@@ -307,6 +340,7 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
307
340
 
308
341
  if (event.type === "message_end" && event.message) {
309
342
  const msg = event.message as Message;
343
+ if (hasMessage(result, msg)) return true;
310
344
  result.messages.push(msg);
311
345
 
312
346
  if (msg.role === "assistant") {
@@ -328,7 +362,8 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
328
362
  }
329
363
 
330
364
  if (event.type === "tool_result_end" && event.message) {
331
- result.messages.push(event.message as Message);
365
+ const msg = event.message as Message;
366
+ if (!hasMessage(result, msg)) result.messages.push(msg);
332
367
  return true;
333
368
  }
334
369
 
@@ -515,6 +550,31 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
515
550
  };
516
551
  }
517
552
 
553
+ let shouldContinueSession = resumeSession;
554
+ if (resumeSession && sessionDir && !sessionDirExists(sessionDir)) {
555
+ if (resultHasStarted(initialResult)) {
556
+ const errorMessage = `Cannot resume subagent session: session directory does not exist: ${sessionDir}`;
557
+ return {
558
+ agent: agentName,
559
+ agentSource: agent.source,
560
+ task,
561
+ exitCode: 1,
562
+ messages: initialResult?.messages ? [...initialResult.messages] : [],
563
+ stderr: errorMessage,
564
+ usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
565
+ toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
566
+ model: initialResult?.model ?? agent.model,
567
+ stopReason: "error",
568
+ errorMessage,
569
+ completedTurns: initialResult?.completedTurns ?? 0,
570
+ turnInProgress: false,
571
+ liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
572
+ sessionDir,
573
+ };
574
+ }
575
+ shouldContinueSession = false;
576
+ }
577
+
518
578
  const result: SingleResult = {
519
579
  agent: agentName,
520
580
  agentSource: agent.source,
@@ -561,7 +621,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
561
621
  promptTmpPath,
562
622
  task,
563
623
  sessionDir,
564
- resumeSession,
624
+ shouldContinueSession,
565
625
  fallbackModel,
566
626
  );
567
627
  let wasAborted = false;
@@ -743,7 +803,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
743
803
  if (!result.stderr.trim()) result.stderr = "Subagent was aborted.";
744
804
  }
745
805
 
746
- if (result.exitCode === 0 && endedWithEmptySyntheticResume(result.messages)) {
806
+ if (result.exitCode === 0 && endedWithSyntheticResumeFailure(result.messages)) {
747
807
  result.exitCode = 1;
748
808
  result.stopReason = "error";
749
809
  result.errorMessage = "Subagent resume failed before the real model continued.";
@@ -841,7 +901,6 @@ export async function executeParallelSubprocess(
841
901
  completedTurns: 0,
842
902
  turnInProgress: false,
843
903
  liveLog: [],
844
- sessionDir: getSessionDir?.(index, t),
845
904
  }));
846
905
 
847
906
  const emitProgress = () => {
@@ -876,7 +935,12 @@ export async function executeParallelSubprocess(
876
935
  emitProgress();
877
936
  return previousResult;
878
937
  }
879
- const sessionDir = previousResult?.sessionDir ?? getSessionDir?.(index, t);
938
+ const savedSessionDir = previousResult?.sessionDir;
939
+ const savedSessionDirExists = sessionDirExists(savedSessionDir);
940
+ const shouldResumeThisSession = resumeExistingSessions && (!previousResult || !savedSessionDir || savedSessionDirExists);
941
+ const sessionDir = shouldResumeThisSession && savedSessionDirExists
942
+ ? savedSessionDir
943
+ : getSessionDir?.(index, t);
880
944
  const result = await runAgentSubprocess({
881
945
  cwd: defaultCwd,
882
946
  agents,
@@ -890,7 +954,7 @@ export async function executeParallelSubprocess(
890
954
  signal,
891
955
  sessionDir,
892
956
  sessionRoot,
893
- resumeSession: resumeExistingSessions && !!sessionDir,
957
+ resumeSession: shouldResumeThisSession && !!sessionDir,
894
958
  initialResult: previousResult,
895
959
  fallbackModel,
896
960
  onUpdate: (partial) => {
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;