oira666_pi-subagent 0.2.24 → 0.2.26

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 CHANGED
@@ -117,7 +117,7 @@ pi --no-subagent-prevent-cycles # allow cycles (not recommended)
117
117
 
118
118
  | Env Var | Default | Description |
119
119
  | -------------------------------- | ------- | ---------------------------------------- |
120
- | `PI_SUBAGENT_MAX_PARALLEL_TASKS` | `16` | Max tasks per single call |
120
+ | `PI_SUBAGENT_MAX_PARALLEL_TASKS` | `30` | Max tasks per single call |
121
121
  | `PI_SUBAGENT_MAX_CONCURRENCY` | `8` | Max subagents running simultaneously |
122
122
 
123
123
  ## Steering Running Subagents
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: team-lead
3
+ description: Focused on tasks managment, delegates work to its subagents. For more compex tasks.
4
+ ---
5
+
6
+ You are an experienced team lead, focused on tasks management. You don't do any work yourself. You delegate.
7
+
8
+ Your job is to split your task to small steps and delegate each step to your subagents for implemetation.
9
+ You control quality and deal with all the unexpected situations and errors.
10
+
package/index.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  SUBAGENT_RESUME_PROMPT_ENV,
23
23
  branchEntries,
24
24
  buildSubagentSessionDir,
25
- findLatestResumableSubagentCall,
25
+ findLatestResumableSubagentCalls,
26
26
  getDefaultSubagentSessionRoot,
27
27
  isFinishedResult,
28
28
  parseBooleanEnv,
@@ -55,7 +55,7 @@ import {
55
55
  // ---------------------------------------------------------------------------
56
56
 
57
57
  const DEFAULT_MAX_DELEGATION_DEPTH = 3;
58
- const DEFAULT_PREVENT_CYCLE_DELEGATION = false;
58
+ const DEFAULT_PREVENT_CYCLE_DELEGATION = true;
59
59
  const DEFAULT_PROJECT_AGENT_CONFIRMATION = "ask";
60
60
  const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
61
61
  const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
@@ -355,6 +355,15 @@ function ensureSubagentToolActive(pi: ExtensionAPI): void {
355
355
  }
356
356
  }
357
357
 
358
+ function isRpcMode(argv: string[]): boolean {
359
+ for (let i = 2; i < argv.length; i++) {
360
+ const arg = argv[i];
361
+ if (arg === "--mode" && argv[i + 1] === "rpc") return true;
362
+ if (arg === "--mode=rpc") return true;
363
+ }
364
+ return false;
365
+ }
366
+
358
367
  function hasCliInitialPrompt(argv: string[]): boolean {
359
368
  for (let i = 2; i < argv.length; i++) {
360
369
  const arg = argv[i];
@@ -381,14 +390,14 @@ const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
381
390
  const RESUME_INTERACTIVE_DELAY_MS = 50;
382
391
 
383
392
  type SyntheticResumeState = {
384
- plan: ResumableSubagentCall | null;
393
+ plans: ResumableSubagentCall[];
385
394
  phase: "tool" | "final";
386
395
  trigger: "resumePrompt" | "nextRequest";
387
396
  };
388
397
 
389
398
  function clearSyntheticResumeState(): void {
390
399
  const state = getSyntheticResumeState();
391
- state.plan = null;
400
+ state.plans = [];
392
401
  state.phase = "tool";
393
402
  state.trigger = "resumePrompt";
394
403
  }
@@ -396,7 +405,7 @@ function clearSyntheticResumeState(): void {
396
405
  function getSyntheticResumeState(): SyntheticResumeState {
397
406
  const g = globalThis as any;
398
407
  if (!g[RESUME_STATE_KEY]) {
399
- g[RESUME_STATE_KEY] = { plan: null, phase: "tool", trigger: "resumePrompt" } satisfies SyntheticResumeState;
408
+ g[RESUME_STATE_KEY] = { plans: [], phase: "tool", trigger: "resumePrompt" } satisfies SyntheticResumeState;
400
409
  }
401
410
  return g[RESUME_STATE_KEY] as SyntheticResumeState;
402
411
  }
@@ -518,27 +527,34 @@ export default function (pi: ExtensionAPI) {
518
527
  streamSimple: async (model, context, options) => {
519
528
  const stream = createAssistantMessageEventStream();
520
529
  const state = getSyntheticResumeState();
521
- const discoveredPlan = state.plan ?? pendingResumePlan ?? (latestSessionCtx ? findLatestResumableSubagentCall(latestSessionCtx) : null);
522
- if (discoveredPlan && !state.plan) {
523
- state.plan = discoveredPlan;
524
- pendingResumePlan = discoveredPlan;
530
+ const discoveredPlans = state.plans.length > 0
531
+ ? state.plans
532
+ : pendingResumePlans.length > 0
533
+ ? pendingResumePlans
534
+ : latestSessionCtx
535
+ ? findLatestResumableSubagentCalls(latestSessionCtx)
536
+ : [];
537
+ if (discoveredPlans.length > 0 && state.plans.length === 0) {
538
+ state.plans = [...discoveredPlans];
539
+ pendingResumePlans = [...discoveredPlans];
525
540
  }
526
- const plan = discoveredPlan;
541
+ const plans = discoveredPlans;
542
+ const totalTaskCount = plans.reduce((sum, plan) => sum + plan.tasks.length, 0);
527
543
  const phase = state.phase;
528
544
  const triggerMatches =
529
545
  state.trigger === "nextRequest" ||
530
- (plan ? isSyntheticResumePrompt(context, plan.tasks.length) : false);
531
- if (plan && phase === "tool" && triggerMatches) {
546
+ (totalTaskCount > 0 ? isSyntheticResumePrompt(context, totalTaskCount) : false);
547
+ if (plans.length > 0 && phase === "tool" && triggerMatches) {
532
548
  state.phase = "final";
533
- const toolCall = {
549
+ const toolCalls = plans.map((plan, index) => ({
534
550
  type: "toolCall" as const,
535
- id: `resume_subagent_${Date.now()}`,
551
+ id: `resume_subagent_${Date.now()}_${index}`,
536
552
  name: "subagent",
537
553
  arguments: { tasks: plan.tasks },
538
- };
554
+ }));
539
555
  const message = {
540
556
  role: "assistant" as const,
541
- content: [toolCall],
557
+ content: toolCalls,
542
558
  api: model.api,
543
559
  provider: model.provider,
544
560
  model: model.id,
@@ -548,13 +564,15 @@ export default function (pi: ExtensionAPI) {
548
564
  };
549
565
  queueMicrotask(() => {
550
566
  stream.push({ type: "start", partial: message });
551
- stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
552
- stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: message });
567
+ toolCalls.forEach((toolCall, contentIndex) => {
568
+ stream.push({ type: "toolcall_start", contentIndex, partial: message });
569
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: message });
570
+ });
553
571
  stream.push({ type: "done", reason: "toolUse", message });
554
572
  stream.end(message);
555
573
 
556
574
  // The synthetic provider's only job is to inject the resumed subagent
557
- // tool call. Restore the real model immediately after that handoff so
575
+ // tool calls. Restore the real model immediately after that handoff so
558
576
  // the TUI does not appear stuck on `pi-subagent-resume` while the
559
577
  // subagent tool execution is still running.
560
578
  void restoreVisibleModelForResume();
@@ -572,8 +590,8 @@ export default function (pi: ExtensionAPI) {
572
590
  const fallback = await streamWithRealModelFallback(context, options, restore ?? lastRestorableModel);
573
591
  if (fallback) return fallback;
574
592
 
575
- if (!(plan && phase === "tool")) {
576
- state.plan = null;
593
+ if (!(plans.length > 0 && phase === "tool")) {
594
+ state.plans = [];
577
595
  state.phase = "tool";
578
596
  }
579
597
  const message = {
@@ -622,7 +640,7 @@ export default function (pi: ExtensionAPI) {
622
640
  let discoveredAgents: AgentConfig[] = [];
623
641
  let currentSessionId = "ephemeral";
624
642
  let currentSubagentSessionRoot = "";
625
- let pendingResumePlan: ResumableSubagentCall | null = null;
643
+ let pendingResumePlans: ResumableSubagentCall[] = [];
626
644
  let modelToRestoreAfterResume: any | undefined;
627
645
  const approvedProjectAgentDirsForSession = new Set<string>();
628
646
  const activeSubagents = new Map<number, { agent: string; task: string; handle: RunningSubagentHandle }>();
@@ -851,7 +869,7 @@ export default function (pi: ExtensionAPI) {
851
869
  return true;
852
870
  }
853
871
 
854
- function updateLatestBroadcastTargets(details: SubagentDetails | undefined): void {
872
+ function updateLatestBroadcastTargets(details: SubagentDetails | undefined, topLevelBaseId = 1): void {
855
873
  latestBroadcastTargets.all = [];
856
874
  latestBroadcastTargets.youngest = [];
857
875
  if (!details) {
@@ -863,7 +881,7 @@ export default function (pi: ExtensionAPI) {
863
881
  return;
864
882
  }
865
883
  details.results.forEach((result, index) => {
866
- collectRunningBroadcastTargetsFromResult(result, [index + 1], latestBroadcastTargets);
884
+ collectRunningBroadcastTargetsFromResult(result, [topLevelBaseId + index], latestBroadcastTargets);
867
885
  });
868
886
  const dedupe = (targets: BroadcastTarget[]) =>
869
887
  Array.from(new Map(targets.map((target) => [target.display, target])).values())
@@ -976,7 +994,7 @@ export default function (pi: ExtensionAPI) {
976
994
  async function restoreModelAfterResumeFailure(ctx?: { ui?: { notify?: (message: string, type?: "info" | "warning" | "error") => void } }) {
977
995
  const restore = modelToRestoreAfterResume;
978
996
  modelToRestoreAfterResume = undefined;
979
- pendingResumePlan = null;
997
+ pendingResumePlans = [];
980
998
  clearSyntheticResumeState();
981
999
  if (!restore) return;
982
1000
  try {
@@ -1024,15 +1042,17 @@ export default function (pi: ExtensionAPI) {
1024
1042
  const resumeDisabled = parseBooleanEnv(process.env[SUBAGENT_RESUME_DISABLE_ENV]) === true;
1025
1043
  if (resumeDisabled || (event.reason !== "resume" && event.reason !== "startup")) return;
1026
1044
 
1027
- const plan = findLatestResumableSubagentCall(ctx);
1028
- if (!plan) return;
1045
+ const plans = findLatestResumableSubagentCalls(ctx);
1046
+ if (plans.length === 0) return;
1047
+ const totalTaskCount = plans.reduce((sum, plan) => sum + plan.tasks.length, 0);
1029
1048
 
1030
1049
  let shouldResume = true;
1031
1050
  const shouldPrompt = parseBooleanEnv(process.env[SUBAGENT_RESUME_PROMPT_ENV]) !== false;
1032
- if (ctx.hasUI && shouldPrompt) {
1051
+ const rpcMode = isRpcMode(process.argv);
1052
+ if (ctx.hasUI && !rpcMode && shouldPrompt) {
1033
1053
  shouldResume = await ctx.ui.confirm(
1034
1054
  "Resume subagents?",
1035
- `The resumed session has an unfinished subagent call (${plan.tasks.length} task${plan.tasks.length === 1 ? "" : "s"}). Resume it from saved subagent sessions?`,
1055
+ `The resumed session has ${plans.length === 1 ? "an" : String(plans.length)} unfinished subagent call${plans.length === 1 ? "" : "s"} (${totalTaskCount} task${totalTaskCount === 1 ? "" : "s"}). Resume from saved subagent sessions?`,
1036
1056
  );
1037
1057
  }
1038
1058
  if (!shouldResume) {
@@ -1057,11 +1077,17 @@ export default function (pi: ExtensionAPI) {
1057
1077
  return;
1058
1078
  }
1059
1079
 
1060
- pendingResumePlan = plan;
1080
+ pendingResumePlans = [...plans];
1061
1081
  const resumeState = getSyntheticResumeState();
1062
- resumeState.plan = plan;
1082
+ resumeState.plans = [...plans];
1063
1083
  resumeState.phase = "tool";
1064
- resumeState.trigger = hasCliInitialPrompt(process.argv) ? "nextRequest" : "resumePrompt";
1084
+ // Headless subprocess/RPC subagents cannot answer a visible resume
1085
+ // prompt. They already receive an initial RPC prompt from the parent
1086
+ // runner, so inject the synthetic resume tool call into that next model
1087
+ // request. Interactive top-level sessions keep using a visible prompt so
1088
+ // the user sees exactly what is happening.
1089
+ const injectOnNextRequest = rpcMode || hasCliInitialPrompt(process.argv) || !ctx.hasUI;
1090
+ resumeState.trigger = injectOnNextRequest ? "nextRequest" : "resumePrompt";
1065
1091
  modelToRestoreAfterResume = restorableModel ?? ctx.model;
1066
1092
  resumeModelRegistry = ctx.modelRegistry;
1067
1093
  ensureSubagentToolActive(pi);
@@ -1076,15 +1102,15 @@ export default function (pi: ExtensionAPI) {
1076
1102
  // to be sent. That prompt will be answered by the synthetic provider with
1077
1103
  // a real assistant subagent tool call. In interactive mode, submit a short
1078
1104
  // visible prompt that triggers the same synthetic provider path.
1079
- if (hasCliInitialPrompt(process.argv)) {
1080
- if (ctx.hasUI) ctx.ui.notify(`Resuming ${plan.tasks.length} subagents...`, "info");
1105
+ if (injectOnNextRequest) {
1106
+ if (ctx.hasUI) ctx.ui.notify(`Resuming ${totalTaskCount} subagents...`, "info");
1081
1107
  } else {
1082
1108
  // Do not start the synthetic resume turn from session_start. Pi renders
1083
1109
  // the resumed chat only after session_start/resources_discover complete;
1084
1110
  // starting now lets that render wipe out the live tool component, so no
1085
1111
  // real-time updates appear. Queue it for resources_discover instead,
1086
1112
  // which is the last extension hook before the initial chat render.
1087
- pendingInteractiveResumePrompt = `Resuming ${plan.tasks.length} subagents...`;
1113
+ pendingInteractiveResumePrompt = `Resuming ${totalTaskCount} subagents...`;
1088
1114
  }
1089
1115
  } catch (err) {
1090
1116
  console.error("[pi-subagent] Error in session_start:", err);
@@ -1198,9 +1224,7 @@ calls one after another. Do NOT put dependent tasks in the same array.
1198
1224
 
1199
1225
  async execute(toolCallId, params, signal, onUpdate, ctx) {
1200
1226
  try {
1201
- activeSubagents.clear();
1202
1227
  updateLatestBroadcastTargets(undefined);
1203
- nextActiveSubagentId = 1;
1204
1228
  const discovery = discoverAgents(ctx.cwd, "both");
1205
1229
  const { agents } = discovery;
1206
1230
 
@@ -1223,9 +1247,11 @@ calls one after another. Do NOT put dependent tasks in the same array.
1223
1247
  }
1224
1248
 
1225
1249
  const executionMode = tasks.length === 1 ? "single" : "parallel";
1250
+ const topLevelBaseId = nextActiveSubagentId;
1251
+ nextActiveSubagentId += tasks.length;
1226
1252
  const trackedOnUpdate = (partial: any) => {
1227
1253
  if (isSubagentDetails(partial?.details)) {
1228
- updateLatestBroadcastTargets(partial.details);
1254
+ updateLatestBroadcastTargets(partial.details, topLevelBaseId);
1229
1255
  emitNestedProgressToParent(toolCallId, partial.details);
1230
1256
  }
1231
1257
  onUpdate?.(partial);
@@ -1275,7 +1301,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1275
1301
  projectAgentConfirmationSetting === "ask" &&
1276
1302
  !approvedProjectAgentDirsForSession.has(projectAgentSessionKey);
1277
1303
  if (shouldConfirmProjectAgents) {
1278
- if (ctx.hasUI) {
1304
+ if (ctx.hasUI && !isRpcMode(process.argv)) {
1279
1305
  const approval = await confirmProjectAgentsIfNeeded(
1280
1306
  requestedProjectAgents,
1281
1307
  discovery.projectAgentsDir,
@@ -1311,12 +1337,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1311
1337
  }
1312
1338
  }
1313
1339
 
1314
- const resumePlan =
1315
- pendingResumePlan && sameTasks(pendingResumePlan.tasks, tasks)
1316
- ? pendingResumePlan
1317
- : null;
1318
- if (resumePlan) {
1319
- pendingResumePlan = null;
1340
+ const resumePlanIndex = pendingResumePlans.findIndex((plan) => sameTasks(plan.tasks, tasks));
1341
+ const resumePlan = resumePlanIndex >= 0 ? pendingResumePlans[resumePlanIndex] : null;
1342
+ if (resumePlanIndex >= 0) {
1343
+ pendingResumePlans.splice(resumePlanIndex, 1);
1320
1344
  }
1321
1345
 
1322
1346
  if (tasks.length === 1) {
@@ -1337,6 +1361,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1337
1361
  // defaulting to whatever settings.json says at spawn time, which
1338
1362
  // can change while the parent session is long-running.
1339
1363
  formatModelFlag(modelToRestoreAfterResume ?? ctx.model ?? lastRestorableModel),
1364
+ topLevelBaseId,
1340
1365
  );
1341
1366
  }
1342
1367
 
@@ -1351,6 +1376,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1351
1376
  (index) => getSessionDirForTask(resumePlan?.previousToolCallId ?? toolCallId, index),
1352
1377
  !!resumePlan,
1353
1378
  formatModelFlag(modelToRestoreAfterResume ?? ctx.model ?? lastRestorableModel),
1379
+ topLevelBaseId,
1354
1380
  );
1355
1381
  } catch (err) {
1356
1382
  const msg = err instanceof Error ? err.message : String(err);
@@ -1392,7 +1418,8 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1392
1418
  previousResult: SingleResult | undefined,
1393
1419
  sessionDir: string,
1394
1420
  resumeExistingSession: boolean,
1395
- fallbackModel?: string,
1421
+ fallbackModel: string | undefined,
1422
+ topLevelBaseId: number,
1396
1423
  ) {
1397
1424
  if (previousResult && isFinishedResult(previousResult)) {
1398
1425
  return {
@@ -1425,7 +1452,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1425
1452
  initialResult: previousResult,
1426
1453
  fallbackModel,
1427
1454
  onHandle: (handle) => {
1428
- activeId = 1;
1455
+ activeId = topLevelBaseId;
1429
1456
  activeSubagents.set(activeId, { agent: agentName, task, handle });
1430
1457
  updateLatestBroadcastTargets(undefined);
1431
1458
  },
@@ -1473,7 +1500,8 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1473
1500
  resumeResults: SingleResult[] | undefined,
1474
1501
  getSessionDir: (index: number) => string,
1475
1502
  resumeExistingSessions: boolean,
1476
- fallbackModel?: string,
1503
+ fallbackModel: string | undefined,
1504
+ topLevelBaseId: number,
1477
1505
  ) {
1478
1506
  const taskIds = new Map<number, number>();
1479
1507
  try {
@@ -1494,7 +1522,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1494
1522
  currentSubagentSessionRoot,
1495
1523
  fallbackModel,
1496
1524
  (index, task, handle) => {
1497
- const id = index + 1;
1525
+ const id = topLevelBaseId + index;
1498
1526
  taskIds.set(index, id);
1499
1527
  activeSubagents.set(id, { agent: task.agent, task: task.task, handle });
1500
1528
  updateLatestBroadcastTargets(undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -13,6 +13,7 @@
13
13
  "render.ts",
14
14
  "tree.ts",
15
15
  "types.ts",
16
+ "shims.d.ts",
16
17
  "agents/*.md",
17
18
  "README.md",
18
19
  "LICENSE"
@@ -41,7 +42,8 @@
41
42
  },
42
43
  "license": "MIT",
43
44
  "scripts": {
44
- "test": "node --import tsx/esm --test tests/*.test.ts test/*.test.ts"
45
+ "test": "node --import tsx/esm --test tests/*.test.ts test/*.test.ts",
46
+ "typecheck": "tsc --noEmit"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@types/node": "^25.2.3",
@@ -52,8 +54,7 @@
52
54
  "@mariozechner/pi-agent-core": ">=0.37.0",
53
55
  "@mariozechner/pi-ai": ">=0.37.0",
54
56
  "@mariozechner/pi-coding-agent": ">=0.37.0",
55
- "@mariozechner/pi-tui": ">=0.37.0",
56
- "@sinclair/typebox": ">=0.34.0"
57
+ "@mariozechner/pi-tui": ">=0.37.0"
57
58
  },
58
59
  "peerDependenciesMeta": {
59
60
  "@mariozechner/pi-agent-core": {
@@ -67,9 +68,9 @@
67
68
  },
68
69
  "@mariozechner/pi-ai": {
69
70
  "optional": true
70
- },
71
- "@sinclair/typebox": {
72
- "optional": true
73
71
  }
72
+ },
73
+ "dependencies": {
74
+ "@sinclair/typebox": ">=0.34.0"
74
75
  }
75
76
  }
package/resume.ts CHANGED
@@ -54,9 +54,13 @@ function getSubagentToolCalls(message: any): Array<{ id: string; args: any }> {
54
54
  if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return [];
55
55
  const calls: Array<{ id: string; args: any }> = [];
56
56
  for (const part of message.content) {
57
- if (part?.type === "toolCall" && part.name === "subagent" && typeof part.id === "string") {
58
- calls.push({ id: part.id, args: part.arguments });
59
- }
57
+ if (part?.type !== "toolCall" || part.name !== "subagent") continue;
58
+ const id = typeof part.id === "string"
59
+ ? part.id
60
+ : typeof part.toolCallId === "string"
61
+ ? part.toolCallId
62
+ : undefined;
63
+ if (id) calls.push({ id, args: part.arguments });
60
64
  }
61
65
  return calls;
62
66
  }
@@ -172,7 +176,7 @@ function hasOnlyIgnorableTrailingEntries(entries: SessionEntry[], activityOrder:
172
176
  return true;
173
177
  }
174
178
 
175
- export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
179
+ export function findLatestResumableSubagentCalls(ctx: ExtensionContext): ResumableSubagentCall[] {
176
180
  const entries = branchEntries(ctx);
177
181
  const calls = new Map<string, { tasks: Array<{ agent: string; task: string }>; order: number }>();
178
182
  const results = new Map<string, { details?: SubagentDetails; isError: boolean; order: number }>();
@@ -193,7 +197,7 @@ export function findLatestResumableSubagentCall(ctx: ExtensionContext): Resumabl
193
197
  }
194
198
  });
195
199
 
196
- const candidates: Array<ResumableSubagentCall & { activityOrder: number }> = [];
200
+ const candidates: Array<ResumableSubagentCall & { callOrder: number; activityOrder: number }> = [];
197
201
  for (const [toolCallId, call] of calls) {
198
202
  const result = results.get(toolCallId);
199
203
  const unfinished = !result || result.isError || hasUnfinishedResults(result.details, call.tasks.length);
@@ -202,13 +206,41 @@ export function findLatestResumableSubagentCall(ctx: ExtensionContext): Resumabl
202
206
  previousToolCallId: toolCallId,
203
207
  tasks: call.tasks,
204
208
  details: result?.details,
209
+ callOrder: call.order,
205
210
  activityOrder: result?.order ?? call.order,
206
211
  });
207
212
  }
208
213
 
209
214
  const latest = candidates.sort((a, b) => a.activityOrder - b.activityOrder).at(-1);
210
- if (!latest || !hasOnlyIgnorableTrailingEntries(entries, latest.activityOrder)) return null;
211
- return latest;
215
+ if (!latest) return [];
216
+
217
+ // If one assistant message issued several subagent tool calls, Pi records
218
+ // separate tool results for them. Resuming only the last one leaves sibling
219
+ // subagents permanently abandoned. Resume every unfinished call from that
220
+ // same assistant message as one synthetic assistant turn.
221
+ const batch = candidates
222
+ .filter((candidate) => candidate.callOrder === latest.callOrder)
223
+ .sort((a, b) => a.activityOrder - b.activityOrder);
224
+ const siblingResultOrders = Array.from(calls.entries())
225
+ .filter(([, call]) => call.order === latest.callOrder)
226
+ .map(([toolCallId]) => results.get(toolCallId)?.order)
227
+ .filter((order): order is number => typeof order === "number");
228
+ const latestBatchActivity = Math.max(
229
+ latest.callOrder,
230
+ ...batch.map((candidate) => candidate.activityOrder),
231
+ ...siblingResultOrders,
232
+ );
233
+ if (!hasOnlyIgnorableTrailingEntries(entries, latestBatchActivity)) return [];
234
+
235
+ return batch.map(({ previousToolCallId, tasks, details }) => ({
236
+ previousToolCallId,
237
+ tasks,
238
+ details,
239
+ }));
240
+ }
241
+
242
+ export function findLatestResumableSubagentCall(ctx: ExtensionContext): ResumableSubagentCall | null {
243
+ return findLatestResumableSubagentCalls(ctx).at(-1) ?? null;
212
244
  }
213
245
 
214
246
  export function sameTasks(
package/runner.ts CHANGED
@@ -62,6 +62,7 @@ function endedWithSyntheticResumeFailure(messages: Message[]): boolean {
62
62
  const hasToolCall = content.some((part: any) => part?.type === "toolCall");
63
63
  return !handedOffToRealModel && !hasToolCall;
64
64
  }
65
+
65
66
  const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
66
67
  const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
67
68
  const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
@@ -620,6 +621,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
620
621
 
621
622
  const shouldContinueSession = resumeSession && (!sessionDir || sessionDirExists(sessionDir));
622
623
 
624
+ const initialMessageCount = initialResult?.messages?.length ?? 0;
625
+
623
626
  const result: SingleResult = {
624
627
  agent: agentName,
625
628
  agentSource: agent.source,
@@ -704,6 +707,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
704
707
  let hangTimer: ReturnType<typeof setTimeout> | undefined;
705
708
  let startupTimer: ReturnType<typeof setTimeout> | undefined;
706
709
  let receivedFirstEvent = false;
710
+ let forcedExitCode: number | undefined;
707
711
 
708
712
  const sendRpc = (command: Record<string, unknown>) => {
709
713
  proc.stdin?.write(`${JSON.stringify(command)}\n`);
@@ -774,9 +778,9 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
774
778
  let event: any;
775
779
  try { event = JSON.parse(line); } catch { event = null; }
776
780
  if (event?.type === "agent_end") {
777
- if (result.exitCode === -1) result.exitCode = 0;
781
+ if (result.exitCode === -1) result.exitCode = forcedExitCode ?? 0;
778
782
  try { proc.kill("SIGTERM"); } catch { /* already dead */ }
779
- doResolve(0);
783
+ doResolve(forcedExitCode ?? 0);
780
784
  return;
781
785
  }
782
786
  const accepted = processJsonLine(line, result);
@@ -818,7 +822,11 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
818
822
  if (startupTimeoutMs > 0) {
819
823
  startupTimer = setTimeout(() => {
820
824
  if (resolved || receivedFirstEvent) return;
821
- result.stderr += `\n[pi-subagent] Killed: no JSON output after ${startupTimeoutMs}ms (startup timeout).`;
825
+ const message = `Subagent startup timeout: no JSON output after ${startupTimeoutMs}ms.`;
826
+ forcedExitCode = 1;
827
+ result.stopReason = "error";
828
+ result.errorMessage = message;
829
+ result.stderr += `\n[pi-subagent] Killed: ${message}`;
822
830
  try { proc.kill("SIGTERM"); } catch { /* already dead */ }
823
831
  setTimeout(() => {
824
832
  if (!resolved) {
@@ -840,13 +848,13 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
840
848
  });
841
849
 
842
850
  proc.on("close", (code) => {
843
- doResolve(code ?? 0);
851
+ doResolve(forcedExitCode ?? code ?? 0);
844
852
  });
845
853
 
846
854
  proc.on("exit", (code) => {
847
855
  // If the process exits, resolve as soon as possible.
848
856
  // Give a tiny grace period for any remaining buffered stdout data.
849
- setTimeout(() => doResolve(code ?? 0), 100);
857
+ setTimeout(() => doResolve(forcedExitCode ?? code ?? 0), 100);
850
858
  });
851
859
 
852
860
  proc.on("error", (err) => {
@@ -879,6 +887,13 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
879
887
  if (!result.stderr.trim()) result.stderr = "Subagent was aborted.";
880
888
  }
881
889
 
890
+ if (result.exitCode === 0 && shouldContinueSession && result.messages.length <= initialMessageCount) {
891
+ result.exitCode = 1;
892
+ result.stopReason = "error";
893
+ result.errorMessage = "Subagent resume made no progress: resumed subprocess exited without producing any new messages.";
894
+ if (!result.stderr.trim()) result.stderr = result.errorMessage;
895
+ }
896
+
882
897
  if (result.exitCode === 0 && endedWithSyntheticResumeFailure(result.messages)) {
883
898
  result.exitCode = 1;
884
899
  result.stopReason = "error";
package/shims.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ declare module "@mariozechner/pi-ai" {
2
+ export interface ModelUsage {
3
+ input?: number;
4
+ output?: number;
5
+ cacheRead?: number;
6
+ cacheWrite?: number;
7
+ totalTokens?: number;
8
+ cost?: number;
9
+ contextTokens?: number;
10
+ }
11
+
12
+ export type Message = any;
13
+
14
+ export function createAssistantMessageEventStream(): any;
15
+ export function streamSimple(model: any, context: any, options: any): any;
16
+ }
17
+
18
+ declare module "@mariozechner/pi-agent-core" {
19
+ export interface AgentToolResult<TDetails = unknown> {
20
+ content: Array<{ type: string; text?: string }>;
21
+ details?: TDetails;
22
+ isError?: boolean;
23
+ }
24
+ }
25
+
26
+ declare module "@mariozechner/pi-coding-agent" {
27
+ export interface ExtensionContext {
28
+ cwd: string;
29
+ model?: any;
30
+ hasUI?: boolean;
31
+ ui?: any;
32
+ sessionManager: {
33
+ getEntries: () => any[];
34
+ getSessionDir: () => string | undefined;
35
+ getLeafId: () => string | undefined;
36
+ getBranch: (leafId: string) => any[] | undefined;
37
+ };
38
+ }
39
+
40
+ export interface ExtensionAPI {
41
+ registerFlag(name: string, config: any): void;
42
+ getFlag(name: string): unknown;
43
+ registerProvider(name: string, provider: any): void;
44
+ registerTool(tool: any): void;
45
+ addBeforeAgentStart(hook: (ctx: ExtensionContext) => unknown): void;
46
+ addBeforeRequest(hook: (event: any, ctx: any) => unknown): void;
47
+ addAfterMessage(hook: (event: any, ctx: any) => unknown): void;
48
+ setModel(model: any): Promise<boolean> | boolean;
49
+ [key: string]: any;
50
+ }
51
+
52
+ export function getAgentDir(): string;
53
+ export function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string };
54
+ }
55
+
56
+ declare module "@mariozechner/pi-tui" {
57
+ export class Text {
58
+ constructor(text: string, x?: number, y?: number);
59
+ }
60
+ export class Spacer {
61
+ constructor(size?: number);
62
+ }
63
+ export class Container {
64
+ addChild(child: unknown): void;
65
+ }
66
+ }
67
+
68
+ declare module "express" {
69
+ export interface Request {
70
+ method: string;
71
+ path: string;
72
+ }
73
+ export interface Response {
74
+ statusCode: number;
75
+ on(event: "finish", listener: () => void): void;
76
+ }
77
+ export type NextFunction = () => void;
78
+ }
package/types.ts CHANGED
@@ -242,7 +242,8 @@ export function getFinalOutput(messages: Message[]): string {
242
242
  for (let i = messages.length - 1; i >= 0; i--) {
243
243
  const msg = messages[i];
244
244
  if (msg.role === "assistant") {
245
- for (const part of msg.content) {
245
+ for (let j = msg.content.length - 1; j >= 0; j--) {
246
+ const part = msg.content[j];
246
247
  if (part.type === "text") return part.text;
247
248
  }
248
249
  }