oira666_pi-subagent 0.2.3 → 0.2.4
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 +15 -0
- package/index.ts +117 -2
- package/package.json +2 -1
- package/render.ts +39 -4
- package/resume.ts +178 -0
- package/runner.ts +51 -48
- package/types.ts +2 -0
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
|
@@ -13,6 +13,18 @@ import { Type } from "@sinclair/typebox";
|
|
|
13
13
|
import { type AgentConfig, discoverAgents } from "./agents.js";
|
|
14
14
|
import { renderCall, renderResult } from "./render.js";
|
|
15
15
|
import { runAgentSubprocess, executeParallelSubprocess } from "./runner.js";
|
|
16
|
+
import {
|
|
17
|
+
SUBAGENT_RESUME_DISABLE_ENV,
|
|
18
|
+
SUBAGENT_RESUME_PROMPT_ENV,
|
|
19
|
+
buildSubagentSessionDir,
|
|
20
|
+
ensureDir,
|
|
21
|
+
findLatestResumableSubagentCall,
|
|
22
|
+
getDefaultSubagentSessionRoot,
|
|
23
|
+
isFinishedResult,
|
|
24
|
+
parseBooleanEnv,
|
|
25
|
+
sameTasks,
|
|
26
|
+
type ResumableSubagentCall,
|
|
27
|
+
} from "./resume.js";
|
|
16
28
|
import {
|
|
17
29
|
DEFAULT_MAX_PARALLEL_TASKS,
|
|
18
30
|
SUBAGENT_MAX_PARALLEL_TASKS_ENV,
|
|
@@ -339,6 +351,21 @@ function getProjectAgentSessionKey(projectAgentsDir: string | null): string {
|
|
|
339
351
|
return projectAgentsDir ?? "(unknown-project-agents-dir)";
|
|
340
352
|
}
|
|
341
353
|
|
|
354
|
+
function ensureSubagentToolActive(pi: ExtensionAPI): void {
|
|
355
|
+
const activeTools = pi.getActiveTools();
|
|
356
|
+
if (!activeTools.includes("subagent")) {
|
|
357
|
+
pi.setActiveTools([...activeTools, "subagent"]);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function hasCliInitialPrompt(argv: string[]): boolean {
|
|
362
|
+
for (let i = 2; i < argv.length; i++) {
|
|
363
|
+
const arg = argv[i];
|
|
364
|
+
if (arg === "-p" || arg === "--print") return true;
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
342
369
|
// ---------------------------------------------------------------------------
|
|
343
370
|
// Extension entry point
|
|
344
371
|
// ---------------------------------------------------------------------------
|
|
@@ -362,14 +389,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
362
389
|
DEFAULT_MAX_PARALLEL_TASKS;
|
|
363
390
|
|
|
364
391
|
let discoveredAgents: AgentConfig[] = [];
|
|
392
|
+
let currentSessionId = "ephemeral";
|
|
393
|
+
let currentSubagentSessionRoot = "";
|
|
394
|
+
let pendingResumePlan: ResumableSubagentCall | null = null;
|
|
365
395
|
const approvedProjectAgentDirsForSession = new Set<string>();
|
|
366
396
|
|
|
367
397
|
// Auto-discover agents on session start
|
|
368
|
-
pi.on("session_start", async (
|
|
398
|
+
pi.on("session_start", async (event, ctx) => {
|
|
369
399
|
if (!canDelegate) return;
|
|
370
400
|
try {
|
|
371
401
|
const discovery = discoverAgents(ctx.cwd, "both");
|
|
372
402
|
discoveredAgents = discovery.agents;
|
|
403
|
+
currentSessionId = ctx.sessionManager.getSessionId?.() ?? "ephemeral";
|
|
404
|
+
currentSubagentSessionRoot = getDefaultSubagentSessionRoot(ctx);
|
|
373
405
|
|
|
374
406
|
if (discoveredAgents.length > 0 && ctx.hasUI) {
|
|
375
407
|
const list = discoveredAgents
|
|
@@ -380,6 +412,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
380
412
|
"info",
|
|
381
413
|
);
|
|
382
414
|
}
|
|
415
|
+
|
|
416
|
+
const resumeDisabled = parseBooleanEnv(process.env[SUBAGENT_RESUME_DISABLE_ENV]) === true;
|
|
417
|
+
if (resumeDisabled || (event.reason !== "resume" && event.reason !== "startup")) return;
|
|
418
|
+
|
|
419
|
+
const plan = findLatestResumableSubagentCall(ctx);
|
|
420
|
+
if (!plan) return;
|
|
421
|
+
|
|
422
|
+
let shouldResume = true;
|
|
423
|
+
const shouldPrompt = parseBooleanEnv(process.env[SUBAGENT_RESUME_PROMPT_ENV]) !== false;
|
|
424
|
+
if (ctx.hasUI && shouldPrompt) {
|
|
425
|
+
shouldResume = await ctx.ui.confirm(
|
|
426
|
+
"Resume subagents?",
|
|
427
|
+
`The resumed session has an unfinished subagent call (${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"}). Resume it from saved subagent sessions?`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (!shouldResume) return;
|
|
431
|
+
|
|
432
|
+
pendingResumePlan = plan;
|
|
433
|
+
ensureSubagentToolActive(pi);
|
|
434
|
+
|
|
435
|
+
// In print/json subprocesses there is already an initial CLI prompt about
|
|
436
|
+
// to be sent. Starting another prompt from session_start races with it and
|
|
437
|
+
// Pi correctly reports "agent is already processing". In that case we only
|
|
438
|
+
// seed pendingResumePlan; before_agent_start injects the exact subagent
|
|
439
|
+
// call instruction into the upcoming turn.
|
|
440
|
+
if (hasCliInitialPrompt(process.argv)) {
|
|
441
|
+
if (ctx.hasUI) ctx.ui.notify(`Resuming ${plan.tasks.length} subagents...`, "info");
|
|
442
|
+
} else {
|
|
443
|
+
pi.sendUserMessage(`Resuming ${plan.tasks.length} subagents...`);
|
|
444
|
+
}
|
|
383
445
|
} catch (err) {
|
|
384
446
|
console.error("[pi-subagent] Error in session_start:", err);
|
|
385
447
|
}
|
|
@@ -394,9 +456,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
456
|
const agentList = discoveredAgents
|
|
395
457
|
.map((a) => `- **${a.name}**: ${a.description}`)
|
|
396
458
|
.join("\n");
|
|
459
|
+
if (pendingResumePlan) ensureSubagentToolActive(pi);
|
|
460
|
+
const resumeInstruction = pendingResumePlan
|
|
461
|
+
? `\n\n## Interrupted Subagent Resume\n\nThe user approved resuming an interrupted subagent delegation. The \`subagent\` tool has been enabled for this turn. You MUST call the \`subagent\` tool exactly once now with these exact arguments and no other tool calls first:\n\n\`\`\`json\n${JSON.stringify({ tasks: pendingResumePlan.tasks }, null, 2)}\n\`\`\`\n`
|
|
462
|
+
: "";
|
|
463
|
+
|
|
397
464
|
return {
|
|
398
465
|
systemPrompt:
|
|
399
466
|
event.systemPrompt +
|
|
467
|
+
resumeInstruction +
|
|
400
468
|
`\n\n## Available Subagents
|
|
401
469
|
|
|
402
470
|
The following subagents are available via the \`subagent\` tool:
|
|
@@ -453,7 +521,7 @@ calls one after another. Do NOT put dependent tasks in the same array.
|
|
|
453
521
|
].join("\n"),
|
|
454
522
|
parameters: SubagentParams,
|
|
455
523
|
|
|
456
|
-
async execute(
|
|
524
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
457
525
|
try {
|
|
458
526
|
const discovery = discoverAgents(ctx.cwd, "both");
|
|
459
527
|
const { agents } = discovery;
|
|
@@ -558,6 +626,12 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
558
626
|
}
|
|
559
627
|
}
|
|
560
628
|
|
|
629
|
+
const resumePlan =
|
|
630
|
+
pendingResumePlan && sameTasks(pendingResumePlan.tasks, tasks)
|
|
631
|
+
? pendingResumePlan
|
|
632
|
+
: null;
|
|
633
|
+
if (resumePlan) pendingResumePlan = null;
|
|
634
|
+
|
|
561
635
|
if (tasks.length === 1) {
|
|
562
636
|
const [task] = tasks;
|
|
563
637
|
return executeSingle(
|
|
@@ -569,6 +643,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
569
643
|
signal,
|
|
570
644
|
onUpdate,
|
|
571
645
|
makeDetails,
|
|
646
|
+
resumePlan?.details?.results[0],
|
|
647
|
+
getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, 0),
|
|
648
|
+
!!resumePlan,
|
|
572
649
|
);
|
|
573
650
|
}
|
|
574
651
|
|
|
@@ -579,6 +656,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
579
656
|
signal,
|
|
580
657
|
onUpdate,
|
|
581
658
|
makeDetails,
|
|
659
|
+
resumePlan?.details?.results,
|
|
660
|
+
(index) => getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, index),
|
|
661
|
+
!!resumePlan,
|
|
582
662
|
);
|
|
583
663
|
} catch (err) {
|
|
584
664
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -598,6 +678,17 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
598
678
|
});
|
|
599
679
|
}
|
|
600
680
|
|
|
681
|
+
function getSessionDirForTask(toolCallId: string, index: number): string {
|
|
682
|
+
const root = currentSubagentSessionRoot || pathlessSubagentRootFallback();
|
|
683
|
+
const dir = buildSubagentSessionDir(root, currentSessionId, toolCallId, index);
|
|
684
|
+
ensureDir(dir);
|
|
685
|
+
return dir;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function pathlessSubagentRootFallback(): string {
|
|
689
|
+
return `${process.env.HOME ?? "."}/.pi/agent/sessions-subagents`;
|
|
690
|
+
}
|
|
691
|
+
|
|
601
692
|
// -----------------------------------------------------------------------
|
|
602
693
|
// Mode implementations
|
|
603
694
|
// -----------------------------------------------------------------------
|
|
@@ -611,7 +702,22 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
611
702
|
signal: AbortSignal | undefined,
|
|
612
703
|
onUpdate: ((partial: any) => void) | undefined,
|
|
613
704
|
makeDetails: ReturnType<typeof makeDetailsFactory>,
|
|
705
|
+
previousResult: SingleResult | undefined,
|
|
706
|
+
sessionDir: string,
|
|
707
|
+
resumeExistingSession: boolean,
|
|
614
708
|
) {
|
|
709
|
+
if (previousResult && isFinishedResult(previousResult)) {
|
|
710
|
+
return {
|
|
711
|
+
content: [
|
|
712
|
+
{
|
|
713
|
+
type: "text" as const,
|
|
714
|
+
text: getFinalOutput(previousResult.messages) || "(no output)",
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
details: makeDetails("single")([previousResult]),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
615
721
|
const result = await runAgentSubprocess({
|
|
616
722
|
cwd: defaultCwd,
|
|
617
723
|
agents,
|
|
@@ -625,6 +731,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
625
731
|
signal,
|
|
626
732
|
onUpdate,
|
|
627
733
|
makeDetails: makeDetails("single"),
|
|
734
|
+
sessionDir: previousResult?.sessionDir ?? sessionDir,
|
|
735
|
+
resumeSession: resumeExistingSession,
|
|
736
|
+
initialResult: previousResult,
|
|
628
737
|
});
|
|
629
738
|
|
|
630
739
|
if (isResultError(result)) {
|
|
@@ -662,6 +771,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
662
771
|
signal: AbortSignal | undefined,
|
|
663
772
|
onUpdate: ((partial: any) => void) | undefined,
|
|
664
773
|
makeDetails: ReturnType<typeof makeDetailsFactory>,
|
|
774
|
+
resumeResults: SingleResult[] | undefined,
|
|
775
|
+
getSessionDir: (index: number) => string,
|
|
776
|
+
resumeExistingSessions: boolean,
|
|
665
777
|
) {
|
|
666
778
|
return executeParallelSubprocess(
|
|
667
779
|
tasks,
|
|
@@ -674,6 +786,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
674
786
|
signal,
|
|
675
787
|
onUpdate,
|
|
676
788
|
makeDetails("parallel"),
|
|
789
|
+
resumeResults,
|
|
790
|
+
(index) => getSessionDir(index),
|
|
791
|
+
resumeExistingSessions,
|
|
677
792
|
);
|
|
678
793
|
}
|
|
679
794
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oira666_pi-subagent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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
|
-
|
|
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
|
-
|
|
225
|
+
return;
|
|
195
226
|
}
|
|
196
|
-
|
|
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
|
|
package/resume.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
|
|
10
|
+
type SessionEntry = ReturnType<ExtensionContext["sessionManager"]["getEntries"]>[number];
|
|
11
|
+
|
|
12
|
+
export interface ResumableSubagentCall {
|
|
13
|
+
previousToolCallId: string;
|
|
14
|
+
tasks: Array<{ agent: string; task: string; cwd?: string }>;
|
|
15
|
+
details?: SubagentDetails;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseBooleanEnv(raw: unknown): boolean | null {
|
|
19
|
+
if (typeof raw === "boolean") return raw;
|
|
20
|
+
if (typeof raw !== "string") return null;
|
|
21
|
+
const normalized = raw.trim().toLowerCase();
|
|
22
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
23
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
|
|
28
|
+
const mainSessionDir = ctx.sessionManager.getSessionDir?.();
|
|
29
|
+
if (typeof mainSessionDir === "string" && mainSessionDir.length > 0) {
|
|
30
|
+
return path.join(path.dirname(mainSessionDir), "sessions-subagents");
|
|
31
|
+
}
|
|
32
|
+
return path.join(os.homedir(), ".pi", "agent", "sessions-subagents");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildSubagentSessionDir(
|
|
36
|
+
root: string,
|
|
37
|
+
parentSessionId: string,
|
|
38
|
+
toolCallId: string,
|
|
39
|
+
index: number,
|
|
40
|
+
): string {
|
|
41
|
+
const safeParent = parentSessionId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
42
|
+
const safeTool = toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
43
|
+
return path.join(root, safeParent, safeTool, String(index));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ensureDir(dir: string): void {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function branchEntries(ctx: ExtensionContext): SessionEntry[] {
|
|
51
|
+
const leafId = ctx.sessionManager.getLeafId?.();
|
|
52
|
+
if (leafId) {
|
|
53
|
+
const branch = ctx.sessionManager.getBranch?.(leafId);
|
|
54
|
+
if (Array.isArray(branch)) return branch as SessionEntry[];
|
|
55
|
+
}
|
|
56
|
+
const entries = ctx.sessionManager.getEntries?.();
|
|
57
|
+
return Array.isArray(entries) ? entries as SessionEntry[] : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getSubagentToolCalls(message: any): Array<{ id: string; args: any }> {
|
|
61
|
+
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
|
|
62
|
+
const calls: Array<{ id: string; args: any }> = [];
|
|
63
|
+
for (const part of message.content) {
|
|
64
|
+
if (part?.type === "toolCall" && part.name === "subagent" && typeof part.id === "string") {
|
|
65
|
+
calls.push({ id: part.id, args: part.arguments });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return calls;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeTasks(args: any): Array<{ agent: string; task: string; cwd?: string }> | null {
|
|
72
|
+
const rawTasks = args?.tasks;
|
|
73
|
+
if (!Array.isArray(rawTasks) || rawTasks.length === 0) return null;
|
|
74
|
+
const tasks: Array<{ agent: string; task: string; cwd?: string }> = [];
|
|
75
|
+
for (const task of rawTasks) {
|
|
76
|
+
if (typeof task?.agent !== "string" || typeof task?.task !== "string") return null;
|
|
77
|
+
tasks.push({
|
|
78
|
+
agent: task.agent,
|
|
79
|
+
task: task.task,
|
|
80
|
+
...(typeof task.cwd === "string" ? { cwd: task.cwd } : {}),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return tasks;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasUnfinishedResults(details: SubagentDetails | undefined): boolean {
|
|
87
|
+
if (!details) return true;
|
|
88
|
+
return details.results.some((result) => result.exitCode === -1 || isResultError(result));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function messageHasNonEmptyText(message: any): boolean {
|
|
92
|
+
const content = message?.content;
|
|
93
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
94
|
+
if (!Array.isArray(content)) return false;
|
|
95
|
+
return content.some((part) => part?.type === "text" && typeof part.text === "string" && part.text.trim().length > 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function messageHasToolCall(message: any): boolean {
|
|
99
|
+
return Array.isArray(message?.content) && message.content.some((part: any) => part?.type === "toolCall");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isIgnorableTrailingAbortMessage(entry: any): boolean {
|
|
103
|
+
if (entry?.type !== "message") return true;
|
|
104
|
+
const message = entry.message;
|
|
105
|
+
if (!message) return true;
|
|
106
|
+
|
|
107
|
+
// Pi may append a final aborted/error assistant message after it has already
|
|
108
|
+
// closed an interrupted tool with a synthetic toolResult. That message is not
|
|
109
|
+
// user-visible progress after the subagent activity, so it must not prevent
|
|
110
|
+
// resume detection.
|
|
111
|
+
if (
|
|
112
|
+
message.role === "assistant" &&
|
|
113
|
+
(message.stopReason === "aborted" || message.stopReason === "error") &&
|
|
114
|
+
!messageHasNonEmptyText(message) &&
|
|
115
|
+
!messageHasToolCall(message)
|
|
116
|
+
) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder: number): boolean {
|
|
124
|
+
for (let i = activityOrder + 1; i < entries.length; i++) {
|
|
125
|
+
if (!isIgnorableTrailingAbortMessage(entries[i])) return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
|
|
131
|
+
const entries = branchEntries(ctx);
|
|
132
|
+
const calls = new Map<string, { tasks: Array<{ agent: string; task: string; cwd?: string }>; order: number }>();
|
|
133
|
+
const results = new Map<string, { details?: SubagentDetails; isError: boolean; order: number }>();
|
|
134
|
+
|
|
135
|
+
entries.forEach((entry: any, order) => {
|
|
136
|
+
if (entry?.type !== "message") return;
|
|
137
|
+
const msg = entry.message;
|
|
138
|
+
for (const call of getSubagentToolCalls(msg)) {
|
|
139
|
+
const tasks = normalizeTasks(call.args);
|
|
140
|
+
if (tasks) calls.set(call.id, { tasks, order });
|
|
141
|
+
}
|
|
142
|
+
if (msg?.role === "toolResult" && msg.toolName === "subagent" && typeof msg.toolCallId === "string") {
|
|
143
|
+
results.set(msg.toolCallId, {
|
|
144
|
+
details: isSubagentDetails(msg.details) ? msg.details : undefined,
|
|
145
|
+
isError: msg.isError === true,
|
|
146
|
+
order,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
|
|
152
|
+
for (const [toolCallId, call] of calls) {
|
|
153
|
+
const result = results.get(toolCallId);
|
|
154
|
+
const unfinished = !result || result.isError || hasUnfinishedResults(result.details);
|
|
155
|
+
if (!unfinished) continue;
|
|
156
|
+
candidates.push({
|
|
157
|
+
previousToolCallId: toolCallId,
|
|
158
|
+
tasks: call.tasks,
|
|
159
|
+
details: result?.details,
|
|
160
|
+
activityOrder: result?.order ?? call.order,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const latest = candidates.at(-1);
|
|
165
|
+
if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
|
|
166
|
+
return latest;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function sameTasks(
|
|
170
|
+
a: Array<{ agent: string; task: string; cwd?: string }>,
|
|
171
|
+
b: Array<{ agent: string; task: string; cwd?: string }>,
|
|
172
|
+
): boolean {
|
|
173
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function isFinishedResult(result: SingleResult | undefined): boolean {
|
|
177
|
+
return !!result && result.exitCode === 0 && !isResultError(result);
|
|
178
|
+
}
|
package/runner.ts
CHANGED
|
@@ -81,25 +81,6 @@ function cleanupTempDir(dir: string | null): void {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
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
84
|
function getCurrentPiCliScript(): string | null {
|
|
104
85
|
const script = process.argv[1];
|
|
105
86
|
if (!script) return null;
|
|
@@ -384,16 +365,20 @@ function buildPiArgs(
|
|
|
384
365
|
agent: AgentConfig,
|
|
385
366
|
systemPromptPath: string | null,
|
|
386
367
|
task: string,
|
|
368
|
+
sessionDir: string | undefined,
|
|
369
|
+
resumeSession: boolean,
|
|
387
370
|
): string[] {
|
|
388
371
|
const args: string[] = [
|
|
389
372
|
"--mode",
|
|
390
373
|
"json",
|
|
391
374
|
..._inheritedCliArgs.extensionArgs,
|
|
392
375
|
..._inheritedCliArgs.alwaysProxy,
|
|
393
|
-
"-p",
|
|
394
|
-
"--no-session",
|
|
395
376
|
];
|
|
396
377
|
|
|
378
|
+
if (sessionDir) args.push("--session-dir", sessionDir);
|
|
379
|
+
if (resumeSession) args.push("--continue");
|
|
380
|
+
args.push("-p");
|
|
381
|
+
|
|
397
382
|
// Agent config takes priority; fall back to parent CLI value
|
|
398
383
|
const model = agent.model ?? _inheritedCliArgs.fallbackModel;
|
|
399
384
|
if (model) args.push("--model", model);
|
|
@@ -420,7 +405,11 @@ function buildPiArgs(
|
|
|
420
405
|
}
|
|
421
406
|
|
|
422
407
|
if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
|
|
423
|
-
args.push(
|
|
408
|
+
args.push(
|
|
409
|
+
resumeSession
|
|
410
|
+
? `Continue the previous task from where you left off. Original task: ${task}`
|
|
411
|
+
: `Task: ${task}`,
|
|
412
|
+
);
|
|
424
413
|
return args;
|
|
425
414
|
}
|
|
426
415
|
|
|
@@ -453,6 +442,12 @@ export interface RunAgentOptions {
|
|
|
453
442
|
onUpdate?: OnUpdateCallback;
|
|
454
443
|
/** Factory to wrap results into SubagentDetails. */
|
|
455
444
|
makeDetails: (results: SingleResult[]) => SubagentDetails;
|
|
445
|
+
/** Dedicated session directory for this subagent process. */
|
|
446
|
+
sessionDir?: string;
|
|
447
|
+
/** Continue the most recent session in sessionDir instead of creating a new one. */
|
|
448
|
+
resumeSession?: boolean;
|
|
449
|
+
/** Previously captured state for this same subagent, used to render resumed nested trees. */
|
|
450
|
+
initialResult?: SingleResult;
|
|
456
451
|
}
|
|
457
452
|
|
|
458
453
|
/**
|
|
@@ -474,6 +469,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
474
469
|
signal,
|
|
475
470
|
onUpdate,
|
|
476
471
|
makeDetails,
|
|
472
|
+
sessionDir,
|
|
473
|
+
resumeSession = false,
|
|
474
|
+
initialResult,
|
|
477
475
|
} = opts;
|
|
478
476
|
|
|
479
477
|
const agent = agents.find((a) => a.name === agentName);
|
|
@@ -491,6 +489,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
491
489
|
completedTurns: 0,
|
|
492
490
|
turnInProgress: false,
|
|
493
491
|
liveLog: [],
|
|
492
|
+
sessionDir: opts.sessionDir,
|
|
494
493
|
};
|
|
495
494
|
}
|
|
496
495
|
|
|
@@ -499,14 +498,16 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
499
498
|
agentSource: agent.source,
|
|
500
499
|
task,
|
|
501
500
|
exitCode: -1,
|
|
502
|
-
messages: [],
|
|
503
|
-
stderr: "",
|
|
504
|
-
usage: emptyUsage(),
|
|
505
|
-
toolCalls: {},
|
|
506
|
-
model: agent.model,
|
|
507
|
-
completedTurns: 0,
|
|
501
|
+
messages: initialResult?.messages ? [...initialResult.messages] : [],
|
|
502
|
+
stderr: initialResult?.stderr ?? "",
|
|
503
|
+
usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
|
|
504
|
+
toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
|
|
505
|
+
model: initialResult?.model ?? agent.model,
|
|
506
|
+
completedTurns: initialResult?.completedTurns ?? 0,
|
|
508
507
|
turnInProgress: false,
|
|
509
|
-
|
|
508
|
+
liveToolExecutions: initialResult?.liveToolExecutions,
|
|
509
|
+
liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
|
|
510
|
+
sessionDir,
|
|
510
511
|
};
|
|
511
512
|
|
|
512
513
|
const emitUpdate = () => {
|
|
@@ -523,18 +524,6 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
523
524
|
|
|
524
525
|
emitUpdate();
|
|
525
526
|
|
|
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
527
|
// Write system prompt to temp file if needed
|
|
539
528
|
let promptTmpDir: string | null = null;
|
|
540
529
|
let promptTmpPath: string | null = null;
|
|
@@ -549,6 +538,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
549
538
|
agent,
|
|
550
539
|
promptTmpPath,
|
|
551
540
|
task,
|
|
541
|
+
sessionDir,
|
|
542
|
+
resumeSession,
|
|
552
543
|
);
|
|
553
544
|
let wasAborted = false;
|
|
554
545
|
|
|
@@ -564,7 +555,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
564
555
|
const spawnCmd = currentPiCli ? process.execPath : "pi";
|
|
565
556
|
const spawnArgs = currentPiCli ? [currentPiCli, ...piArgs] : piArgs;
|
|
566
557
|
const proc = spawn(spawnCmd, spawnArgs, {
|
|
567
|
-
cwd:
|
|
558
|
+
cwd: taskCwd ?? cwd,
|
|
568
559
|
shell: false,
|
|
569
560
|
stdio: ["ignore", "pipe", "pipe"],
|
|
570
561
|
env: {
|
|
@@ -698,10 +689,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
698
689
|
});
|
|
699
690
|
|
|
700
691
|
proc.on("error", (err) => {
|
|
701
|
-
|
|
702
|
-
result.stderr += result.stderr ? `\n${message}` : message;
|
|
692
|
+
result.stderr += `Spawn error: ${err.message}`;
|
|
703
693
|
result.stopReason = "error";
|
|
704
|
-
result.errorMessage = message
|
|
694
|
+
result.errorMessage = `Failed to spawn pi process: ${err.message}`;
|
|
705
695
|
doResolve(1);
|
|
706
696
|
});
|
|
707
697
|
|
|
@@ -768,6 +758,9 @@ export async function executeParallelSubprocess(
|
|
|
768
758
|
signal: AbortSignal | undefined,
|
|
769
759
|
onUpdate: OnUpdateCallback | undefined,
|
|
770
760
|
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
761
|
+
resumeResults?: SingleResult[],
|
|
762
|
+
getSessionDir?: (index: number, task: { agent: string; task: string; cwd?: string }) => string | undefined,
|
|
763
|
+
resumeExistingSessions = false,
|
|
771
764
|
): Promise<{
|
|
772
765
|
content: Array<{ type: "text"; text: string }>;
|
|
773
766
|
details: SubagentDetails;
|
|
@@ -802,7 +795,7 @@ export async function executeParallelSubprocess(
|
|
|
802
795
|
};
|
|
803
796
|
}
|
|
804
797
|
|
|
805
|
-
const allResults: SingleResult[] = tasks.map((t) => ({
|
|
798
|
+
const allResults: SingleResult[] = tasks.map((t, index) => resumeResults?.[index] ?? ({
|
|
806
799
|
agent: t.agent,
|
|
807
800
|
agentSource: "unknown" as const,
|
|
808
801
|
task: t.task,
|
|
@@ -814,6 +807,7 @@ export async function executeParallelSubprocess(
|
|
|
814
807
|
completedTurns: 0,
|
|
815
808
|
turnInProgress: false,
|
|
816
809
|
liveLog: [],
|
|
810
|
+
sessionDir: getSessionDir?.(index, t),
|
|
817
811
|
}));
|
|
818
812
|
|
|
819
813
|
const emitProgress = () => {
|
|
@@ -842,6 +836,13 @@ export async function executeParallelSubprocess(
|
|
|
842
836
|
let results: SingleResult[];
|
|
843
837
|
try {
|
|
844
838
|
results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
|
|
839
|
+
const previousResult = resumeResults?.[index];
|
|
840
|
+
if (previousResult?.exitCode === 0) {
|
|
841
|
+
allResults[index] = previousResult;
|
|
842
|
+
emitProgress();
|
|
843
|
+
return previousResult;
|
|
844
|
+
}
|
|
845
|
+
const sessionDir = previousResult?.sessionDir ?? getSessionDir?.(index, t);
|
|
845
846
|
const result = await runAgentSubprocess({
|
|
846
847
|
cwd: defaultCwd,
|
|
847
848
|
agents,
|
|
@@ -853,6 +854,9 @@ export async function executeParallelSubprocess(
|
|
|
853
854
|
maxDepth,
|
|
854
855
|
preventCycles,
|
|
855
856
|
signal,
|
|
857
|
+
sessionDir,
|
|
858
|
+
resumeSession: resumeExistingSessions && !!sessionDir,
|
|
859
|
+
initialResult: previousResult,
|
|
856
860
|
onUpdate: (partial) => {
|
|
857
861
|
if (partial.details?.results[0]) {
|
|
858
862
|
allResults[index] = partial.details.results[0];
|
|
@@ -872,8 +876,7 @@ export async function executeParallelSubprocess(
|
|
|
872
876
|
const successCount = results.filter((r) => r.exitCode === 0).length;
|
|
873
877
|
const summaries = results.map((r) => {
|
|
874
878
|
const output = getFinalOutput(r.messages);
|
|
875
|
-
|
|
876
|
-
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || error || "(no output)"}`;
|
|
879
|
+
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
|
|
877
880
|
});
|
|
878
881
|
|
|
879
882
|
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). */
|