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.
- package/index.ts +33 -6
- package/package.json +1 -1
- package/resume.ts +59 -7
- 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
|
|
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"
|
|
528
|
-
const
|
|
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
|
|
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
|
-
|
|
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
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
|
|
85
|
+
function getMessageText(message: any): string {
|
|
86
86
|
const content = message?.content;
|
|
87
|
-
if (typeof content === "string") return content
|
|
88
|
-
if (!Array.isArray(content)) return
|
|
89
|
-
return content
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
610
|
-
const spawnCmd =
|
|
611
|
-
const spawnArgs =
|
|
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);
|