oira666_pi-subagent 0.2.30 → 0.2.31

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.
Files changed (4) hide show
  1. package/README.md +2 -0
  2. package/index.ts +148 -73
  3. package/package.json +1 -1
  4. package/runner.ts +8 -1
package/README.md CHANGED
@@ -128,6 +128,8 @@ While a `subagent` tool call is running, mid-stream steering input can be broadc
128
128
 
129
129
  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.
130
130
 
131
+ The same detection also runs after navigating the session tree in the TUI (Esc navigation): if you jump back to a point whose branch ends in an unfinished `subagent` call, the extension offers to resume those subagents from their saved sessions.
132
+
131
133
  - TUI mode asks: **Resume subagents?**
132
134
  - Non-UI modes (`pi -p`, JSON/RPC) resume automatically.
133
135
  - Already-finished subagents are reused as completed; unfinished ones continue from their own saved sessions.
package/index.ts CHANGED
@@ -743,7 +743,7 @@ export default function (pi: ExtensionAPI) {
743
743
  let pendingResumePlans: ResumableSubagentCall[] = [];
744
744
  let modelToRestoreAfterResume: any | undefined;
745
745
  const approvedProjectAgentDirsForSession = new Set<string>();
746
- const activeSubagents = new Map<number, { agent: string; task: string; handle: RunningSubagentHandle }>();
746
+ const activeSubagents = new Map<number, { agent: string; task: string; taskIndex: number; handle: RunningSubagentHandle }>();
747
747
  const activeSubagentUsageSummaries = new Map<string, SubagentUsageSummary>();
748
748
  const latestBroadcastTargets = {
749
749
  all: [] as BroadcastTarget[],
@@ -1026,11 +1026,21 @@ export default function (pi: ExtensionAPI) {
1026
1026
  async function askBroadcastForSteering(message: string, ctx: any): Promise<"continue" | "handled"> {
1027
1027
  const nested = decodeNestedBroadcast(message);
1028
1028
  if (nested) {
1029
- const available = Array.from(activeSubagents.keys()).sort((a, b) => a - b);
1030
- if (available.length === 0) return "handled";
1031
- const [nextId, ...restPath] = nested.path;
1032
- if (!available.includes(nextId)) return "handled";
1033
- sendBroadcastToTargets(nested.message, [{ display: nested.path.join("."), topLevelId: nextId, restPath }], ctx);
1029
+ if (activeSubagents.size === 0) return "handled";
1030
+ const [rawTarget, ...restPath] = nested.path;
1031
+ // Path components below the top level are 1-based task indexes within the
1032
+ // subagent tool call running in THIS process. Internal active ids keep
1033
+ // growing across sequential tool calls (and process restarts), so resolve
1034
+ // by task index first; fall back to a direct id match for compatibility.
1035
+ let resolvedId: number | undefined;
1036
+ for (const [id, item] of activeSubagents) {
1037
+ if (item.taskIndex === rawTarget - 1 && (resolvedId === undefined || id > resolvedId)) {
1038
+ resolvedId = id;
1039
+ }
1040
+ }
1041
+ if (resolvedId === undefined && activeSubagents.has(rawTarget)) resolvedId = rawTarget;
1042
+ if (resolvedId === undefined) return "handled";
1043
+ sendBroadcastToTargets(nested.message, [{ display: nested.path.join("."), topLevelId: resolvedId, restPath }], ctx);
1034
1044
  return "handled";
1035
1045
  }
1036
1046
 
@@ -1171,78 +1181,136 @@ export default function (pi: ExtensionAPI) {
1171
1181
  const resumeDisabled = parseBooleanEnv(process.env[SUBAGENT_RESUME_DISABLE_ENV]) === true;
1172
1182
  if (resumeDisabled || (event.reason !== "resume" && event.reason !== "startup")) return;
1173
1183
 
1174
- const plans = findLatestResumableSubagentCalls(ctx);
1175
- if (plans.length === 0) return;
1176
- const totalTaskCount = plans.reduce((sum, plan) => sum + plan.tasks.length, 0);
1184
+ await maybeOfferSubagentResume(ctx, { deferInteractivePrompt: true });
1185
+ } catch (err) {
1186
+ console.error("[pi-subagent] Error in session_start:", err);
1187
+ await restoreModelAfterResumeFailure(ctx);
1188
+ }
1189
+ });
1177
1190
 
1178
- let shouldResume = true;
1179
- const shouldPrompt = parseBooleanEnv(process.env[SUBAGENT_RESUME_PROMPT_ENV]) !== false;
1180
- const rpcMode = isRpcMode(process.argv);
1181
- if (ctx.hasUI && !rpcMode && shouldPrompt) {
1182
- shouldResume = await ctx.ui.confirm(
1183
- "Resume subagents?",
1184
- `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?`,
1185
- );
1186
- }
1187
- if (!shouldResume) {
1188
- if (ctx.model?.provider === RESUME_PROVIDER) {
1189
- if (restorableModel) {
1190
- await pi.setModel(restorableModel);
1191
- } else {
1192
- ctx.ui.notify(
1193
- `Subagent resume was declined, but the current model is the synthetic resume model and no real fallback model is available. Select a real model before continuing, or set ${SUBAGENT_FALLBACK_MODEL_ENV}=provider/model.`,
1194
- "error",
1195
- );
1196
- }
1191
+ /**
1192
+ * Detect unfinished subagent calls at the current branch leaf and offer to
1193
+ * resume them. Shared between session_start (startup/resume) and
1194
+ * session_tree (TUI tree navigation back to a subagent point).
1195
+ *
1196
+ * When deferInteractivePrompt is true, the interactive resume prompt is
1197
+ * queued for resources_discover (only valid during session_start, before the
1198
+ * initial chat render). Otherwise the prompt is sent directly after a short
1199
+ * delay.
1200
+ */
1201
+ async function maybeOfferSubagentResume(
1202
+ ctx: any,
1203
+ opts: { deferInteractivePrompt: boolean },
1204
+ ): Promise<void> {
1205
+ const restorableModel = getRestorableModel(ctx);
1206
+ if (restorableModel) {
1207
+ lastRestorableModel = restorableModel;
1208
+ resumeModelRegistry = ctx.modelRegistry;
1209
+ }
1210
+
1211
+ const plans = findLatestResumableSubagentCalls(ctx);
1212
+ if (plans.length === 0) return;
1213
+ const totalTaskCount = plans.reduce((sum, plan) => sum + plan.tasks.length, 0);
1214
+
1215
+ let shouldResume = true;
1216
+ const shouldPrompt = parseBooleanEnv(process.env[SUBAGENT_RESUME_PROMPT_ENV]) !== false;
1217
+ const rpcMode = isRpcMode(process.argv);
1218
+ if (ctx.hasUI && !rpcMode && shouldPrompt) {
1219
+ shouldResume = await ctx.ui.confirm(
1220
+ "Resume subagents?",
1221
+ `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?`,
1222
+ );
1223
+ }
1224
+ if (!shouldResume) {
1225
+ if (ctx.model?.provider === RESUME_PROVIDER) {
1226
+ if (restorableModel) {
1227
+ await pi.setModel(restorableModel);
1228
+ } else {
1229
+ ctx.ui.notify(
1230
+ `Subagent resume was declined, but the current model is the synthetic resume model and no real fallback model is available. Select a real model before continuing, or set ${SUBAGENT_FALLBACK_MODEL_ENV}=provider/model.`,
1231
+ "error",
1232
+ );
1197
1233
  }
1198
- return;
1199
1234
  }
1235
+ return;
1236
+ }
1200
1237
 
1201
- if (!restorableModel && ctx.model?.provider === RESUME_PROVIDER) {
1202
- ctx.ui.notify(
1203
- `Cannot resume subagents while on the synthetic resume model because no real fallback model is available. Select a real model or set ${SUBAGENT_FALLBACK_MODEL_ENV}=provider/model.`,
1204
- "error",
1205
- );
1206
- return;
1207
- }
1238
+ if (!restorableModel && ctx.model?.provider === RESUME_PROVIDER) {
1239
+ ctx.ui.notify(
1240
+ `Cannot resume subagents while on the synthetic resume model because no real fallback model is available. Select a real model or set ${SUBAGENT_FALLBACK_MODEL_ENV}=provider/model.`,
1241
+ "error",
1242
+ );
1243
+ return;
1244
+ }
1208
1245
 
1209
- pendingResumePlans = [...plans];
1210
- const resumeState = getSyntheticResumeState();
1211
- resumeState.plans = [...plans];
1212
- resumeState.phase = "tool";
1213
- // Headless subprocess/RPC subagents cannot answer a visible resume
1214
- // prompt. They already receive an initial RPC prompt from the parent
1215
- // runner, so inject the synthetic resume tool call into that next model
1216
- // request. Interactive top-level sessions keep using a visible prompt so
1217
- // the user sees exactly what is happening.
1218
- const injectOnNextRequest = rpcMode || hasCliInitialPrompt(process.argv) || !ctx.hasUI;
1219
- resumeState.trigger = injectOnNextRequest ? "nextRequest" : "resumePrompt";
1220
- modelToRestoreAfterResume = restorableModel ?? ctx.model;
1221
- resumeModelRegistry = ctx.modelRegistry;
1222
- ensureSubagentToolActive(pi);
1223
- const resumeModel = ctx.modelRegistry.find(RESUME_PROVIDER, RESUME_MODEL_ID);
1224
- if (!resumeModel || !(await pi.setModel(resumeModel))) {
1225
- ctx.ui.notify("Failed to switch to synthetic subagent resume model.", "error");
1226
- await restoreModelAfterResumeFailure(ctx);
1227
- return;
1228
- }
1246
+ pendingResumePlans = [...plans];
1247
+ const resumeState = getSyntheticResumeState();
1248
+ resumeState.plans = [...plans];
1249
+ resumeState.phase = "tool";
1250
+ // Headless subprocess/RPC subagents cannot answer a visible resume
1251
+ // prompt. They already receive an initial RPC prompt from the parent
1252
+ // runner, so inject the synthetic resume tool call into that next model
1253
+ // request. Interactive top-level sessions keep using a visible prompt so
1254
+ // the user sees exactly what is happening.
1255
+ const injectOnNextRequest = rpcMode || hasCliInitialPrompt(process.argv) || !ctx.hasUI;
1256
+ resumeState.trigger = injectOnNextRequest ? "nextRequest" : "resumePrompt";
1257
+ modelToRestoreAfterResume = restorableModel ?? ctx.model;
1258
+ resumeModelRegistry = ctx.modelRegistry;
1259
+ ensureSubagentToolActive(pi);
1260
+ const resumeModel = ctx.modelRegistry.find(RESUME_PROVIDER, RESUME_MODEL_ID);
1261
+ if (!resumeModel || !(await pi.setModel(resumeModel))) {
1262
+ ctx.ui.notify("Failed to switch to synthetic subagent resume model.", "error");
1263
+ await restoreModelAfterResumeFailure(ctx);
1264
+ return;
1265
+ }
1229
1266
 
1230
- // In print/json subprocesses there is already an initial CLI prompt about
1231
- // to be sent. That prompt will be answered by the synthetic provider with
1232
- // a real assistant subagent tool call. In interactive mode, submit a short
1233
- // visible prompt that triggers the same synthetic provider path.
1234
- if (injectOnNextRequest) {
1235
- if (ctx.hasUI) ctx.ui.notify(`Resuming ${totalTaskCount} subagents...`, "info");
1236
- } else {
1237
- // Do not start the synthetic resume turn from session_start. Pi renders
1238
- // the resumed chat only after session_start/resources_discover complete;
1239
- // starting now lets that render wipe out the live tool component, so no
1240
- // real-time updates appear. Queue it for resources_discover instead,
1241
- // which is the last extension hook before the initial chat render.
1242
- pendingInteractiveResumePrompt = `Resuming ${totalTaskCount} subagents...`;
1243
- }
1267
+ // In print/json subprocesses there is already an initial CLI prompt about
1268
+ // to be sent. That prompt will be answered by the synthetic provider with
1269
+ // a real assistant subagent tool call. In interactive mode, submit a short
1270
+ // visible prompt that triggers the same synthetic provider path.
1271
+ if (injectOnNextRequest) {
1272
+ if (ctx.hasUI) ctx.ui.notify(`Resuming ${totalTaskCount} subagents...`, "info");
1273
+ } else if (opts.deferInteractivePrompt) {
1274
+ // Do not start the synthetic resume turn from session_start. Pi renders
1275
+ // the resumed chat only after session_start/resources_discover complete;
1276
+ // starting now lets that render wipe out the live tool component, so no
1277
+ // real-time updates appear. Queue it for resources_discover instead,
1278
+ // which is the last extension hook before the initial chat render.
1279
+ pendingInteractiveResumePrompt = `Resuming ${totalTaskCount} subagents...`;
1280
+ } else {
1281
+ setTimeout(() => {
1282
+ try {
1283
+ pi.sendUserMessage(`Resuming ${totalTaskCount} subagents...`);
1284
+ } catch (err) {
1285
+ console.error("[pi-subagent] Failed to start resume turn after tree navigation:", err);
1286
+ void restoreModelAfterResumeFailure(ctx);
1287
+ }
1288
+ }, RESUME_INTERACTIVE_DELAY_MS);
1289
+ }
1290
+ }
1291
+
1292
+ // Offer to resume subagents after the user navigates the session tree (Esc
1293
+ // navigation in the TUI) back to a point with an unfinished subagent call.
1294
+ pi.on("session_tree", async (event: any, ctx) => {
1295
+ latestSessionCtx = ctx;
1296
+ updateCombinedUsageStatus(ctx);
1297
+ try {
1298
+ if (!canDelegate) return;
1299
+ // Skip extension-driven navigation (e.g. compaction) and any state where
1300
+ // a resume is already pending or subagents are still running.
1301
+ if (event?.fromExtension) return;
1302
+ if (activeSubagents.size > 0) return;
1303
+ if (pendingResumePlans.length > 0) return;
1304
+ if (pendingInteractiveResumePrompt) return;
1305
+ if (ctx.model?.provider === RESUME_PROVIDER) return;
1306
+ if (typeof ctx.isIdle === "function" && !ctx.isIdle()) return;
1307
+
1308
+ const resumeDisabled = parseBooleanEnv(process.env[SUBAGENT_RESUME_DISABLE_ENV]) === true;
1309
+ if (resumeDisabled) return;
1310
+
1311
+ await maybeOfferSubagentResume(ctx, { deferInteractivePrompt: false });
1244
1312
  } catch (err) {
1245
- console.error("[pi-subagent] Error in session_start:", err);
1313
+ console.error("[pi-subagent] Error in session_tree:", err);
1246
1314
  await restoreModelAfterResumeFailure(ctx);
1247
1315
  }
1248
1316
  });
@@ -1283,6 +1351,13 @@ export default function (pi: ExtensionAPI) {
1283
1351
 
1284
1352
  pi.on("input", async (event, ctx) => {
1285
1353
  try {
1354
+ // Encoded nested-broadcast envelopes must never leak into this agent's
1355
+ // conversation as literal text. Intercept them unconditionally: if the
1356
+ // target subagent is gone, the message is dropped (best effort).
1357
+ if (decodeNestedBroadcast(event.text)) {
1358
+ await askBroadcastForSteering(event.text, ctx);
1359
+ return { action: "handled" as const };
1360
+ }
1286
1361
  // Pi emits this before it applies the built-in streaming behavior. When a
1287
1362
  // subagent tool is running, only mid-stream steering messages should be
1288
1363
  // candidates for child broadcast. Idle prompts and queued follow-ups must
@@ -1600,7 +1675,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1600
1675
  fallbackModel,
1601
1676
  onHandle: (handle) => {
1602
1677
  activeId = topLevelBaseId;
1603
- activeSubagents.set(activeId, { agent: agentName, task, handle });
1678
+ activeSubagents.set(activeId, { agent: agentName, task, taskIndex: 0, handle });
1604
1679
  updateLatestBroadcastTargets(undefined);
1605
1680
  },
1606
1681
  });
@@ -1671,7 +1746,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
1671
1746
  (index, task, handle) => {
1672
1747
  const id = topLevelBaseId + index;
1673
1748
  taskIds.set(index, id);
1674
- activeSubagents.set(id, { agent: task.agent, task: task.task, handle });
1749
+ activeSubagents.set(id, { agent: task.agent, task: task.task, taskIndex: index, handle });
1675
1750
  updateLatestBroadcastTargets(undefined);
1676
1751
  },
1677
1752
  (index) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/runner.ts CHANGED
@@ -782,7 +782,14 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
782
782
 
783
783
  opts.onHandle?.({
784
784
  steer(message: string) {
785
- sendRpc({ type: "steer", message });
785
+ // Use "prompt" with streamingBehavior "steer" instead of the raw
786
+ // "steer" RPC command. Pi's `session.steer()` bypasses the `input`
787
+ // extension hook entirely, so the child's pi-subagent extension would
788
+ // never see encoded nested-broadcast messages and could not forward
789
+ // them to its own (grand)children. `session.prompt()` emits the
790
+ // `input` event first and still queues the message as a steering
791
+ // message while the child is streaming.
792
+ sendRpc({ type: "prompt", message, streamingBehavior: "steer" });
786
793
  },
787
794
  });
788
795