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.
- package/README.md +2 -0
- package/index.ts +148 -73
- package/package.json +1 -1
- 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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
|
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
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
|
-
|
|
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
|
|