oira666_pi-subagent 0.2.16 → 0.2.18

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.
Files changed (4) hide show
  1. package/index.ts +33 -6
  2. package/package.json +1 -1
  3. package/resume.ts +59 -7
  4. package/runner.ts +59 -3
package/index.ts CHANGED
@@ -457,6 +457,7 @@ function getRestorableModel(ctx: any): any | undefined {
457
457
  export default function (pi: ExtensionAPI) {
458
458
  let resumeModelRegistry: any | undefined;
459
459
  let lastRestorableModel: any | undefined;
460
+ let latestSessionCtx: any | undefined;
460
461
  let pendingInteractiveResumePrompt: string | null = null;
461
462
 
462
463
  async function streamWithRealModelFallback(context: any, options: any, fallback: any) {
@@ -474,6 +475,20 @@ export default function (pi: ExtensionAPI) {
474
475
  });
475
476
  }
476
477
 
478
+ async function restoreVisibleModelForResume(): Promise<any | undefined> {
479
+ const restore = modelToRestoreAfterResume ?? lastRestorableModel;
480
+ if (!restore) return undefined;
481
+ lastRestorableModel = restore;
482
+ if (latestSessionCtx?.model?.provider === RESUME_PROVIDER) {
483
+ try {
484
+ await pi.setModel(restore);
485
+ } catch (err) {
486
+ console.error("[pi-subagent] Failed to restore real model during resume:", err);
487
+ }
488
+ }
489
+ return restore;
490
+ }
491
+
477
492
  pi.registerFlag("subagent-max-depth", {
478
493
  description: "Maximum allowed subagent delegation depth (default: 3).",
479
494
  type: "string",
@@ -491,7 +506,12 @@ export default function (pi: ExtensionAPI) {
491
506
  streamSimple: async (model, context, options) => {
492
507
  const stream = createAssistantMessageEventStream();
493
508
  const state = getSyntheticResumeState();
494
- const plan = state.plan;
509
+ const discoveredPlan = state.plan ?? pendingResumePlan ?? (latestSessionCtx ? findLatestResumableSubagentCall(latestSessionCtx) : null);
510
+ if (discoveredPlan && !state.plan) {
511
+ state.plan = discoveredPlan;
512
+ pendingResumePlan = discoveredPlan;
513
+ }
514
+ const plan = discoveredPlan;
495
515
  const phase = state.phase;
496
516
  const triggerMatches =
497
517
  state.trigger === "nextRequest" ||
@@ -524,12 +544,14 @@ export default function (pi: ExtensionAPI) {
524
544
  return stream;
525
545
  }
526
546
 
527
- if (phase === "final" && modelToRestoreAfterResume) {
528
- const delegated = await streamWithRealModelFallback(context, options, modelToRestoreAfterResume);
547
+ if (phase === "final") {
548
+ const restore = await restoreVisibleModelForResume();
549
+ const delegated = await streamWithRealModelFallback(context, options, restore);
529
550
  if (delegated) return delegated;
530
551
  }
531
552
 
532
- const fallback = await streamWithRealModelFallback(context, options, lastRestorableModel);
553
+ const restore = await restoreVisibleModelForResume();
554
+ const fallback = await streamWithRealModelFallback(context, options, restore ?? lastRestorableModel);
533
555
  if (fallback) return fallback;
534
556
 
535
557
  if (!(plan && phase === "tool")) {
@@ -603,6 +625,7 @@ export default function (pi: ExtensionAPI) {
603
625
 
604
626
  // Auto-discover agents on session start
605
627
  pi.on("session_start", async (event, ctx) => {
628
+ latestSessionCtx = ctx;
606
629
  try {
607
630
  // Always repair sessions left on the synthetic resume model, even in
608
631
  // nested subagents that can no longer delegate. Those leaf processes
@@ -917,7 +940,11 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
917
940
  resumePlan?.details?.results[0],
918
941
  getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, 0),
919
942
  !!resumePlan,
920
- formatModelFlag(modelToRestoreAfterResume),
943
+ // Prefer the pre-resume model (during resume) or the current
944
+ // active model (normal runs). This prevents children from
945
+ // defaulting to whatever settings.json says at spawn time, which
946
+ // can change while the parent session is long-running.
947
+ formatModelFlag(modelToRestoreAfterResume ?? lastRestorableModel),
921
948
  );
922
949
  }
923
950
 
@@ -931,7 +958,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
931
958
  resumePlan?.details?.results,
932
959
  (index) => getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, index),
933
960
  !!resumePlan,
934
- formatModelFlag(modelToRestoreAfterResume),
961
+ formatModelFlag(modelToRestoreAfterResume ?? lastRestorableModel),
935
962
  );
936
963
  } catch (err) {
937
964
  const msg = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/resume.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3
- import { parseBoolean } from "./shared.js";
3
+ import { parseBoolean, RESUME_PROVIDER } from "./shared.js";
4
4
  import { isResultError, isSubagentDetails, type SingleResult, type SubagentDetails } from "./types.js";
5
5
 
6
6
  export const SUBAGENT_RESUME_PROMPT_ENV = "PI_SUBAGENT_RESUME_PROMPT";
@@ -82,11 +82,17 @@ function hasUnfinishedResults(details: SubagentDetails | undefined, expectedTask
82
82
  return details.results.slice(0, expectedTaskCount).some((result) => result.exitCode === -1 || isResultError(result));
83
83
  }
84
84
 
85
- function messageHasNonEmptyText(message: any): boolean {
85
+ function getMessageText(message: any): string {
86
86
  const content = message?.content;
87
- if (typeof content === "string") return content.trim().length > 0;
88
- if (!Array.isArray(content)) return false;
89
- return content.some((part) => part?.type === "text" && typeof part.text === "string" && part.text.trim().length > 0);
87
+ if (typeof content === "string") return content;
88
+ if (!Array.isArray(content)) return "";
89
+ return content
90
+ .map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
91
+ .join("");
92
+ }
93
+
94
+ function messageHasNonEmptyText(message: any): boolean {
95
+ return getMessageText(message).trim().length > 0;
90
96
  }
91
97
 
92
98
  function messageHasToolCall(message: any): boolean {
@@ -114,9 +120,55 @@ function isIgnorableTrailingAbortMessage(entry: any): boolean {
114
120
  return false;
115
121
  }
116
122
 
123
+ function isResumePromptEntry(entry: any): boolean {
124
+ if (entry?.type !== "message") return false;
125
+ const message = entry.message;
126
+ if (message?.role !== "user") return false;
127
+ return /^Resuming \d+ subagents\.\.\.$/.test(getMessageText(message).trim());
128
+ }
129
+
130
+ function isSyntheticResumeModelChange(entry: any): boolean {
131
+ return entry?.type === "model_change" && entry.provider === RESUME_PROVIDER;
132
+ }
133
+
134
+ function isFailedResumeAttemptTail(entries: SessionEntry[], start: number): boolean {
135
+ if (start >= entries.length) return true;
136
+
137
+ let sawSyntheticModel = false;
138
+ let sawResumePrompt = false;
139
+ let sawFailure = false;
140
+
141
+ for (let i = start; i < entries.length; i++) {
142
+ const entry: any = entries[i];
143
+
144
+ if (isSyntheticResumeModelChange(entry)) {
145
+ sawSyntheticModel = true;
146
+ continue;
147
+ }
148
+
149
+ if (entry?.type === "thinking_level_change") continue;
150
+
151
+ if (isResumePromptEntry(entry)) {
152
+ if (!sawSyntheticModel) return false;
153
+ sawResumePrompt = true;
154
+ continue;
155
+ }
156
+
157
+ if (isIgnorableTrailingAbortMessage(entry)) {
158
+ if (entry?.type === "message" && sawResumePrompt) sawFailure = true;
159
+ continue;
160
+ }
161
+
162
+ return false;
163
+ }
164
+
165
+ return sawSyntheticModel && sawResumePrompt && sawFailure;
166
+ }
167
+
117
168
  function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder: number): boolean {
118
- for (let i = activityOrder + 1; i < entries.length; i++) {
119
- if (!isIgnorableTrailingAbortMessage(entries[i])) return false;
169
+ const start = activityOrder + 1;
170
+ for (let i = start; i < entries.length; i++) {
171
+ if (!isIgnorableTrailingAbortMessage(entries[i])) return isFailedResumeAttemptTail(entries, start);
120
172
  }
121
173
  return true;
122
174
  }
package/runner.ts CHANGED
@@ -38,6 +38,8 @@ const SIGKILL_TIMEOUT_MS = 5000;
38
38
  const HANG_GUARD_DELAY_MS = 5000;
39
39
  const DEFAULT_STARTUP_TIMEOUT_MS = 120_000; // only for startup (before first assistant turn)
40
40
  const SUBAGENT_STARTUP_TIMEOUT_ENV = "PI_SUBAGENT_STARTUP_TIMEOUT";
41
+ const SUBAGENT_PI_COMMAND_ENV = "PI_SUBAGENT_PI_COMMAND";
42
+ const SUBAGENT_PI_ARGS_PREFIX_ENV = "PI_SUBAGENT_PI_ARGS_PREFIX";
41
43
 
42
44
  /**
43
45
  * Stop reasons that indicate the agent has truly finished its work.
@@ -110,6 +112,53 @@ function getCurrentPiCliScript(): string | null {
110
112
  return script;
111
113
  }
112
114
 
115
+ function findPiCliScriptOnPath(): string | null {
116
+ const pathEnv = process.env.PATH ?? "";
117
+ for (const dir of pathEnv.split(path.delimiter)) {
118
+ if (!dir) continue;
119
+ for (const shimName of process.platform === "win32" ? ["pi.cmd", "pi"] : ["pi"]) {
120
+ const shimPath = path.join(dir, shimName);
121
+ if (!fs.existsSync(shimPath)) continue;
122
+ let text = "";
123
+ try {
124
+ text = fs.readFileSync(shimPath, "utf8");
125
+ } catch {
126
+ continue;
127
+ }
128
+ const match = text.match(/node_modules[\\/]([^\s"']*pi-coding-agent)[\\/]dist[\\/]cli\.js/);
129
+ if (!match) continue;
130
+ const cliPath = path.join(dir, "node_modules", match[1], "dist", "cli.js");
131
+ if (fs.existsSync(cliPath)) return cliPath;
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function getPiSpawnCommand(override?: { command: string; argsPrefix?: string[] }): { command: string; argsPrefix: string[] } {
138
+ if (override?.command) return { command: override.command, argsPrefix: override.argsPrefix ?? [] };
139
+
140
+ const overrideCommand = process.env[SUBAGENT_PI_COMMAND_ENV];
141
+ if (overrideCommand) {
142
+ let argsPrefix: string[] = [];
143
+ const rawPrefix = process.env[SUBAGENT_PI_ARGS_PREFIX_ENV];
144
+ if (rawPrefix) {
145
+ try {
146
+ const parsed = JSON.parse(rawPrefix);
147
+ if (Array.isArray(parsed) && parsed.every((value) => typeof value === "string")) {
148
+ argsPrefix = parsed;
149
+ }
150
+ } catch {
151
+ // Ignore invalid test/debug override and run the command without a prefix.
152
+ }
153
+ }
154
+ return { command: overrideCommand, argsPrefix };
155
+ }
156
+
157
+ const cliScript = getCurrentPiCliScript() ?? findPiCliScriptOnPath();
158
+ if (cliScript) return { command: process.execPath, argsPrefix: [cliScript] };
159
+ return { command: "pi", argsPrefix: [] };
160
+ }
161
+
113
162
  function resolveExtensionArg(value: string): string {
114
163
  if (!value) return value;
115
164
  if (value.startsWith("npm:") || value.startsWith("git:")) return value;
@@ -498,6 +547,10 @@ export interface RunAgentOptions {
498
547
  initialResult?: SingleResult;
499
548
  /** Fallback model to use when the agent config does not pin one. */
500
549
  fallbackModel?: string;
550
+ /** Test/debug override for the spawned pi executable. */
551
+ piCommandOverride?: { command: string; argsPrefix?: string[] };
552
+ /** Test/debug override for startup timeout. */
553
+ startupTimeoutMsOverride?: number;
501
554
  }
502
555
 
503
556
  /**
@@ -524,6 +577,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
524
577
  resumeSession = false,
525
578
  initialResult,
526
579
  fallbackModel,
580
+ piCommandOverride,
581
+ startupTimeoutMsOverride,
527
582
  } = opts;
528
583
 
529
584
  const agent = agents.find((a) => a.name === agentName);
@@ -606,9 +661,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
606
661
  // but shell:true splits arguments on whitespace — breaking task strings.
607
662
  // Fix: reuse the running node binary + the pi CLI script path directly,
608
663
  // so the child is spawned without a shell and args are passed safely.
609
- const currentPiCli = getCurrentPiCliScript();
610
- const spawnCmd = currentPiCli ? process.execPath : "pi";
611
- const spawnArgs = currentPiCli ? [currentPiCli, ...piArgs] : piArgs;
664
+ const piSpawn = getPiSpawnCommand(piCommandOverride);
665
+ const spawnCmd = piSpawn.command;
666
+ const spawnArgs = [...piSpawn.argsPrefix, ...piArgs];
612
667
  const proc = spawn(spawnCmd, spawnArgs, {
613
668
  cwd: taskCwd ?? cwd,
614
669
  shell: false,
@@ -636,6 +691,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
636
691
  // disabled — from that point, tool calls can run for as long as they
637
692
  // need, and only the terminal-stopReason hang guard applies.
638
693
  const startupTimeoutMs = (() => {
694
+ if (startupTimeoutMsOverride !== undefined) return startupTimeoutMsOverride;
639
695
  const raw = process.env[SUBAGENT_STARTUP_TIMEOUT_ENV];
640
696
  if (raw === undefined) return DEFAULT_STARTUP_TIMEOUT_MS;
641
697
  const parsed = parseNonNegativeInt(raw);