@ryan_nookpi/pi-extension-subagent 0.2.1 → 0.3.1
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/commands.ts +113 -114
- package/escalation.ts +60 -59
- package/index.ts +126 -167
- package/lifecycle.ts +161 -0
- package/package.json +3 -1
- package/registration-manifest.ts +23 -0
package/commands.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
} from "./group-pending.js";
|
|
43
43
|
import { enqueueSubagentInvocation } from "./invocation-queue.js";
|
|
44
44
|
import { appendDisplayTaskUpdate, getSessionFileSize } from "./persisted-session.js";
|
|
45
|
+
import { SUBAGENT_COMMANDS, type SubagentCommandName } from "./registration-manifest.js";
|
|
45
46
|
import { readSessionReplayItems, SubagentSessionReplayOverlay } from "./replay.js";
|
|
46
47
|
import { invokeWithAutoRetry, MAX_SUBAGENT_AUTO_RETRIES } from "./retry.js";
|
|
47
48
|
import { getLatestRun, removeRun, trimCommandRunHistory } from "./run-utils.js";
|
|
@@ -890,7 +891,18 @@ function finalizeHumanOnlyCompletion(
|
|
|
890
891
|
store.globalLiveRuns.delete(runId);
|
|
891
892
|
}
|
|
892
893
|
|
|
893
|
-
export
|
|
894
|
+
export type SubagentCommandDefinition = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
895
|
+
|
|
896
|
+
export interface SubagentRegistrations {
|
|
897
|
+
commands: ReadonlyMap<SubagentCommandName, SubagentCommandDefinition>;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentRegistrations {
|
|
901
|
+
const commandDefinitions = new Map<SubagentCommandName, SubagentCommandDefinition>();
|
|
902
|
+
const defineCommand = (name: SubagentCommandName, definition: SubagentCommandDefinition): void => {
|
|
903
|
+
commandDefinitions.set(name, definition);
|
|
904
|
+
};
|
|
905
|
+
|
|
894
906
|
pi.registerTool({
|
|
895
907
|
name: "list-agents",
|
|
896
908
|
label: "List Agents",
|
|
@@ -958,8 +970,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
958
970
|
});
|
|
959
971
|
|
|
960
972
|
const subCommand = {
|
|
961
|
-
description:
|
|
962
|
-
"Run a subagent in a dedicated sub-session: /sub:isolate <agent|alias> <task>, /sub:isolate <runId> <task>, /sub:isolate <task> (uses configured defaultAgent)",
|
|
973
|
+
description: SUBAGENT_COMMANDS["sub:isolate"],
|
|
963
974
|
getArgumentCompletions: (argumentPrefix: string) => {
|
|
964
975
|
const trimmedStart = argumentPrefix.trimStart();
|
|
965
976
|
if (trimmedStart.includes(" ")) return null;
|
|
@@ -1574,10 +1585,10 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1574
1585
|
},
|
|
1575
1586
|
};
|
|
1576
1587
|
|
|
1577
|
-
|
|
1588
|
+
defineCommand("sub:isolate", subCommand);
|
|
1578
1589
|
|
|
1579
|
-
|
|
1580
|
-
description: "
|
|
1590
|
+
defineCommand("sub:main", {
|
|
1591
|
+
description: SUBAGENT_COMMANDS["sub:main"],
|
|
1581
1592
|
getArgumentCompletions: subCommand.getArgumentCompletions,
|
|
1582
1593
|
handler: async (args, ctx) => {
|
|
1583
1594
|
captureSwitchSession(store, ctx);
|
|
@@ -1586,8 +1597,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1586
1597
|
},
|
|
1587
1598
|
});
|
|
1588
1599
|
|
|
1589
|
-
|
|
1590
|
-
description:
|
|
1600
|
+
defineCommand("subagents", {
|
|
1601
|
+
description: SUBAGENT_COMMANDS.subagents,
|
|
1591
1602
|
handler: async (_args, ctx) => {
|
|
1592
1603
|
captureSwitchSession(store, ctx);
|
|
1593
1604
|
const starterPack = await offerStarterPackIfEmpty(ctx);
|
|
@@ -1619,8 +1630,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1619
1630
|
},
|
|
1620
1631
|
});
|
|
1621
1632
|
|
|
1622
|
-
|
|
1623
|
-
description: "
|
|
1633
|
+
defineCommand("sub:peek", {
|
|
1634
|
+
description: SUBAGENT_COMMANDS["sub:peek"],
|
|
1624
1635
|
getArgumentCompletions: (argumentPrefix) => {
|
|
1625
1636
|
const trimmedStart = argumentPrefix.trimStart();
|
|
1626
1637
|
if (trimmedStart.includes(" ")) return null;
|
|
@@ -1643,8 +1654,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1643
1654
|
},
|
|
1644
1655
|
});
|
|
1645
1656
|
|
|
1646
|
-
|
|
1647
|
-
description: "
|
|
1657
|
+
defineCommand("sub:open", {
|
|
1658
|
+
description: SUBAGENT_COMMANDS["sub:open"],
|
|
1648
1659
|
getArgumentCompletions: (argumentPrefix) => {
|
|
1649
1660
|
const trimmedStart = argumentPrefix.trimStart();
|
|
1650
1661
|
if (trimmedStart.includes(" ")) return null;
|
|
@@ -1735,8 +1746,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1735
1746
|
},
|
|
1736
1747
|
});
|
|
1737
1748
|
|
|
1738
|
-
|
|
1739
|
-
description: "
|
|
1749
|
+
defineCommand("sub:history", {
|
|
1750
|
+
description: SUBAGENT_COMMANDS["sub:history"],
|
|
1740
1751
|
handler: async (_args, ctx) => {
|
|
1741
1752
|
captureSwitchSession(store, ctx);
|
|
1742
1753
|
|
|
@@ -1787,8 +1798,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1787
1798
|
},
|
|
1788
1799
|
});
|
|
1789
1800
|
|
|
1790
|
-
|
|
1791
|
-
description: "
|
|
1801
|
+
defineCommand("sub:rm", {
|
|
1802
|
+
description: SUBAGENT_COMMANDS["sub:rm"],
|
|
1792
1803
|
handler: async (args, ctx) => {
|
|
1793
1804
|
captureSwitchSession(store, ctx);
|
|
1794
1805
|
const raw = (args ?? "").trim();
|
|
@@ -1871,8 +1882,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1871
1882
|
ctx.ui.notify(`Cleared ${removed} finished subagent job(s).`, "info");
|
|
1872
1883
|
};
|
|
1873
1884
|
|
|
1874
|
-
|
|
1875
|
-
description: "
|
|
1885
|
+
defineCommand("sub:clear", {
|
|
1886
|
+
description: SUBAGENT_COMMANDS["sub:clear"],
|
|
1876
1887
|
handler: async (args, ctx) => {
|
|
1877
1888
|
await handleSubClear(args, ctx);
|
|
1878
1889
|
},
|
|
@@ -1947,23 +1958,16 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
1947
1958
|
ctx.ui.notify("Usage: /sub:abort [runId|all]", "info");
|
|
1948
1959
|
};
|
|
1949
1960
|
|
|
1950
|
-
|
|
1951
|
-
description: "
|
|
1961
|
+
defineCommand("sub:abort", {
|
|
1962
|
+
description: SUBAGENT_COMMANDS["sub:abort"],
|
|
1952
1963
|
handler: async (args, ctx) => {
|
|
1953
1964
|
captureSwitchSession(store, ctx);
|
|
1954
1965
|
await handleSubAbort(args, ctx);
|
|
1955
1966
|
},
|
|
1956
1967
|
});
|
|
1957
1968
|
|
|
1958
|
-
//
|
|
1959
|
-
//
|
|
1960
|
-
// so the hidden subagent prefix is documented via footer/status hints instead.
|
|
1961
|
-
pi.registerShortcut(">>" as any, {
|
|
1962
|
-
description: "Run subagent task",
|
|
1963
|
-
handler: async () => {
|
|
1964
|
-
// Documentation-only entry.
|
|
1965
|
-
},
|
|
1966
|
-
});
|
|
1969
|
+
// Text-prefix shortcuts are registered synchronously in index.ts for
|
|
1970
|
+
// /hotkeys visibility. Their actual routing remains in the input handlers below.
|
|
1967
1971
|
|
|
1968
1972
|
pi.on("input", async (event, ctx) => {
|
|
1969
1973
|
if (event.source === "extension") {
|
|
@@ -2073,13 +2077,6 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
2073
2077
|
});
|
|
2074
2078
|
|
|
2075
2079
|
// #<runId> shortcut: resume a subagent run (e.g. #42 keep going)
|
|
2076
|
-
pi.registerShortcut("#<runId>" as any, {
|
|
2077
|
-
description: "Resume subagent run: #<runId> <task>",
|
|
2078
|
-
handler: async () => {
|
|
2079
|
-
// Documentation-only entry.
|
|
2080
|
-
},
|
|
2081
|
-
});
|
|
2082
|
-
|
|
2083
2080
|
pi.on("input", async (event, ctx) => {
|
|
2084
2081
|
if (event.source === "extension") {
|
|
2085
2082
|
return { action: "continue" as const };
|
|
@@ -2126,20 +2123,6 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
2126
2123
|
});
|
|
2127
2124
|
|
|
2128
2125
|
// << shortcut: abort running jobs or clear finished jobs
|
|
2129
|
-
pi.registerShortcut("<<" as any, {
|
|
2130
|
-
description: "Abort or clear subagent runs",
|
|
2131
|
-
handler: async () => {
|
|
2132
|
-
// Documentation-only entry.
|
|
2133
|
-
},
|
|
2134
|
-
});
|
|
2135
|
-
|
|
2136
|
-
pi.registerShortcut("<<<" as any, {
|
|
2137
|
-
description: "Clear finished subagent jobs (= /sub:clear). <<< all to clear all",
|
|
2138
|
-
handler: async () => {
|
|
2139
|
-
// Documentation-only entry.
|
|
2140
|
-
},
|
|
2141
|
-
});
|
|
2142
|
-
|
|
2143
2126
|
pi.on("input", async (event, ctx) => {
|
|
2144
2127
|
if (event.source === "extension") {
|
|
2145
2128
|
return { action: "continue" as const };
|
|
@@ -2227,82 +2210,98 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
2227
2210
|
return { action: "handled" as const };
|
|
2228
2211
|
});
|
|
2229
2212
|
|
|
2230
|
-
|
|
2231
|
-
|
|
2213
|
+
const missingCommands = (Object.keys(SUBAGENT_COMMANDS) as SubagentCommandName[]).filter(
|
|
2214
|
+
(name) => !commandDefinitions.has(name),
|
|
2215
|
+
);
|
|
2216
|
+
if (missingCommands.length > 0) {
|
|
2217
|
+
throw new Error(`Missing subagent command definitions: ${missingCommands.join(", ")}`);
|
|
2218
|
+
}
|
|
2232
2219
|
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
unsubTerminalInput?.();
|
|
2236
|
-
unsubTerminalInput = null;
|
|
2220
|
+
return { commands: commandDefinitions };
|
|
2221
|
+
}
|
|
2237
2222
|
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
if (!matchesKey(data, "enter")) return undefined;
|
|
2223
|
+
// ── onTerminalInput hack: auto-redirect <>7 to /sub:peek 7 ───────────
|
|
2224
|
+
let unsubTerminalInput: (() => void) | null = null;
|
|
2241
2225
|
|
|
2242
|
-
|
|
2226
|
+
function registerTerminalInputRedirect(ctx: any): void {
|
|
2227
|
+
// Unsubscribe previous listener to avoid duplicates on session change.
|
|
2228
|
+
unsubTerminalInput?.();
|
|
2229
|
+
unsubTerminalInput = null;
|
|
2243
2230
|
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
ctx.ui.setEditorText(`/sub:peek ${compactPeekMatch[1]}`);
|
|
2248
|
-
return undefined; // let Enter proceed with rewritten text
|
|
2249
|
-
}
|
|
2231
|
+
unsubTerminalInput = ctx.ui.onTerminalInput((data: string) => {
|
|
2232
|
+
// Only intercept Enter key (all terminal variants).
|
|
2233
|
+
if (!matchesKey(data, "enter")) return undefined;
|
|
2250
2234
|
|
|
2251
|
-
|
|
2252
|
-
});
|
|
2253
|
-
}
|
|
2235
|
+
const editorText = (ctx.ui.getEditorText() ?? "").trim();
|
|
2254
2236
|
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
2262
|
-
// Skip if persona marker already present (avoid double-inject)
|
|
2263
|
-
if (event.systemPrompt.includes(PERSONA_MARKER)) return;
|
|
2264
|
-
|
|
2265
|
-
// Find latest PARENT_ENTRY_TYPE entry to determine if this is a sub-trans child session
|
|
2266
|
-
let latestEntry: any = null;
|
|
2267
|
-
try {
|
|
2268
|
-
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
|
2269
|
-
for (const entry of entries) {
|
|
2270
|
-
if ((entry as any).type === "custom" && (entry as any).customType === PARENT_ENTRY_TYPE) {
|
|
2271
|
-
latestEntry = entry;
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
} catch {
|
|
2275
|
-
return;
|
|
2237
|
+
// <>7 → /sub:peek 7
|
|
2238
|
+
const compactPeekMatch = /^<>(\d+)$/.exec(editorText);
|
|
2239
|
+
if (compactPeekMatch?.[1]) {
|
|
2240
|
+
ctx.ui.setEditorText(`/sub:peek ${compactPeekMatch[1]}`);
|
|
2241
|
+
return undefined; // let Enter proceed with rewritten text
|
|
2276
2242
|
}
|
|
2277
2243
|
|
|
2278
|
-
|
|
2244
|
+
return undefined;
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// ── Persona injection for sub-trans child sessions ──────────────────
|
|
2249
|
+
// When the user switches into a subagent session via <> / /sub:trans
|
|
2250
|
+
// and sends normal chat prompts, prepend the subagent's system prompt
|
|
2251
|
+
// so the main agent responds with that persona.
|
|
2252
|
+
const PERSONA_MARKER = "<!-- subagent-persona-injected -->";
|
|
2253
|
+
|
|
2254
|
+
/** before_agent_start handler; index.ts registers the wrapper that awaits the lazy core. */
|
|
2255
|
+
export async function handleBeforeAgentStart(
|
|
2256
|
+
event: { systemPrompt: string },
|
|
2257
|
+
ctx: ExtensionContext,
|
|
2258
|
+
store: SubagentStore,
|
|
2259
|
+
): Promise<{ systemPrompt: string } | undefined> {
|
|
2260
|
+
// Skip if persona marker already present (avoid double-inject)
|
|
2261
|
+
if (event.systemPrompt.includes(PERSONA_MARKER)) return;
|
|
2279
2262
|
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2263
|
+
// Find latest PARENT_ENTRY_TYPE entry to determine if this is a sub-trans child session
|
|
2264
|
+
let latestEntry: any = null;
|
|
2265
|
+
try {
|
|
2266
|
+
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
|
2267
|
+
for (const entry of entries) {
|
|
2268
|
+
if ((entry as any).type === "custom" && (entry as any).customType === PARENT_ENTRY_TYPE) {
|
|
2269
|
+
latestEntry = entry;
|
|
2270
|
+
}
|
|
2284
2271
|
}
|
|
2285
|
-
|
|
2272
|
+
} catch {
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2286
2275
|
|
|
2287
|
-
|
|
2288
|
-
const discovery = discoverAgents(ctx.cwd);
|
|
2289
|
-
const agentConfig = discovery.agents.find((a) => a.name.toLowerCase() === agentName?.toLowerCase());
|
|
2290
|
-
if (!agentConfig?.systemPrompt?.trim()) return;
|
|
2276
|
+
if (!latestEntry?.data) return;
|
|
2291
2277
|
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2278
|
+
// Resolve agent name: data.agent (new entries) or fallback via runId (legacy entries)
|
|
2279
|
+
let agentName: string | undefined = latestEntry.data.agent;
|
|
2280
|
+
if (!agentName && latestEntry.data.runId != null) {
|
|
2281
|
+
agentName = store.commandRuns.get(latestEntry.data.runId)?.agent;
|
|
2282
|
+
}
|
|
2283
|
+
if (!agentName) return;
|
|
2298
2284
|
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2285
|
+
// Discover agents and find exact match
|
|
2286
|
+
const discovery = discoverAgents(ctx.cwd);
|
|
2287
|
+
const agentConfig = discovery.agents.find((a) => a.name.toLowerCase() === agentName?.toLowerCase());
|
|
2288
|
+
if (!agentConfig?.systemPrompt?.trim()) return;
|
|
2303
2289
|
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2290
|
+
// Prepend persona block with marker
|
|
2291
|
+
const personaBlock = `${PERSONA_MARKER}\n${agentConfig.systemPrompt}`;
|
|
2292
|
+
return {
|
|
2293
|
+
systemPrompt: `${personaBlock}\n\n${event.systemPrompt}`,
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
/** session_start handler; index.ts invokes this once the lazy core is loaded. */
|
|
2298
|
+
export function handleSessionStart(pi: ExtensionAPI, store: SubagentStore, ctx: ExtensionContext): void {
|
|
2299
|
+
restoreRunsFromSession(store, ctx, pi);
|
|
2300
|
+
registerTerminalInputRedirect(ctx);
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/** session_shutdown handler; index.ts invokes this after shutting down runs. */
|
|
2304
|
+
export function handleSessionShutdown(): void {
|
|
2305
|
+
unsubTerminalInput?.();
|
|
2306
|
+
unsubTerminalInput = null;
|
|
2308
2307
|
}
|
package/escalation.ts
CHANGED
|
@@ -49,70 +49,71 @@ export function writeEscalationRecord(sessionFile: string, message: string, cont
|
|
|
49
49
|
* - Reads + deletes the escalation file (IPC)
|
|
50
50
|
* - Surfaces the message to the master
|
|
51
51
|
*/
|
|
52
|
-
export function
|
|
53
|
-
pi
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
export function maybeRegisterAskMaster(
|
|
53
|
+
pi: ExtensionAPI,
|
|
54
|
+
ctx: { sessionManager: { getSessionFile(): string | undefined } },
|
|
55
|
+
): void {
|
|
56
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
57
|
+
if (!isSubagentSession(sessionFile)) return;
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}),
|
|
81
|
-
context: Type.Optional(
|
|
82
|
-
Type.String({
|
|
83
|
-
description: "Additional context, such as current progress, discovered issues, and options",
|
|
84
|
-
}),
|
|
85
|
-
),
|
|
59
|
+
pi.registerTool({
|
|
60
|
+
name: "ask_master",
|
|
61
|
+
label: "Ask Master",
|
|
62
|
+
description: [
|
|
63
|
+
"Calling this tool terminates the process immediately. No further work can be performed afterward.",
|
|
64
|
+
"Sends a message to the master and terminates the current process.",
|
|
65
|
+
"The master will review the message and respond appropriately.",
|
|
66
|
+
"",
|
|
67
|
+
"Use when:",
|
|
68
|
+
"- A decision about how to proceed is required",
|
|
69
|
+
"- Confirmation is needed before a risky operation such as deletion, deployment, or migration",
|
|
70
|
+
"- An unexpected situation requires the master’s judgment",
|
|
71
|
+
].join("\n"),
|
|
72
|
+
promptSnippet: "Ask the master for a decision. WARNING: calling this tool terminates your session immediately.",
|
|
73
|
+
promptGuidelines: [
|
|
74
|
+
"ask_master terminates your process — only call when you truly cannot proceed without the master's decision.",
|
|
75
|
+
"Exhaust available tools and context first before resorting to ask_master.",
|
|
76
|
+
"When calling, always include actionable options and your recommendation in the message.",
|
|
77
|
+
],
|
|
78
|
+
parameters: Type.Object({
|
|
79
|
+
message: Type.String({
|
|
80
|
+
description:
|
|
81
|
+
"Message for the master. Explain why a decision is needed, what must be decided, the available options, and your recommendation.",
|
|
86
82
|
}),
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
],
|
|
98
|
-
details: { message: params.message, context: params.context, error: true },
|
|
99
|
-
terminate: true,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
writeEscalationRecord(activeSessionFile, params.message, params.context);
|
|
105
|
-
} catch (err) {
|
|
106
|
-
process.stderr.write(`[ask_master] Failed to write escalation file: ${err}\n`);
|
|
107
|
-
}
|
|
108
|
-
|
|
83
|
+
context: Type.Optional(
|
|
84
|
+
Type.String({
|
|
85
|
+
description: "Additional context, such as current progress, discovered issues, and options",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
}),
|
|
89
|
+
execute: async (_toolCallId, rawParams) => {
|
|
90
|
+
const params = rawParams as { message: string; context?: string };
|
|
91
|
+
const activeSessionFile = sessionFile;
|
|
92
|
+
if (!activeSessionFile) {
|
|
109
93
|
return {
|
|
110
|
-
content: [
|
|
111
|
-
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: "text" as const,
|
|
97
|
+
text: "[ask_master] Error: Missing subagent session file. Escalation not written.",
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
details: { message: params.message, context: params.context, error: true },
|
|
112
101
|
terminate: true,
|
|
113
102
|
};
|
|
114
|
-
}
|
|
115
|
-
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
writeEscalationRecord(activeSessionFile, params.message, params.context);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
process.stderr.write(`[ask_master] Failed to write escalation file: ${err}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
content: [{ type: "text" as const, text: `Escalated to master: ${params.message}` }],
|
|
113
|
+
details: { message: params.message, context: params.context, error: false },
|
|
114
|
+
terminate: true,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
119
|
|
package/index.ts
CHANGED
|
@@ -9,183 +9,124 @@
|
|
|
9
9
|
* Uses JSON mode to capture structured output from subagents.
|
|
10
10
|
*
|
|
11
11
|
* Architecture:
|
|
12
|
-
* types.ts
|
|
13
|
-
* store.ts
|
|
14
|
-
* format.ts
|
|
15
|
-
* session.ts
|
|
16
|
-
* runner.ts
|
|
17
|
-
* replay.ts
|
|
18
|
-
* widget.ts
|
|
19
|
-
* commands.ts
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
import { getSessionFileMtimeMs, readPersistedSessionSnapshot } from "./persisted-session.js";
|
|
29
|
-
import { getLastNonEmptyLine } from "./runner.js";
|
|
30
|
-
import { createStore, type SubagentStore } from "./store.js";
|
|
31
|
-
import type { CommandRunState } from "./types.js";
|
|
32
|
-
import { cleanupCommandRunsWidgetTimer, updateCommandRunsWidget } from "./widget.js";
|
|
33
|
-
|
|
34
|
-
function reconcileRunWithPersistedSession(run: CommandRunState): void {
|
|
35
|
-
if (!run.sessionFile) return;
|
|
36
|
-
|
|
37
|
-
const mtimeMs = getSessionFileMtimeMs(run.sessionFile);
|
|
38
|
-
if (mtimeMs && mtimeMs > run.lastActivityAt) {
|
|
39
|
-
run.lastActivityAt = mtimeMs;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const snapshot = readPersistedSessionSnapshot(run.sessionFile, {
|
|
43
|
-
startOffset: run.persistedSessionBaseOffset,
|
|
44
|
-
});
|
|
45
|
-
if (snapshot.latestActivityAt && snapshot.latestActivityAt > run.lastActivityAt) {
|
|
46
|
-
run.lastActivityAt = snapshot.latestActivityAt;
|
|
47
|
-
}
|
|
48
|
-
if (!snapshot.isTerminal) return;
|
|
49
|
-
|
|
50
|
-
const exitCode =
|
|
51
|
-
snapshot.completionMarker?.exitCode ??
|
|
52
|
-
(snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted" ? 1 : 0);
|
|
53
|
-
run.status = exitCode === 0 ? "done" : "error";
|
|
54
|
-
if (snapshot.finalOutput) {
|
|
55
|
-
run.lastOutput = snapshot.finalOutput;
|
|
56
|
-
run.lastLine = getLastNonEmptyLine(snapshot.finalOutput) || run.lastLine;
|
|
57
|
-
}
|
|
58
|
-
if (snapshot.latestActivityAt) {
|
|
59
|
-
run.elapsedMs = Math.max(run.elapsedMs, snapshot.latestActivityAt - run.startedAt);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Abort all session-scoped child processes before Pi invalidates this
|
|
65
|
-
* extension runtime. A non-triggering failure entry is persisted so returning
|
|
66
|
-
* to the old session explains why the run stopped.
|
|
12
|
+
* types.ts — Type definitions, interfaces, Typebox schemas
|
|
13
|
+
* store.ts — Shared state (SubagentStore) and state-mutation helpers
|
|
14
|
+
* format.ts — Token/usage/tool-call formatting utilities
|
|
15
|
+
* session.ts — Session file management and context helpers
|
|
16
|
+
* runner.ts — Subagent process execution, agent matching, concurrency
|
|
17
|
+
* replay.ts — Session replay viewer (TUI overlay)
|
|
18
|
+
* widget.ts — Run status widget (above-editor display)
|
|
19
|
+
* commands.ts — Tool handler, slash-commands, event handlers
|
|
20
|
+
* lifecycle.ts — Hang detection sweeps and shutdown cleanup
|
|
21
|
+
* index.ts — Thin boot orchestrator (this file)
|
|
22
|
+
*
|
|
23
|
+
* Boot strategy: this entrypoint imports only constants and registers thin
|
|
24
|
+
* event wrappers, then loads the heavy module graph lazily in the background.
|
|
25
|
+
* before_agent_start awaits the lazy core so tools (subagent, list-agents,
|
|
26
|
+
* ask_master) are always registered before the first agent turn — including
|
|
27
|
+
* headless (`pi -p`) runs.
|
|
67
28
|
*/
|
|
68
|
-
type SessionShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
|
69
29
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const activeRuns = new Map<number, CommandRunState>();
|
|
75
|
-
for (const [runId, run] of store.commandRuns) activeRuns.set(runId, run);
|
|
76
|
-
for (const [runId, entry] of store.globalLiveRuns) activeRuns.set(runId, entry.runState);
|
|
77
|
-
|
|
78
|
-
for (const [runId, run] of activeRuns) {
|
|
79
|
-
if (run.status !== "running") continue;
|
|
80
|
-
const message = `Aborted because the parent pi session ${reason} is shutting down.`;
|
|
81
|
-
run.status = "error";
|
|
82
|
-
run.elapsedMs = Date.now() - run.startedAt;
|
|
83
|
-
run.lastActivityAt = Date.now();
|
|
84
|
-
run.lastLine = message;
|
|
85
|
-
run.lastOutput = message;
|
|
86
|
-
run.removed = true;
|
|
87
|
-
|
|
88
|
-
const controller = run.abortController ?? store.globalLiveRuns.get(runId)?.abortController;
|
|
89
|
-
controller?.abort();
|
|
90
|
-
run.abortController = undefined;
|
|
91
|
-
|
|
92
|
-
if (run.deliveryMode === "humanOnly") continue;
|
|
93
|
-
try {
|
|
94
|
-
pi.sendMessage(
|
|
95
|
-
{
|
|
96
|
-
customType: run.source === "tool" ? "subagent-tool" : "subagent-command",
|
|
97
|
-
content: `[subagent:${run.agent}#${runId}] failed\n\n${message}`,
|
|
98
|
-
display: false,
|
|
99
|
-
details: {
|
|
100
|
-
runId,
|
|
101
|
-
agent: run.agent,
|
|
102
|
-
task: run.task,
|
|
103
|
-
displayTask: run.displayTask,
|
|
104
|
-
status: "error",
|
|
105
|
-
error: message,
|
|
106
|
-
startedAt: run.startedAt,
|
|
107
|
-
elapsedMs: run.elapsedMs,
|
|
108
|
-
lastActivityAt: run.lastActivityAt,
|
|
109
|
-
sessionFile: run.sessionFile,
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
{ deliverAs: "followUp", triggerTurn: false },
|
|
113
|
-
);
|
|
114
|
-
} catch {
|
|
115
|
-
// The runtime may already be closing; aborting the child remains the priority.
|
|
116
|
-
}
|
|
117
|
-
}
|
|
30
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { HANG_CHECK_INTERVAL_MS } from "./constants.js";
|
|
32
|
+
import { SUBAGENT_COMMANDS, SUBAGENT_SHORTCUTS, type SubagentCommandName } from "./registration-manifest.js";
|
|
118
33
|
|
|
119
|
-
|
|
120
|
-
store.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
store.pixelWidgetCtx = null;
|
|
126
|
-
cleanupCommandRunsWidgetTimer();
|
|
34
|
+
interface SubagentCore {
|
|
35
|
+
store: import("./store.js").SubagentStore;
|
|
36
|
+
commands: typeof import("./commands.js");
|
|
37
|
+
registrations: import("./commands.js").SubagentRegistrations;
|
|
38
|
+
escalation: typeof import("./escalation.js");
|
|
39
|
+
lifecycle: typeof import("./lifecycle.js");
|
|
127
40
|
}
|
|
128
41
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
*/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
42
|
+
export default function (pi: ExtensionAPI) {
|
|
43
|
+
let core: SubagentCore | null = null;
|
|
44
|
+
let corePromise: Promise<SubagentCore> | null = null;
|
|
45
|
+
/** Serializes session lifecycle work so events apply in dispatch order. */
|
|
46
|
+
let chain: Promise<void> = Promise.resolve();
|
|
47
|
+
|
|
48
|
+
const loadCore = (): Promise<SubagentCore> => {
|
|
49
|
+
corePromise ??= (async () => {
|
|
50
|
+
const [commands, escalation, lifecycle, storeMod] = await Promise.all([
|
|
51
|
+
import("./commands.js"),
|
|
52
|
+
import("./escalation.js"),
|
|
53
|
+
import("./lifecycle.js"),
|
|
54
|
+
import("./store.js"),
|
|
55
|
+
]);
|
|
56
|
+
const store = storeMod.createStore();
|
|
57
|
+
const registrations = commands.registerAll(pi, store);
|
|
58
|
+
core = { store, commands, registrations, escalation, lifecycle };
|
|
59
|
+
return core;
|
|
60
|
+
})().catch((error) => {
|
|
61
|
+
corePromise = null;
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
return corePromise;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const enqueue = (fn: (core: SubagentCore) => void): Promise<void> => {
|
|
68
|
+
chain = chain
|
|
69
|
+
.then(() => loadCore())
|
|
70
|
+
.then(fn)
|
|
71
|
+
.catch((err) => {
|
|
72
|
+
process.stderr.write(`[subagent] deferred init failed: ${err instanceof Error ? err.message : err}\n`);
|
|
73
|
+
});
|
|
74
|
+
return chain;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const getCommandDefinition = async (name: SubagentCommandName) => {
|
|
78
|
+
const loaded = await loadCore();
|
|
79
|
+
await chain;
|
|
80
|
+
const definition = loaded.registrations.commands.get(name);
|
|
81
|
+
if (!definition) throw new Error(`Subagent command definition not found: ${name}`);
|
|
82
|
+
return definition;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Register lightweight proxies synchronously so Pi's initial autocomplete
|
|
86
|
+
// and command registry contain every subagent command before lazy core load.
|
|
87
|
+
for (const [name, description] of Object.entries(SUBAGENT_COMMANDS) as Array<[SubagentCommandName, string]>) {
|
|
88
|
+
pi.registerCommand(name, {
|
|
89
|
+
description,
|
|
90
|
+
getArgumentCompletions: async (prefix) => {
|
|
91
|
+
const definition = await getCommandDefinition(name);
|
|
92
|
+
return definition.getArgumentCompletions?.(prefix) ?? null;
|
|
93
|
+
},
|
|
94
|
+
handler: async (args, ctx) => {
|
|
95
|
+
const definition = await getCommandDefinition(name);
|
|
96
|
+
return definition.handler(args, ctx);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
168
99
|
}
|
|
169
100
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
101
|
+
// These entries document text-prefix shortcuts in /hotkeys. Actual routing
|
|
102
|
+
// remains in commands.ts input handlers after the lazy core is loaded.
|
|
103
|
+
for (const [shortcut, description] of Object.entries(SUBAGENT_SHORTCUTS)) {
|
|
104
|
+
pi.registerShortcut(shortcut as never, {
|
|
105
|
+
description,
|
|
106
|
+
handler: async () => {},
|
|
107
|
+
});
|
|
174
108
|
}
|
|
175
109
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
110
|
+
// Start loading in the background without blocking extension boot.
|
|
111
|
+
// setTimeout keeps the load out of the boot path's microtask drains.
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
void loadCore().catch(() => {
|
|
114
|
+
// Reported when the first enqueue/await surfaces the failure.
|
|
115
|
+
});
|
|
116
|
+
}, 0);
|
|
183
117
|
|
|
184
118
|
let hangCheckTimer: ReturnType<typeof setInterval> | undefined;
|
|
185
|
-
|
|
186
|
-
|
|
119
|
+
|
|
120
|
+
pi.on("session_start", (_event, ctx) => {
|
|
121
|
+
enqueue((c) => {
|
|
122
|
+
c.store.disposed = false;
|
|
123
|
+
c.commands.handleSessionStart(pi, c.store, ctx as unknown as ExtensionContext);
|
|
124
|
+
c.escalation.maybeRegisterAskMaster(pi, ctx);
|
|
125
|
+
});
|
|
187
126
|
if (!hangCheckTimer) {
|
|
188
|
-
hangCheckTimer = setInterval(() =>
|
|
127
|
+
hangCheckTimer = setInterval(() => {
|
|
128
|
+
if (core) core.lifecycle.checkForHungRuns(core.store, pi);
|
|
129
|
+
}, HANG_CHECK_INTERVAL_MS);
|
|
189
130
|
}
|
|
190
131
|
});
|
|
191
132
|
|
|
@@ -194,7 +135,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
194
135
|
clearInterval(hangCheckTimer);
|
|
195
136
|
hangCheckTimer = undefined;
|
|
196
137
|
}
|
|
197
|
-
|
|
198
|
-
|
|
138
|
+
// Wait for any queued session_start work so its loadCore() is visible here.
|
|
139
|
+
await chain;
|
|
140
|
+
if (!corePromise) return;
|
|
141
|
+
const c = await loadCore();
|
|
142
|
+
c.lifecycle.shutdownSubagentRuns(c.store, pi, event.reason);
|
|
143
|
+
c.commands.handleSessionShutdown();
|
|
144
|
+
c.lifecycle.cleanupPixelTimer();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
148
|
+
const c = await loadCore();
|
|
149
|
+
await chain;
|
|
150
|
+
return c.commands.handleBeforeAgentStart(event, ctx, c.store);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// If input arrives while the core is still loading, awaiting here lets the
|
|
154
|
+
// live handler-array dispatch reach the shortcut handlers registerAll adds.
|
|
155
|
+
pi.on("input", async () => {
|
|
156
|
+
if (!core) await loadCore().catch(() => {});
|
|
157
|
+
return { action: "continue" as const };
|
|
199
158
|
});
|
|
200
159
|
}
|
package/lifecycle.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle helpers: hang detection sweeps and shutdown cleanup.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of index.ts so the boot entrypoint stays import-light; index.ts
|
|
5
|
+
* loads this module lazily.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { HANG_TIMEOUT_MS } from "./constants.js";
|
|
10
|
+
import { getSessionFileMtimeMs, readPersistedSessionSnapshot } from "./persisted-session.js";
|
|
11
|
+
import { getLastNonEmptyLine } from "./runner.js";
|
|
12
|
+
import type { SubagentStore } from "./store.js";
|
|
13
|
+
import type { CommandRunState } from "./types.js";
|
|
14
|
+
import { cleanupCommandRunsWidgetTimer, updateCommandRunsWidget } from "./widget.js";
|
|
15
|
+
|
|
16
|
+
export { cleanupPixelTimer } from "./above-widget.js";
|
|
17
|
+
|
|
18
|
+
function reconcileRunWithPersistedSession(run: CommandRunState): void {
|
|
19
|
+
if (!run.sessionFile) return;
|
|
20
|
+
|
|
21
|
+
const mtimeMs = getSessionFileMtimeMs(run.sessionFile);
|
|
22
|
+
if (mtimeMs && mtimeMs > run.lastActivityAt) {
|
|
23
|
+
run.lastActivityAt = mtimeMs;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const snapshot = readPersistedSessionSnapshot(run.sessionFile, {
|
|
27
|
+
startOffset: run.persistedSessionBaseOffset,
|
|
28
|
+
});
|
|
29
|
+
if (snapshot.latestActivityAt && snapshot.latestActivityAt > run.lastActivityAt) {
|
|
30
|
+
run.lastActivityAt = snapshot.latestActivityAt;
|
|
31
|
+
}
|
|
32
|
+
if (!snapshot.isTerminal) return;
|
|
33
|
+
|
|
34
|
+
const exitCode =
|
|
35
|
+
snapshot.completionMarker?.exitCode ??
|
|
36
|
+
(snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted" ? 1 : 0);
|
|
37
|
+
run.status = exitCode === 0 ? "done" : "error";
|
|
38
|
+
if (snapshot.finalOutput) {
|
|
39
|
+
run.lastOutput = snapshot.finalOutput;
|
|
40
|
+
run.lastLine = getLastNonEmptyLine(snapshot.finalOutput) || run.lastLine;
|
|
41
|
+
}
|
|
42
|
+
if (snapshot.latestActivityAt) {
|
|
43
|
+
run.elapsedMs = Math.max(run.elapsedMs, snapshot.latestActivityAt - run.startedAt);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Abort all session-scoped child processes before Pi invalidates this
|
|
49
|
+
* extension runtime. A non-triggering failure entry is persisted so returning
|
|
50
|
+
* to the old session explains why the run stopped.
|
|
51
|
+
*/
|
|
52
|
+
export type SessionShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
|
53
|
+
|
|
54
|
+
export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, reason: SessionShutdownReason): void {
|
|
55
|
+
if (store.disposed) return;
|
|
56
|
+
store.disposed = true;
|
|
57
|
+
|
|
58
|
+
const activeRuns = new Map<number, CommandRunState>();
|
|
59
|
+
for (const [runId, run] of store.commandRuns) activeRuns.set(runId, run);
|
|
60
|
+
for (const [runId, entry] of store.globalLiveRuns) activeRuns.set(runId, entry.runState);
|
|
61
|
+
|
|
62
|
+
for (const [runId, run] of activeRuns) {
|
|
63
|
+
if (run.status !== "running") continue;
|
|
64
|
+
const message = `Aborted because the parent pi session ${reason} is shutting down.`;
|
|
65
|
+
run.status = "error";
|
|
66
|
+
run.elapsedMs = Date.now() - run.startedAt;
|
|
67
|
+
run.lastActivityAt = Date.now();
|
|
68
|
+
run.lastLine = message;
|
|
69
|
+
run.lastOutput = message;
|
|
70
|
+
run.removed = true;
|
|
71
|
+
|
|
72
|
+
const controller = run.abortController ?? store.globalLiveRuns.get(runId)?.abortController;
|
|
73
|
+
controller?.abort();
|
|
74
|
+
run.abortController = undefined;
|
|
75
|
+
|
|
76
|
+
if (run.deliveryMode === "humanOnly") continue;
|
|
77
|
+
try {
|
|
78
|
+
pi.sendMessage(
|
|
79
|
+
{
|
|
80
|
+
customType: run.source === "tool" ? "subagent-tool" : "subagent-command",
|
|
81
|
+
content: `[subagent:${run.agent}#${runId}] failed\n\n${message}`,
|
|
82
|
+
display: false,
|
|
83
|
+
details: {
|
|
84
|
+
runId,
|
|
85
|
+
agent: run.agent,
|
|
86
|
+
task: run.task,
|
|
87
|
+
displayTask: run.displayTask,
|
|
88
|
+
status: "error",
|
|
89
|
+
error: message,
|
|
90
|
+
startedAt: run.startedAt,
|
|
91
|
+
elapsedMs: run.elapsedMs,
|
|
92
|
+
lastActivityAt: run.lastActivityAt,
|
|
93
|
+
sessionFile: run.sessionFile,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{ deliverAs: "followUp", triggerTurn: false },
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
// The runtime may already be closing; aborting the child remains the priority.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
store.globalLiveRuns.clear();
|
|
104
|
+
store.batchGroups.clear();
|
|
105
|
+
store.pipelines.clear();
|
|
106
|
+
store.recentLaunchTimestamps.clear();
|
|
107
|
+
store.commandRuns.clear();
|
|
108
|
+
store.commandWidgetCtx = null;
|
|
109
|
+
store.pixelWidgetCtx = null;
|
|
110
|
+
cleanupCommandRunsWidgetTimer();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Sweep active runs for inactivity. The normal run finalizer owns completion
|
|
115
|
+
* delivery so an auto-abort cannot emit two follow-up messages.
|
|
116
|
+
*/
|
|
117
|
+
export function checkForHungRuns(store: SubagentStore, _pi: ExtensionAPI): void {
|
|
118
|
+
if (store.disposed) return;
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const processed = new Set<number>();
|
|
121
|
+
|
|
122
|
+
function tryAbort(runId: number, run: CommandRunState): void {
|
|
123
|
+
reconcileRunWithPersistedSession(run);
|
|
124
|
+
// Skip if already completed/aborted or not running
|
|
125
|
+
if (run.status !== "running") return;
|
|
126
|
+
if (!run.lastActivityAt) return;
|
|
127
|
+
// Guard: skip runs already auto-aborted (prevents duplicate abort/followUp)
|
|
128
|
+
if (run.lastLine?.startsWith("Auto-aborted:")) return;
|
|
129
|
+
|
|
130
|
+
const idleMs = now - run.lastActivityAt;
|
|
131
|
+
if (idleMs < HANG_TIMEOUT_MS) return;
|
|
132
|
+
|
|
133
|
+
// Try to abort via run's own controller, then globalLiveRuns fallback
|
|
134
|
+
const globalEntry = store.globalLiveRuns.get(runId);
|
|
135
|
+
const controller = run.abortController ?? globalEntry?.abortController;
|
|
136
|
+
|
|
137
|
+
const reason = `Auto-aborted: no activity for ${Math.round(idleMs / 1000)}s`;
|
|
138
|
+
run.lastLine = reason;
|
|
139
|
+
run.lastOutput = reason;
|
|
140
|
+
run.status = "error";
|
|
141
|
+
run.autoAbortReason = reason;
|
|
142
|
+
|
|
143
|
+
if (controller) {
|
|
144
|
+
controller.abort();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sweep commandRuns first
|
|
149
|
+
for (const [runId, run] of store.commandRuns) {
|
|
150
|
+
processed.add(runId);
|
|
151
|
+
tryAbort(runId, run);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sweep registry-only runs that are not present in the current widget view.
|
|
155
|
+
for (const [runId, entry] of store.globalLiveRuns) {
|
|
156
|
+
if (processed.has(runId)) continue;
|
|
157
|
+
tryAbort(runId, entry.runState);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
updateCommandRunsWidget(store);
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryan_nookpi/pi-extension-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,10 +36,12 @@
|
|
|
36
36
|
"format.ts",
|
|
37
37
|
"group-pending.ts",
|
|
38
38
|
"index.ts",
|
|
39
|
+
"lifecycle.ts",
|
|
39
40
|
"invocation-queue.ts",
|
|
40
41
|
"live-preview.ts",
|
|
41
42
|
"persisted-session.ts",
|
|
42
43
|
"replay.ts",
|
|
44
|
+
"registration-manifest.ts",
|
|
43
45
|
"retry.ts",
|
|
44
46
|
"run-utils.ts",
|
|
45
47
|
"runner.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const SUBAGENT_COMMANDS = {
|
|
2
|
+
"sub:isolate":
|
|
3
|
+
"Run a subagent in a dedicated sub-session: /sub:isolate <agent|alias> <task>, /sub:isolate <runId> <task>, /sub:isolate <task> (uses configured defaultAgent)",
|
|
4
|
+
"sub:main": "Run a subagent with main-session context inheritance: /sub:main <agent|alias> <task>",
|
|
5
|
+
subagents: "List available subagents and offer the starter pack when none are configured",
|
|
6
|
+
"sub:peek": "Show the latest response from a subagent in an overlay: /sub:peek [runId]",
|
|
7
|
+
"sub:open": "Open a subagent session replay overlay: /sub:open [runId]",
|
|
8
|
+
"sub:history": "Show all subagent run history (including removed) in an overlay: /sub:history",
|
|
9
|
+
"sub:rm": "Remove one /sub job entry (aborts it if running): /sub:rm [runId]",
|
|
10
|
+
"sub:clear": "Clear /sub job widget entries. /sub:clear (finished only) or /sub:clear all",
|
|
11
|
+
"sub:abort": "Abort running subagent job(s). /sub:abort [runId|all]",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type SubagentCommandName = keyof typeof SUBAGENT_COMMANDS;
|
|
15
|
+
|
|
16
|
+
export const SUBAGENT_SHORTCUTS = {
|
|
17
|
+
">>": "Run subagent task",
|
|
18
|
+
"#<runId>": "Resume subagent run: #<runId> <task>",
|
|
19
|
+
"<<": "Abort or clear subagent runs",
|
|
20
|
+
"<<<": "Clear finished subagent jobs (= /sub:clear). <<< all to clear all",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type SubagentShortcutName = keyof typeof SUBAGENT_SHORTCUTS;
|