oira666_pi-subagent 0.2.3 → 0.2.5
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 +301 -3
- package/package.json +2 -1
- package/render.ts +50 -6
- package/resume.ts +182 -0
- package/runner.ts +69 -50
- 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
|
@@ -9,10 +9,25 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
createAssistantMessageEventStream,
|
|
14
|
+
streamSimple as streamModelSimple,
|
|
15
|
+
} from "@mariozechner/pi-ai";
|
|
12
16
|
import { Type } from "@sinclair/typebox";
|
|
13
17
|
import { type AgentConfig, discoverAgents } from "./agents.js";
|
|
14
18
|
import { renderCall, renderResult } from "./render.js";
|
|
15
19
|
import { runAgentSubprocess, executeParallelSubprocess } from "./runner.js";
|
|
20
|
+
import {
|
|
21
|
+
SUBAGENT_RESUME_DISABLE_ENV,
|
|
22
|
+
SUBAGENT_RESUME_PROMPT_ENV,
|
|
23
|
+
buildSubagentSessionDir,
|
|
24
|
+
findLatestResumableSubagentCall,
|
|
25
|
+
getDefaultSubagentSessionRoot,
|
|
26
|
+
isFinishedResult,
|
|
27
|
+
parseBooleanEnv,
|
|
28
|
+
sameTasks,
|
|
29
|
+
type ResumableSubagentCall,
|
|
30
|
+
} from "./resume.js";
|
|
16
31
|
import {
|
|
17
32
|
DEFAULT_MAX_PARALLEL_TASKS,
|
|
18
33
|
SUBAGENT_MAX_PARALLEL_TASKS_ENV,
|
|
@@ -339,11 +354,82 @@ function getProjectAgentSessionKey(projectAgentsDir: string | null): string {
|
|
|
339
354
|
return projectAgentsDir ?? "(unknown-project-agents-dir)";
|
|
340
355
|
}
|
|
341
356
|
|
|
357
|
+
function ensureSubagentToolActive(pi: ExtensionAPI): void {
|
|
358
|
+
const activeTools = pi.getActiveTools();
|
|
359
|
+
if (!activeTools.includes("subagent")) {
|
|
360
|
+
pi.setActiveTools([...activeTools, "subagent"]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function hasCliInitialPrompt(argv: string[]): boolean {
|
|
365
|
+
for (let i = 2; i < argv.length; i++) {
|
|
366
|
+
const arg = argv[i];
|
|
367
|
+
if (arg === "-p" || arg === "--print") return true;
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const RESUME_PROVIDER = "pi-subagent-resume";
|
|
373
|
+
const RESUME_MODEL_ID = "synthetic-tool-call";
|
|
374
|
+
const RESUME_STATE_KEY = "__piSubagentResumeState";
|
|
375
|
+
|
|
376
|
+
type SyntheticResumeState = {
|
|
377
|
+
plan: ResumableSubagentCall | null;
|
|
378
|
+
phase: "tool" | "final";
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
function clearSyntheticResumeState(): void {
|
|
382
|
+
const state = getSyntheticResumeState();
|
|
383
|
+
state.plan = null;
|
|
384
|
+
state.phase = "tool";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function getSyntheticResumeState(): SyntheticResumeState {
|
|
388
|
+
const g = globalThis as any;
|
|
389
|
+
if (!g[RESUME_STATE_KEY]) {
|
|
390
|
+
g[RESUME_STATE_KEY] = { plan: null, phase: "tool" } satisfies SyntheticResumeState;
|
|
391
|
+
}
|
|
392
|
+
return g[RESUME_STATE_KEY] as SyntheticResumeState;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function emptyModelUsage() {
|
|
396
|
+
return {
|
|
397
|
+
input: 0,
|
|
398
|
+
output: 0,
|
|
399
|
+
cacheRead: 0,
|
|
400
|
+
cacheWrite: 0,
|
|
401
|
+
totalTokens: 0,
|
|
402
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function getMessageText(message: any): string {
|
|
407
|
+
const content = message?.content;
|
|
408
|
+
if (typeof content === "string") return content;
|
|
409
|
+
if (!Array.isArray(content)) return "";
|
|
410
|
+
return content
|
|
411
|
+
.map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
|
|
412
|
+
.join("");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isSyntheticResumePrompt(context: any, taskCount: number): boolean {
|
|
416
|
+
const messages = Array.isArray(context?.messages) ? context.messages : [];
|
|
417
|
+
const lastUser = [...messages].reverse().find((message) => message?.role === "user");
|
|
418
|
+
return getMessageText(lastUser).trim() === `Resuming ${taskCount} subagents...`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function formatModelFlag(model: any): string | undefined {
|
|
422
|
+
if (!model?.id || !model?.provider) return undefined;
|
|
423
|
+
return `${model.provider}/${model.id}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
342
426
|
// ---------------------------------------------------------------------------
|
|
343
427
|
// Extension entry point
|
|
344
428
|
// ---------------------------------------------------------------------------
|
|
345
429
|
|
|
346
430
|
export default function (pi: ExtensionAPI) {
|
|
431
|
+
let resumeModelRegistry: any | undefined;
|
|
432
|
+
|
|
347
433
|
pi.registerFlag("subagent-max-depth", {
|
|
348
434
|
description: "Maximum allowed subagent delegation depth (default: 3).",
|
|
349
435
|
type: "string",
|
|
@@ -354,6 +440,97 @@ export default function (pi: ExtensionAPI) {
|
|
|
354
440
|
type: "boolean",
|
|
355
441
|
});
|
|
356
442
|
|
|
443
|
+
pi.registerProvider(RESUME_PROVIDER, {
|
|
444
|
+
baseUrl: "http://127.0.0.1/pi-subagent-resume",
|
|
445
|
+
api: "openai-responses",
|
|
446
|
+
apiKey: "pi-subagent-resume-noop-key",
|
|
447
|
+
streamSimple: async (model, context, options) => {
|
|
448
|
+
const stream = createAssistantMessageEventStream();
|
|
449
|
+
const state = getSyntheticResumeState();
|
|
450
|
+
const plan = state.plan;
|
|
451
|
+
const phase = state.phase;
|
|
452
|
+
if (plan && phase === "tool" && isSyntheticResumePrompt(context, plan.tasks.length)) {
|
|
453
|
+
state.phase = "final";
|
|
454
|
+
const toolCall = {
|
|
455
|
+
type: "toolCall" as const,
|
|
456
|
+
id: `resume_subagent_${Date.now()}`,
|
|
457
|
+
name: "subagent",
|
|
458
|
+
arguments: { tasks: plan.tasks },
|
|
459
|
+
};
|
|
460
|
+
const message = {
|
|
461
|
+
role: "assistant" as const,
|
|
462
|
+
content: [toolCall],
|
|
463
|
+
api: model.api,
|
|
464
|
+
provider: model.provider,
|
|
465
|
+
model: model.id,
|
|
466
|
+
usage: emptyModelUsage(),
|
|
467
|
+
stopReason: "toolUse" as const,
|
|
468
|
+
timestamp: Date.now(),
|
|
469
|
+
};
|
|
470
|
+
queueMicrotask(() => {
|
|
471
|
+
stream.push({ type: "start", partial: message });
|
|
472
|
+
stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
|
|
473
|
+
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: message });
|
|
474
|
+
stream.push({ type: "done", reason: "toolUse", message });
|
|
475
|
+
stream.end(message);
|
|
476
|
+
});
|
|
477
|
+
return stream;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (phase === "final" && modelToRestoreAfterResume) {
|
|
481
|
+
const restore = modelToRestoreAfterResume;
|
|
482
|
+
const auth = resumeModelRegistry
|
|
483
|
+
? await resumeModelRegistry.getApiKeyAndHeaders(restore)
|
|
484
|
+
: { ok: true, apiKey: undefined, headers: undefined };
|
|
485
|
+
if (!auth.ok) {
|
|
486
|
+
throw new Error(auth.error);
|
|
487
|
+
}
|
|
488
|
+
return streamModelSimple(restore, context, {
|
|
489
|
+
...options,
|
|
490
|
+
apiKey: auth.apiKey,
|
|
491
|
+
headers: auth.headers,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!(plan && phase === "tool")) {
|
|
496
|
+
state.plan = null;
|
|
497
|
+
state.phase = "tool";
|
|
498
|
+
}
|
|
499
|
+
const message = {
|
|
500
|
+
role: "assistant" as const,
|
|
501
|
+
content: [],
|
|
502
|
+
api: model.api,
|
|
503
|
+
provider: model.provider,
|
|
504
|
+
model: model.id,
|
|
505
|
+
usage: emptyModelUsage(),
|
|
506
|
+
stopReason: "stop" as const,
|
|
507
|
+
timestamp: Date.now(),
|
|
508
|
+
};
|
|
509
|
+
queueMicrotask(() => {
|
|
510
|
+
stream.push({ type: "start", partial: message });
|
|
511
|
+
stream.push({ type: "done", reason: "stop", message });
|
|
512
|
+
stream.end(message);
|
|
513
|
+
});
|
|
514
|
+
return stream;
|
|
515
|
+
},
|
|
516
|
+
models: [
|
|
517
|
+
{
|
|
518
|
+
id: RESUME_MODEL_ID,
|
|
519
|
+
name: "Pi Subagent Resume",
|
|
520
|
+
api: "openai-responses",
|
|
521
|
+
reasoning: false,
|
|
522
|
+
input: ["text"],
|
|
523
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
524
|
+
// Keep this large so Pi's pre-prompt auto-compaction does not call the
|
|
525
|
+
// synthetic provider before the visible resume prompt is appended. That
|
|
526
|
+
// would consume the tool-call phase during compaction and the real
|
|
527
|
+
// resume turn would only see the final text message.
|
|
528
|
+
contextWindow: 1_000_000,
|
|
529
|
+
maxTokens: 16,
|
|
530
|
+
},
|
|
531
|
+
],
|
|
532
|
+
});
|
|
533
|
+
|
|
357
534
|
const depthConfig = resolveDelegationDepthConfig(pi);
|
|
358
535
|
const { currentDepth, maxDepth, canDelegate, ancestorAgentStack, preventCycles } =
|
|
359
536
|
depthConfig;
|
|
@@ -362,14 +539,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
362
539
|
DEFAULT_MAX_PARALLEL_TASKS;
|
|
363
540
|
|
|
364
541
|
let discoveredAgents: AgentConfig[] = [];
|
|
542
|
+
let currentSessionId = "ephemeral";
|
|
543
|
+
let currentSubagentSessionRoot = "";
|
|
544
|
+
let pendingResumePlan: ResumableSubagentCall | null = null;
|
|
545
|
+
let modelToRestoreAfterResume: any | undefined;
|
|
365
546
|
const approvedProjectAgentDirsForSession = new Set<string>();
|
|
366
547
|
|
|
548
|
+
async function restoreModelAfterResumeFailure(ctx?: { ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void } }) {
|
|
549
|
+
const restore = modelToRestoreAfterResume;
|
|
550
|
+
modelToRestoreAfterResume = undefined;
|
|
551
|
+
pendingResumePlan = null;
|
|
552
|
+
clearSyntheticResumeState();
|
|
553
|
+
if (!restore) return;
|
|
554
|
+
try {
|
|
555
|
+
await pi.setModel(restore);
|
|
556
|
+
} catch (err) {
|
|
557
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
558
|
+
ctx?.ui?.notify?.(`Failed to restore model after subagent resume error: ${message}`, "error");
|
|
559
|
+
console.error("[pi-subagent] Failed to restore model after resume error:", err);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
367
563
|
// Auto-discover agents on session start
|
|
368
|
-
pi.on("session_start", async (
|
|
564
|
+
pi.on("session_start", async (event, ctx) => {
|
|
369
565
|
if (!canDelegate) return;
|
|
370
566
|
try {
|
|
371
567
|
const discovery = discoverAgents(ctx.cwd, "both");
|
|
372
568
|
discoveredAgents = discovery.agents;
|
|
569
|
+
currentSessionId = ctx.sessionManager.getSessionId?.() ?? "ephemeral";
|
|
570
|
+
currentSubagentSessionRoot = getDefaultSubagentSessionRoot(ctx);
|
|
373
571
|
|
|
374
572
|
if (discoveredAgents.length > 0 && ctx.hasUI) {
|
|
375
573
|
const list = discoveredAgents
|
|
@@ -380,11 +578,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
380
578
|
"info",
|
|
381
579
|
);
|
|
382
580
|
}
|
|
581
|
+
|
|
582
|
+
const resumeDisabled = parseBooleanEnv(process.env[SUBAGENT_RESUME_DISABLE_ENV]) === true;
|
|
583
|
+
if (resumeDisabled || (event.reason !== "resume" && event.reason !== "startup")) return;
|
|
584
|
+
|
|
585
|
+
const plan = findLatestResumableSubagentCall(ctx);
|
|
586
|
+
if (!plan) return;
|
|
587
|
+
|
|
588
|
+
let shouldResume = true;
|
|
589
|
+
const shouldPrompt = parseBooleanEnv(process.env[SUBAGENT_RESUME_PROMPT_ENV]) !== false;
|
|
590
|
+
if (ctx.hasUI && shouldPrompt) {
|
|
591
|
+
shouldResume = await ctx.ui.confirm(
|
|
592
|
+
"Resume subagents?",
|
|
593
|
+
`The resumed session has an unfinished subagent call (${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"}). Resume it from saved subagent sessions?`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (!shouldResume) return;
|
|
597
|
+
|
|
598
|
+
pendingResumePlan = plan;
|
|
599
|
+
const resumeState = getSyntheticResumeState();
|
|
600
|
+
resumeState.plan = plan;
|
|
601
|
+
resumeState.phase = "tool";
|
|
602
|
+
modelToRestoreAfterResume = ctx.model;
|
|
603
|
+
resumeModelRegistry = ctx.modelRegistry;
|
|
604
|
+
ensureSubagentToolActive(pi);
|
|
605
|
+
const resumeModel = ctx.modelRegistry.find(RESUME_PROVIDER, RESUME_MODEL_ID);
|
|
606
|
+
if (!resumeModel || !(await pi.setModel(resumeModel))) {
|
|
607
|
+
ctx.ui.notify("Failed to switch to synthetic subagent resume model.", "error");
|
|
608
|
+
await restoreModelAfterResumeFailure(ctx);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// In print/json subprocesses there is already an initial CLI prompt about
|
|
613
|
+
// to be sent. That prompt will be answered by the synthetic provider with
|
|
614
|
+
// a real assistant subagent tool call. In interactive mode, submit a short
|
|
615
|
+
// visible prompt that triggers the same synthetic provider path.
|
|
616
|
+
if (hasCliInitialPrompt(process.argv)) {
|
|
617
|
+
if (ctx.hasUI) ctx.ui.notify(`Resuming ${plan.tasks.length} subagents...`, "info");
|
|
618
|
+
} else {
|
|
619
|
+
pi.sendUserMessage(`Resuming ${plan.tasks.length} subagents...`);
|
|
620
|
+
}
|
|
383
621
|
} catch (err) {
|
|
384
622
|
console.error("[pi-subagent] Error in session_start:", err);
|
|
623
|
+
await restoreModelAfterResumeFailure(ctx);
|
|
385
624
|
}
|
|
386
625
|
});
|
|
387
626
|
|
|
627
|
+
pi.on("agent_end", async () => {
|
|
628
|
+
await restoreModelAfterResumeFailure();
|
|
629
|
+
});
|
|
630
|
+
|
|
388
631
|
// Inject available agents into the system prompt
|
|
389
632
|
pi.on("before_agent_start", async (event) => {
|
|
390
633
|
try {
|
|
@@ -453,7 +696,7 @@ calls one after another. Do NOT put dependent tasks in the same array.
|
|
|
453
696
|
].join("\n"),
|
|
454
697
|
parameters: SubagentParams,
|
|
455
698
|
|
|
456
|
-
async execute(
|
|
699
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
457
700
|
try {
|
|
458
701
|
const discovery = discoverAgents(ctx.cwd, "both");
|
|
459
702
|
const { agents } = discovery;
|
|
@@ -558,6 +801,14 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
558
801
|
}
|
|
559
802
|
}
|
|
560
803
|
|
|
804
|
+
const resumePlan =
|
|
805
|
+
pendingResumePlan && sameTasks(pendingResumePlan.tasks, tasks)
|
|
806
|
+
? pendingResumePlan
|
|
807
|
+
: null;
|
|
808
|
+
if (resumePlan) {
|
|
809
|
+
pendingResumePlan = null;
|
|
810
|
+
}
|
|
811
|
+
|
|
561
812
|
if (tasks.length === 1) {
|
|
562
813
|
const [task] = tasks;
|
|
563
814
|
return executeSingle(
|
|
@@ -569,6 +820,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
569
820
|
signal,
|
|
570
821
|
onUpdate,
|
|
571
822
|
makeDetails,
|
|
823
|
+
resumePlan?.details?.results[0],
|
|
824
|
+
getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, 0),
|
|
825
|
+
!!resumePlan,
|
|
826
|
+
formatModelFlag(modelToRestoreAfterResume),
|
|
572
827
|
);
|
|
573
828
|
}
|
|
574
829
|
|
|
@@ -579,6 +834,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
579
834
|
signal,
|
|
580
835
|
onUpdate,
|
|
581
836
|
makeDetails,
|
|
837
|
+
resumePlan?.details?.results,
|
|
838
|
+
(index) => getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, index),
|
|
839
|
+
!!resumePlan,
|
|
840
|
+
formatModelFlag(modelToRestoreAfterResume),
|
|
582
841
|
);
|
|
583
842
|
} catch (err) {
|
|
584
843
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -592,12 +851,21 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
592
851
|
|
|
593
852
|
},
|
|
594
853
|
|
|
595
|
-
renderCall: (args, theme) => renderCall(args, theme),
|
|
854
|
+
renderCall: (args, theme, context) => renderCall(args, theme, context),
|
|
596
855
|
renderResult: (result, { expanded }, theme) =>
|
|
597
856
|
renderResult(result, expanded, theme),
|
|
598
857
|
});
|
|
599
858
|
}
|
|
600
859
|
|
|
860
|
+
function getSessionDirForTask(toolCallId: string, index: number): string {
|
|
861
|
+
const root = currentSubagentSessionRoot || pathlessSubagentRootFallback();
|
|
862
|
+
return buildSubagentSessionDir(root, currentSessionId, toolCallId, index);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function pathlessSubagentRootFallback(): string {
|
|
866
|
+
return `${process.env.HOME ?? "."}/.pi/agent/sessions-subagents`;
|
|
867
|
+
}
|
|
868
|
+
|
|
601
869
|
// -----------------------------------------------------------------------
|
|
602
870
|
// Mode implementations
|
|
603
871
|
// -----------------------------------------------------------------------
|
|
@@ -611,7 +879,23 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
611
879
|
signal: AbortSignal | undefined,
|
|
612
880
|
onUpdate: ((partial: any) => void) | undefined,
|
|
613
881
|
makeDetails: ReturnType<typeof makeDetailsFactory>,
|
|
882
|
+
previousResult: SingleResult | undefined,
|
|
883
|
+
sessionDir: string,
|
|
884
|
+
resumeExistingSession: boolean,
|
|
885
|
+
fallbackModel?: string,
|
|
614
886
|
) {
|
|
887
|
+
if (previousResult && isFinishedResult(previousResult)) {
|
|
888
|
+
return {
|
|
889
|
+
content: [
|
|
890
|
+
{
|
|
891
|
+
type: "text" as const,
|
|
892
|
+
text: getFinalOutput(previousResult.messages) || "(no output)",
|
|
893
|
+
},
|
|
894
|
+
],
|
|
895
|
+
details: makeDetails("single")([previousResult]),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
615
899
|
const result = await runAgentSubprocess({
|
|
616
900
|
cwd: defaultCwd,
|
|
617
901
|
agents,
|
|
@@ -625,6 +909,11 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
625
909
|
signal,
|
|
626
910
|
onUpdate,
|
|
627
911
|
makeDetails: makeDetails("single"),
|
|
912
|
+
sessionDir: previousResult?.sessionDir ?? sessionDir,
|
|
913
|
+
sessionRoot: currentSubagentSessionRoot,
|
|
914
|
+
resumeSession: resumeExistingSession,
|
|
915
|
+
initialResult: previousResult,
|
|
916
|
+
fallbackModel,
|
|
628
917
|
});
|
|
629
918
|
|
|
630
919
|
if (isResultError(result)) {
|
|
@@ -662,6 +951,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
662
951
|
signal: AbortSignal | undefined,
|
|
663
952
|
onUpdate: ((partial: any) => void) | undefined,
|
|
664
953
|
makeDetails: ReturnType<typeof makeDetailsFactory>,
|
|
954
|
+
resumeResults: SingleResult[] | undefined,
|
|
955
|
+
getSessionDir: (index: number) => string,
|
|
956
|
+
resumeExistingSessions: boolean,
|
|
957
|
+
fallbackModel?: string,
|
|
665
958
|
) {
|
|
666
959
|
return executeParallelSubprocess(
|
|
667
960
|
tasks,
|
|
@@ -674,6 +967,11 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
674
967
|
signal,
|
|
675
968
|
onUpdate,
|
|
676
969
|
makeDetails("parallel"),
|
|
970
|
+
resumeResults,
|
|
971
|
+
(index) => getSessionDir(index),
|
|
972
|
+
resumeExistingSessions,
|
|
973
|
+
currentSubagentSessionRoot,
|
|
974
|
+
fallbackModel,
|
|
677
975
|
);
|
|
678
976
|
}
|
|
679
977
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oira666_pi-subagent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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
|
|
|
@@ -365,14 +400,23 @@ function topLevelSummary(details: SubagentDetails, counts: TreeCounts): string {
|
|
|
365
400
|
// renderCall — shown while the tool is being invoked
|
|
366
401
|
// ---------------------------------------------------------------------------
|
|
367
402
|
|
|
368
|
-
export function renderCall(
|
|
403
|
+
export function renderCall(
|
|
404
|
+
args: Record<string, any>,
|
|
405
|
+
theme: { fg: ThemeFg; bold: (s: string) => string },
|
|
406
|
+
context?: { isPartial?: boolean; isError?: boolean },
|
|
407
|
+
): Text {
|
|
369
408
|
const tasks = Array.isArray(args.tasks) ? args.tasks : [];
|
|
370
409
|
const count = tasks.length;
|
|
410
|
+
const icon = context?.isPartial === false
|
|
411
|
+
? context.isError
|
|
412
|
+
? theme.fg("error", "❌")
|
|
413
|
+
: theme.fg("success", "✅")
|
|
414
|
+
: theme.fg("warning", "⏳");
|
|
371
415
|
let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `${count} task${count === 1 ? "" : "s"}`)}`;
|
|
372
416
|
for (const task of tasks.slice(0, 6)) {
|
|
373
417
|
const agent = typeof task?.agent === "string" ? task.agent : "...";
|
|
374
418
|
const preview = typeof task?.task === "string" ? ` ${truncate(task.task, 56)}` : "";
|
|
375
|
-
text += `\n ${
|
|
419
|
+
text += `\n ${icon} ${theme.fg("accent", agent)}${theme.fg("dim", preview)}`;
|
|
376
420
|
}
|
|
377
421
|
if (tasks.length > 6) text += `\n ${theme.fg("muted", `... +${tasks.length - 6} more`)}`;
|
|
378
422
|
return new Text(text, 0, 0);
|
package/resume.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
export const SUBAGENT_SESSION_ROOT_ENV = "PI_SUBAGENT_SESSION_ROOT";
|
|
10
|
+
|
|
11
|
+
type SessionEntry = ReturnType<ExtensionContext["sessionManager"]["getEntries"]>[number];
|
|
12
|
+
|
|
13
|
+
export interface ResumableSubagentCall {
|
|
14
|
+
previousToolCallId: string;
|
|
15
|
+
tasks: Array<{ agent: string; task: string; cwd?: string }>;
|
|
16
|
+
details?: SubagentDetails;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseBooleanEnv(raw: unknown): boolean | null {
|
|
20
|
+
if (typeof raw === "boolean") return raw;
|
|
21
|
+
if (typeof raw !== "string") return null;
|
|
22
|
+
const normalized = raw.trim().toLowerCase();
|
|
23
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
24
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getDefaultSubagentSessionRoot(ctx: ExtensionContext): string {
|
|
29
|
+
const inheritedRoot = process.env[SUBAGENT_SESSION_ROOT_ENV];
|
|
30
|
+
if (inheritedRoot) return inheritedRoot;
|
|
31
|
+
|
|
32
|
+
const mainSessionDir = ctx.sessionManager.getSessionDir?.();
|
|
33
|
+
if (typeof mainSessionDir === "string" && mainSessionDir.length > 0) {
|
|
34
|
+
return path.join(path.dirname(mainSessionDir), "sessions-subagents");
|
|
35
|
+
}
|
|
36
|
+
return path.join(os.homedir(), ".pi", "agent", "sessions-subagents");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildSubagentSessionDir(
|
|
40
|
+
root: string,
|
|
41
|
+
parentSessionId: string,
|
|
42
|
+
toolCallId: string,
|
|
43
|
+
index: number,
|
|
44
|
+
): string {
|
|
45
|
+
const safeParent = parentSessionId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
46
|
+
const safeTool = toolCallId.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
47
|
+
return path.join(root, safeParent, safeTool, String(index));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ensureDir(dir: string): void {
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function branchEntries(ctx: ExtensionContext): SessionEntry[] {
|
|
55
|
+
const leafId = ctx.sessionManager.getLeafId?.();
|
|
56
|
+
if (leafId) {
|
|
57
|
+
const branch = ctx.sessionManager.getBranch?.(leafId);
|
|
58
|
+
if (Array.isArray(branch)) return branch as SessionEntry[];
|
|
59
|
+
}
|
|
60
|
+
const entries = ctx.sessionManager.getEntries?.();
|
|
61
|
+
return Array.isArray(entries) ? entries as SessionEntry[] : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getSubagentToolCalls(message: any): Array<{ id: string; args: any }> {
|
|
65
|
+
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
|
|
66
|
+
const calls: Array<{ id: string; args: any }> = [];
|
|
67
|
+
for (const part of message.content) {
|
|
68
|
+
if (part?.type === "toolCall" && part.name === "subagent" && typeof part.id === "string") {
|
|
69
|
+
calls.push({ id: part.id, args: part.arguments });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return calls;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeTasks(args: any): Array<{ agent: string; task: string; cwd?: string }> | null {
|
|
76
|
+
const rawTasks = args?.tasks;
|
|
77
|
+
if (!Array.isArray(rawTasks) || rawTasks.length === 0) return null;
|
|
78
|
+
const tasks: Array<{ agent: string; task: string; cwd?: string }> = [];
|
|
79
|
+
for (const task of rawTasks) {
|
|
80
|
+
if (typeof task?.agent !== "string" || typeof task?.task !== "string") return null;
|
|
81
|
+
tasks.push({
|
|
82
|
+
agent: task.agent,
|
|
83
|
+
task: task.task,
|
|
84
|
+
...(typeof task.cwd === "string" ? { cwd: task.cwd } : {}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return tasks;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasUnfinishedResults(details: SubagentDetails | undefined): boolean {
|
|
91
|
+
if (!details) return true;
|
|
92
|
+
return details.results.some((result) => result.exitCode === -1 || isResultError(result));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function messageHasNonEmptyText(message: any): boolean {
|
|
96
|
+
const content = message?.content;
|
|
97
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
98
|
+
if (!Array.isArray(content)) return false;
|
|
99
|
+
return content.some((part) => part?.type === "text" && typeof part.text === "string" && part.text.trim().length > 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function messageHasToolCall(message: any): boolean {
|
|
103
|
+
return Array.isArray(message?.content) && message.content.some((part: any) => part?.type === "toolCall");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isIgnorableTrailingAbortMessage(entry: any): boolean {
|
|
107
|
+
if (entry?.type !== "message") return true;
|
|
108
|
+
const message = entry.message;
|
|
109
|
+
if (!message) return true;
|
|
110
|
+
|
|
111
|
+
// Pi may append a final aborted/error assistant message after it has already
|
|
112
|
+
// closed an interrupted tool with a synthetic toolResult. That message is not
|
|
113
|
+
// user-visible progress after the subagent activity, so it must not prevent
|
|
114
|
+
// resume detection.
|
|
115
|
+
if (
|
|
116
|
+
message.role === "assistant" &&
|
|
117
|
+
(message.stopReason === "aborted" || message.stopReason === "error") &&
|
|
118
|
+
!messageHasNonEmptyText(message) &&
|
|
119
|
+
!messageHasToolCall(message)
|
|
120
|
+
) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder: number): boolean {
|
|
128
|
+
for (let i = activityOrder + 1; i < entries.length; i++) {
|
|
129
|
+
if (!isIgnorableTrailingAbortMessage(entries[i])) return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
|
|
135
|
+
const entries = branchEntries(ctx);
|
|
136
|
+
const calls = new Map<string, { tasks: Array<{ agent: string; task: string; cwd?: string }>; order: number }>();
|
|
137
|
+
const results = new Map<string, { details?: SubagentDetails; isError: boolean; order: number }>();
|
|
138
|
+
|
|
139
|
+
entries.forEach((entry: any, order) => {
|
|
140
|
+
if (entry?.type !== "message") return;
|
|
141
|
+
const msg = entry.message;
|
|
142
|
+
for (const call of getSubagentToolCalls(msg)) {
|
|
143
|
+
const tasks = normalizeTasks(call.args);
|
|
144
|
+
if (tasks) calls.set(call.id, { tasks, order });
|
|
145
|
+
}
|
|
146
|
+
if (msg?.role === "toolResult" && msg.toolName === "subagent" && typeof msg.toolCallId === "string") {
|
|
147
|
+
results.set(msg.toolCallId, {
|
|
148
|
+
details: isSubagentDetails(msg.details) ? msg.details : undefined,
|
|
149
|
+
isError: msg.isError === true,
|
|
150
|
+
order,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
|
|
156
|
+
for (const [toolCallId, call] of calls) {
|
|
157
|
+
const result = results.get(toolCallId);
|
|
158
|
+
const unfinished = !result || result.isError || hasUnfinishedResults(result.details);
|
|
159
|
+
if (!unfinished) continue;
|
|
160
|
+
candidates.push({
|
|
161
|
+
previousToolCallId: toolCallId,
|
|
162
|
+
tasks: call.tasks,
|
|
163
|
+
details: result?.details,
|
|
164
|
+
activityOrder: result?.order ?? call.order,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const latest = candidates.at(-1);
|
|
169
|
+
if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
|
|
170
|
+
return latest;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function sameTasks(
|
|
174
|
+
a: Array<{ agent: string; task: string; cwd?: string }>,
|
|
175
|
+
b: Array<{ agent: string; task: string; cwd?: string }>,
|
|
176
|
+
): boolean {
|
|
177
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function isFinishedResult(result: SingleResult | undefined): boolean {
|
|
181
|
+
return !!result && result.exitCode === 0 && !isResultError(result);
|
|
182
|
+
}
|
package/runner.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getFinalOutput,
|
|
22
22
|
getNestedSubagentErrorSummary,
|
|
23
23
|
} from "./types.js";
|
|
24
|
+
import { SUBAGENT_SESSION_ROOT_ENV } from "./resume.js";
|
|
24
25
|
import {
|
|
25
26
|
DEFAULT_MAX_PARALLEL_TASKS,
|
|
26
27
|
DEFAULT_MAX_CONCURRENCY,
|
|
@@ -51,6 +52,7 @@ const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
|
|
|
51
52
|
const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
|
|
52
53
|
const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
|
|
53
54
|
const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
|
|
55
|
+
const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
|
|
54
56
|
// PI_OFFLINE intentionally removed: setting it on child processes blocks all API
|
|
55
57
|
// calls and renders subagents unable to do any LLM work. Children inherit the
|
|
56
58
|
// parent's PI_OFFLINE value via process.env spread if needed.
|
|
@@ -81,25 +83,6 @@ function cleanupTempDir(dir: string | null): void {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
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
86
|
function getCurrentPiCliScript(): string | null {
|
|
104
87
|
const script = process.argv[1];
|
|
105
88
|
if (!script) return null;
|
|
@@ -324,7 +307,7 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
|
|
|
324
307
|
result.usage.cost += usage.cost?.total || 0;
|
|
325
308
|
result.usage.contextTokens = usage.totalTokens || 0;
|
|
326
309
|
}
|
|
327
|
-
if (
|
|
310
|
+
if (msg.model && msg.model !== "synthetic-tool-call") result.model = msg.model;
|
|
328
311
|
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
329
312
|
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
330
313
|
}
|
|
@@ -384,18 +367,23 @@ function buildPiArgs(
|
|
|
384
367
|
agent: AgentConfig,
|
|
385
368
|
systemPromptPath: string | null,
|
|
386
369
|
task: string,
|
|
370
|
+
sessionDir: string | undefined,
|
|
371
|
+
resumeSession: boolean,
|
|
372
|
+
fallbackModelOverride?: string,
|
|
387
373
|
): string[] {
|
|
388
374
|
const args: string[] = [
|
|
389
375
|
"--mode",
|
|
390
376
|
"json",
|
|
391
377
|
..._inheritedCliArgs.extensionArgs,
|
|
392
378
|
..._inheritedCliArgs.alwaysProxy,
|
|
393
|
-
"-p",
|
|
394
|
-
"--no-session",
|
|
395
379
|
];
|
|
396
380
|
|
|
381
|
+
if (sessionDir) args.push("--session-dir", sessionDir);
|
|
382
|
+
if (resumeSession) args.push("--continue");
|
|
383
|
+
args.push("-p");
|
|
384
|
+
|
|
397
385
|
// Agent config takes priority; fall back to parent CLI value
|
|
398
|
-
const model = agent.model ?? _inheritedCliArgs.fallbackModel;
|
|
386
|
+
const model = agent.model ?? fallbackModelOverride ?? process.env[SUBAGENT_FALLBACK_MODEL_ENV] ?? _inheritedCliArgs.fallbackModel;
|
|
399
387
|
if (model) args.push("--model", model);
|
|
400
388
|
|
|
401
389
|
const thinking = agent.thinking ?? _inheritedCliArgs.fallbackThinking;
|
|
@@ -420,7 +408,11 @@ function buildPiArgs(
|
|
|
420
408
|
}
|
|
421
409
|
|
|
422
410
|
if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
|
|
423
|
-
args.push(
|
|
411
|
+
args.push(
|
|
412
|
+
resumeSession
|
|
413
|
+
? `Continue the previous task from where you left off. Original task: ${task}`
|
|
414
|
+
: `Task: ${task}`,
|
|
415
|
+
);
|
|
424
416
|
return args;
|
|
425
417
|
}
|
|
426
418
|
|
|
@@ -453,6 +445,16 @@ export interface RunAgentOptions {
|
|
|
453
445
|
onUpdate?: OnUpdateCallback;
|
|
454
446
|
/** Factory to wrap results into SubagentDetails. */
|
|
455
447
|
makeDetails: (results: SingleResult[]) => SubagentDetails;
|
|
448
|
+
/** Dedicated session directory for this subagent process. */
|
|
449
|
+
sessionDir?: string;
|
|
450
|
+
/** Top-level root for all subagent session directories in this delegation tree. */
|
|
451
|
+
sessionRoot?: string;
|
|
452
|
+
/** Continue the most recent session in sessionDir instead of creating a new one. */
|
|
453
|
+
resumeSession?: boolean;
|
|
454
|
+
/** Previously captured state for this same subagent, used to render resumed nested trees. */
|
|
455
|
+
initialResult?: SingleResult;
|
|
456
|
+
/** Fallback model to use when the agent config does not pin one. */
|
|
457
|
+
fallbackModel?: string;
|
|
456
458
|
}
|
|
457
459
|
|
|
458
460
|
/**
|
|
@@ -474,6 +476,11 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
474
476
|
signal,
|
|
475
477
|
onUpdate,
|
|
476
478
|
makeDetails,
|
|
479
|
+
sessionDir,
|
|
480
|
+
sessionRoot,
|
|
481
|
+
resumeSession = false,
|
|
482
|
+
initialResult,
|
|
483
|
+
fallbackModel,
|
|
477
484
|
} = opts;
|
|
478
485
|
|
|
479
486
|
const agent = agents.find((a) => a.name === agentName);
|
|
@@ -491,6 +498,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
491
498
|
completedTurns: 0,
|
|
492
499
|
turnInProgress: false,
|
|
493
500
|
liveLog: [],
|
|
501
|
+
sessionDir: opts.sessionDir,
|
|
494
502
|
};
|
|
495
503
|
}
|
|
496
504
|
|
|
@@ -499,14 +507,16 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
499
507
|
agentSource: agent.source,
|
|
500
508
|
task,
|
|
501
509
|
exitCode: -1,
|
|
502
|
-
messages: [],
|
|
503
|
-
stderr: "",
|
|
504
|
-
usage: emptyUsage(),
|
|
505
|
-
toolCalls: {},
|
|
506
|
-
model: agent.model,
|
|
507
|
-
completedTurns: 0,
|
|
510
|
+
messages: initialResult?.messages ? [...initialResult.messages] : [],
|
|
511
|
+
stderr: initialResult?.stderr ?? "",
|
|
512
|
+
usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
|
|
513
|
+
toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
|
|
514
|
+
model: initialResult?.model ?? agent.model,
|
|
515
|
+
completedTurns: initialResult?.completedTurns ?? 0,
|
|
508
516
|
turnInProgress: false,
|
|
509
|
-
|
|
517
|
+
liveToolExecutions: initialResult?.liveToolExecutions,
|
|
518
|
+
liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
|
|
519
|
+
sessionDir,
|
|
510
520
|
};
|
|
511
521
|
|
|
512
522
|
const emitUpdate = () => {
|
|
@@ -523,18 +533,6 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
523
533
|
|
|
524
534
|
emitUpdate();
|
|
525
535
|
|
|
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
536
|
// Write system prompt to temp file if needed
|
|
539
537
|
let promptTmpDir: string | null = null;
|
|
540
538
|
let promptTmpPath: string | null = null;
|
|
@@ -549,6 +547,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
549
547
|
agent,
|
|
550
548
|
promptTmpPath,
|
|
551
549
|
task,
|
|
550
|
+
sessionDir,
|
|
551
|
+
resumeSession,
|
|
552
|
+
fallbackModel,
|
|
552
553
|
);
|
|
553
554
|
let wasAborted = false;
|
|
554
555
|
|
|
@@ -564,7 +565,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
564
565
|
const spawnCmd = currentPiCli ? process.execPath : "pi";
|
|
565
566
|
const spawnArgs = currentPiCli ? [currentPiCli, ...piArgs] : piArgs;
|
|
566
567
|
const proc = spawn(spawnCmd, spawnArgs, {
|
|
567
|
-
cwd:
|
|
568
|
+
cwd: taskCwd ?? cwd,
|
|
568
569
|
shell: false,
|
|
569
570
|
stdio: ["ignore", "pipe", "pipe"],
|
|
570
571
|
env: {
|
|
@@ -573,6 +574,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
573
574
|
[SUBAGENT_MAX_DEPTH_ENV]: String(propagatedMaxDepth),
|
|
574
575
|
[SUBAGENT_STACK_ENV]: JSON.stringify(propagatedStack),
|
|
575
576
|
[SUBAGENT_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
|
|
577
|
+
...(sessionRoot ? { [SUBAGENT_SESSION_ROOT_ENV]: sessionRoot } : {}),
|
|
578
|
+
...(fallbackModel ? { [SUBAGENT_FALLBACK_MODEL_ENV]: fallbackModel } : {}),
|
|
576
579
|
// PI_OFFLINE is NOT forced here — see explanation near PI_OFFLINE_ENV.
|
|
577
580
|
},
|
|
578
581
|
});
|
|
@@ -698,10 +701,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
698
701
|
});
|
|
699
702
|
|
|
700
703
|
proc.on("error", (err) => {
|
|
701
|
-
|
|
702
|
-
result.stderr += result.stderr ? `\n${message}` : message;
|
|
704
|
+
result.stderr += `Spawn error: ${err.message}`;
|
|
703
705
|
result.stopReason = "error";
|
|
704
|
-
result.errorMessage = message
|
|
706
|
+
result.errorMessage = `Failed to spawn pi process: ${err.message}`;
|
|
705
707
|
doResolve(1);
|
|
706
708
|
});
|
|
707
709
|
|
|
@@ -768,6 +770,11 @@ export async function executeParallelSubprocess(
|
|
|
768
770
|
signal: AbortSignal | undefined,
|
|
769
771
|
onUpdate: OnUpdateCallback | undefined,
|
|
770
772
|
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
773
|
+
resumeResults?: SingleResult[],
|
|
774
|
+
getSessionDir?: (index: number, task: { agent: string; task: string; cwd?: string }) => string | undefined,
|
|
775
|
+
resumeExistingSessions = false,
|
|
776
|
+
sessionRoot?: string,
|
|
777
|
+
fallbackModel?: string,
|
|
771
778
|
): Promise<{
|
|
772
779
|
content: Array<{ type: "text"; text: string }>;
|
|
773
780
|
details: SubagentDetails;
|
|
@@ -802,7 +809,7 @@ export async function executeParallelSubprocess(
|
|
|
802
809
|
};
|
|
803
810
|
}
|
|
804
811
|
|
|
805
|
-
const allResults: SingleResult[] = tasks.map((t) => ({
|
|
812
|
+
const allResults: SingleResult[] = tasks.map((t, index) => resumeResults?.[index] ?? ({
|
|
806
813
|
agent: t.agent,
|
|
807
814
|
agentSource: "unknown" as const,
|
|
808
815
|
task: t.task,
|
|
@@ -814,6 +821,7 @@ export async function executeParallelSubprocess(
|
|
|
814
821
|
completedTurns: 0,
|
|
815
822
|
turnInProgress: false,
|
|
816
823
|
liveLog: [],
|
|
824
|
+
sessionDir: getSessionDir?.(index, t),
|
|
817
825
|
}));
|
|
818
826
|
|
|
819
827
|
const emitProgress = () => {
|
|
@@ -842,6 +850,13 @@ export async function executeParallelSubprocess(
|
|
|
842
850
|
let results: SingleResult[];
|
|
843
851
|
try {
|
|
844
852
|
results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
|
|
853
|
+
const previousResult = resumeResults?.[index];
|
|
854
|
+
if (previousResult?.exitCode === 0) {
|
|
855
|
+
allResults[index] = previousResult;
|
|
856
|
+
emitProgress();
|
|
857
|
+
return previousResult;
|
|
858
|
+
}
|
|
859
|
+
const sessionDir = previousResult?.sessionDir ?? getSessionDir?.(index, t);
|
|
845
860
|
const result = await runAgentSubprocess({
|
|
846
861
|
cwd: defaultCwd,
|
|
847
862
|
agents,
|
|
@@ -853,6 +868,11 @@ export async function executeParallelSubprocess(
|
|
|
853
868
|
maxDepth,
|
|
854
869
|
preventCycles,
|
|
855
870
|
signal,
|
|
871
|
+
sessionDir,
|
|
872
|
+
sessionRoot,
|
|
873
|
+
resumeSession: resumeExistingSessions && !!sessionDir,
|
|
874
|
+
initialResult: previousResult,
|
|
875
|
+
fallbackModel,
|
|
856
876
|
onUpdate: (partial) => {
|
|
857
877
|
if (partial.details?.results[0]) {
|
|
858
878
|
allResults[index] = partial.details.results[0];
|
|
@@ -872,8 +892,7 @@ export async function executeParallelSubprocess(
|
|
|
872
892
|
const successCount = results.filter((r) => r.exitCode === 0).length;
|
|
873
893
|
const summaries = results.map((r) => {
|
|
874
894
|
const output = getFinalOutput(r.messages);
|
|
875
|
-
|
|
876
|
-
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || error || "(no output)"}`;
|
|
895
|
+
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
|
|
877
896
|
});
|
|
878
897
|
|
|
879
898
|
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). */
|