oira666_pi-subagent 0.2.2 → 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 +47 -11
- 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
|
@@ -365,16 +365,20 @@ function buildPiArgs(
|
|
|
365
365
|
agent: AgentConfig,
|
|
366
366
|
systemPromptPath: string | null,
|
|
367
367
|
task: string,
|
|
368
|
+
sessionDir: string | undefined,
|
|
369
|
+
resumeSession: boolean,
|
|
368
370
|
): string[] {
|
|
369
371
|
const args: string[] = [
|
|
370
372
|
"--mode",
|
|
371
373
|
"json",
|
|
372
374
|
..._inheritedCliArgs.extensionArgs,
|
|
373
375
|
..._inheritedCliArgs.alwaysProxy,
|
|
374
|
-
"-p",
|
|
375
|
-
"--no-session",
|
|
376
376
|
];
|
|
377
377
|
|
|
378
|
+
if (sessionDir) args.push("--session-dir", sessionDir);
|
|
379
|
+
if (resumeSession) args.push("--continue");
|
|
380
|
+
args.push("-p");
|
|
381
|
+
|
|
378
382
|
// Agent config takes priority; fall back to parent CLI value
|
|
379
383
|
const model = agent.model ?? _inheritedCliArgs.fallbackModel;
|
|
380
384
|
if (model) args.push("--model", model);
|
|
@@ -401,7 +405,11 @@ function buildPiArgs(
|
|
|
401
405
|
}
|
|
402
406
|
|
|
403
407
|
if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
|
|
404
|
-
args.push(
|
|
408
|
+
args.push(
|
|
409
|
+
resumeSession
|
|
410
|
+
? `Continue the previous task from where you left off. Original task: ${task}`
|
|
411
|
+
: `Task: ${task}`,
|
|
412
|
+
);
|
|
405
413
|
return args;
|
|
406
414
|
}
|
|
407
415
|
|
|
@@ -434,6 +442,12 @@ export interface RunAgentOptions {
|
|
|
434
442
|
onUpdate?: OnUpdateCallback;
|
|
435
443
|
/** Factory to wrap results into SubagentDetails. */
|
|
436
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;
|
|
437
451
|
}
|
|
438
452
|
|
|
439
453
|
/**
|
|
@@ -455,6 +469,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
455
469
|
signal,
|
|
456
470
|
onUpdate,
|
|
457
471
|
makeDetails,
|
|
472
|
+
sessionDir,
|
|
473
|
+
resumeSession = false,
|
|
474
|
+
initialResult,
|
|
458
475
|
} = opts;
|
|
459
476
|
|
|
460
477
|
const agent = agents.find((a) => a.name === agentName);
|
|
@@ -472,6 +489,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
472
489
|
completedTurns: 0,
|
|
473
490
|
turnInProgress: false,
|
|
474
491
|
liveLog: [],
|
|
492
|
+
sessionDir: opts.sessionDir,
|
|
475
493
|
};
|
|
476
494
|
}
|
|
477
495
|
|
|
@@ -480,14 +498,16 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
480
498
|
agentSource: agent.source,
|
|
481
499
|
task,
|
|
482
500
|
exitCode: -1,
|
|
483
|
-
messages: [],
|
|
484
|
-
stderr: "",
|
|
485
|
-
usage: emptyUsage(),
|
|
486
|
-
toolCalls: {},
|
|
487
|
-
model: agent.model,
|
|
488
|
-
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,
|
|
489
507
|
turnInProgress: false,
|
|
490
|
-
|
|
508
|
+
liveToolExecutions: initialResult?.liveToolExecutions,
|
|
509
|
+
liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
|
|
510
|
+
sessionDir,
|
|
491
511
|
};
|
|
492
512
|
|
|
493
513
|
const emitUpdate = () => {
|
|
@@ -518,6 +538,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
518
538
|
agent,
|
|
519
539
|
promptTmpPath,
|
|
520
540
|
task,
|
|
541
|
+
sessionDir,
|
|
542
|
+
resumeSession,
|
|
521
543
|
);
|
|
522
544
|
let wasAborted = false;
|
|
523
545
|
|
|
@@ -736,6 +758,9 @@ export async function executeParallelSubprocess(
|
|
|
736
758
|
signal: AbortSignal | undefined,
|
|
737
759
|
onUpdate: OnUpdateCallback | undefined,
|
|
738
760
|
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
761
|
+
resumeResults?: SingleResult[],
|
|
762
|
+
getSessionDir?: (index: number, task: { agent: string; task: string; cwd?: string }) => string | undefined,
|
|
763
|
+
resumeExistingSessions = false,
|
|
739
764
|
): Promise<{
|
|
740
765
|
content: Array<{ type: "text"; text: string }>;
|
|
741
766
|
details: SubagentDetails;
|
|
@@ -770,7 +795,7 @@ export async function executeParallelSubprocess(
|
|
|
770
795
|
};
|
|
771
796
|
}
|
|
772
797
|
|
|
773
|
-
const allResults: SingleResult[] = tasks.map((t) => ({
|
|
798
|
+
const allResults: SingleResult[] = tasks.map((t, index) => resumeResults?.[index] ?? ({
|
|
774
799
|
agent: t.agent,
|
|
775
800
|
agentSource: "unknown" as const,
|
|
776
801
|
task: t.task,
|
|
@@ -782,6 +807,7 @@ export async function executeParallelSubprocess(
|
|
|
782
807
|
completedTurns: 0,
|
|
783
808
|
turnInProgress: false,
|
|
784
809
|
liveLog: [],
|
|
810
|
+
sessionDir: getSessionDir?.(index, t),
|
|
785
811
|
}));
|
|
786
812
|
|
|
787
813
|
const emitProgress = () => {
|
|
@@ -810,6 +836,13 @@ export async function executeParallelSubprocess(
|
|
|
810
836
|
let results: SingleResult[];
|
|
811
837
|
try {
|
|
812
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);
|
|
813
846
|
const result = await runAgentSubprocess({
|
|
814
847
|
cwd: defaultCwd,
|
|
815
848
|
agents,
|
|
@@ -821,6 +854,9 @@ export async function executeParallelSubprocess(
|
|
|
821
854
|
maxDepth,
|
|
822
855
|
preventCycles,
|
|
823
856
|
signal,
|
|
857
|
+
sessionDir,
|
|
858
|
+
resumeSession: resumeExistingSessions && !!sessionDir,
|
|
859
|
+
initialResult: previousResult,
|
|
824
860
|
onUpdate: (partial) => {
|
|
825
861
|
if (partial.details?.results[0]) {
|
|
826
862
|
allResults[index] = partial.details.results[0];
|
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). */
|