@ryan_nookpi/pi-extension-subagent 0.2.0 → 0.3.0
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 +1 -1
- package/commands.ts +71 -64
- package/escalation.ts +60 -59
- package/index.ts +91 -170
- package/lifecycle.ts +161 -0
- package/package.json +97 -96
- package/utils/agent-utils.ts +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ Implement the requested changes and verify them.
|
|
|
67
67
|
|
|
68
68
|
- `runtime`: `pi` (default) or `claude`
|
|
69
69
|
- `model`: runtime-compatible model ID
|
|
70
|
-
- `thinking`: `off`, `minimal`, `low`, `medium`, `high`, or `
|
|
70
|
+
- `thinking`: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`
|
|
71
71
|
- `tools`: comma-separated tool names
|
|
72
72
|
|
|
73
73
|
Omitted model, thinking, and tools values use that runtime's defaults.
|
package/commands.ts
CHANGED
|
@@ -2226,83 +2226,90 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
|
|
|
2226
2226
|
ctx.ui.notify(parts.join(", ") || "Nothing to do.", parts.length ? (aborted ? "warning" : "info") : "info");
|
|
2227
2227
|
return { action: "handled" as const };
|
|
2228
2228
|
});
|
|
2229
|
+
}
|
|
2229
2230
|
|
|
2230
|
-
|
|
2231
|
-
|
|
2231
|
+
// ── onTerminalInput hack: auto-redirect <>7 to /sub:peek 7 ───────────
|
|
2232
|
+
let unsubTerminalInput: (() => void) | null = null;
|
|
2232
2233
|
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2234
|
+
function registerTerminalInputRedirect(ctx: any): void {
|
|
2235
|
+
// Unsubscribe previous listener to avoid duplicates on session change.
|
|
2236
|
+
unsubTerminalInput?.();
|
|
2237
|
+
unsubTerminalInput = null;
|
|
2237
2238
|
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2239
|
+
unsubTerminalInput = ctx.ui.onTerminalInput((data: string) => {
|
|
2240
|
+
// Only intercept Enter key (all terminal variants).
|
|
2241
|
+
if (!matchesKey(data, "enter")) return undefined;
|
|
2241
2242
|
|
|
2242
|
-
|
|
2243
|
+
const editorText = (ctx.ui.getEditorText() ?? "").trim();
|
|
2243
2244
|
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2245
|
+
// <>7 → /sub:peek 7
|
|
2246
|
+
const compactPeekMatch = /^<>(\d+)$/.exec(editorText);
|
|
2247
|
+
if (compactPeekMatch?.[1]) {
|
|
2248
|
+
ctx.ui.setEditorText(`/sub:peek ${compactPeekMatch[1]}`);
|
|
2249
|
+
return undefined; // let Enter proceed with rewritten text
|
|
2250
|
+
}
|
|
2250
2251
|
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2252
|
+
return undefined;
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2254
2255
|
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2256
|
+
// ── Persona injection for sub-trans child sessions ──────────────────
|
|
2257
|
+
// When the user switches into a subagent session via <> / /sub:trans
|
|
2258
|
+
// and sends normal chat prompts, prepend the subagent's system prompt
|
|
2259
|
+
// so the main agent responds with that persona.
|
|
2260
|
+
const PERSONA_MARKER = "<!-- subagent-persona-injected -->";
|
|
2261
|
+
|
|
2262
|
+
/** before_agent_start handler; index.ts registers the wrapper that awaits the lazy core. */
|
|
2263
|
+
export async function handleBeforeAgentStart(
|
|
2264
|
+
event: { systemPrompt: string },
|
|
2265
|
+
ctx: ExtensionContext,
|
|
2266
|
+
store: SubagentStore,
|
|
2267
|
+
): Promise<{ systemPrompt: string } | undefined> {
|
|
2268
|
+
// Skip if persona marker already present (avoid double-inject)
|
|
2269
|
+
if (event.systemPrompt.includes(PERSONA_MARKER)) return;
|
|
2270
|
+
|
|
2271
|
+
// Find latest PARENT_ENTRY_TYPE entry to determine if this is a sub-trans child session
|
|
2272
|
+
let latestEntry: any = null;
|
|
2273
|
+
try {
|
|
2274
|
+
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
|
2275
|
+
for (const entry of entries) {
|
|
2276
|
+
if ((entry as any).type === "custom" && (entry as any).customType === PARENT_ENTRY_TYPE) {
|
|
2277
|
+
latestEntry = entry;
|
|
2273
2278
|
}
|
|
2274
|
-
} catch {
|
|
2275
|
-
return;
|
|
2276
2279
|
}
|
|
2280
|
+
} catch {
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2277
2283
|
|
|
2278
|
-
|
|
2284
|
+
if (!latestEntry?.data) return;
|
|
2279
2285
|
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
+
// Resolve agent name: data.agent (new entries) or fallback via runId (legacy entries)
|
|
2287
|
+
let agentName: string | undefined = latestEntry.data.agent;
|
|
2288
|
+
if (!agentName && latestEntry.data.runId != null) {
|
|
2289
|
+
agentName = store.commandRuns.get(latestEntry.data.runId)?.agent;
|
|
2290
|
+
}
|
|
2291
|
+
if (!agentName) return;
|
|
2286
2292
|
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2293
|
+
// Discover agents and find exact match
|
|
2294
|
+
const discovery = discoverAgents(ctx.cwd);
|
|
2295
|
+
const agentConfig = discovery.agents.find((a) => a.name.toLowerCase() === agentName?.toLowerCase());
|
|
2296
|
+
if (!agentConfig?.systemPrompt?.trim()) return;
|
|
2291
2297
|
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
+
// Prepend persona block with marker
|
|
2299
|
+
const personaBlock = `${PERSONA_MARKER}\n${agentConfig.systemPrompt}`;
|
|
2300
|
+
return {
|
|
2301
|
+
systemPrompt: `${personaBlock}\n\n${event.systemPrompt}`,
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2298
2304
|
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2305
|
+
/** session_start handler; index.ts invokes this once the lazy core is loaded. */
|
|
2306
|
+
export function handleSessionStart(pi: ExtensionAPI, store: SubagentStore, ctx: ExtensionContext): void {
|
|
2307
|
+
restoreRunsFromSession(store, ctx, pi);
|
|
2308
|
+
registerTerminalInputRedirect(ctx);
|
|
2309
|
+
}
|
|
2303
2310
|
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2311
|
+
/** session_shutdown handler; index.ts invokes this after shutting down runs. */
|
|
2312
|
+
export function handleSessionShutdown(): void {
|
|
2313
|
+
unsubTerminalInput?.();
|
|
2314
|
+
unsubTerminalInput = null;
|
|
2308
2315
|
}
|
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,86 @@
|
|
|
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.
|
|
67
|
-
*/
|
|
68
|
-
type SessionShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
|
69
|
-
|
|
70
|
-
export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, reason: SessionShutdownReason): void {
|
|
71
|
-
if (store.disposed) return;
|
|
72
|
-
store.disposed = true;
|
|
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
|
-
}
|
|
118
|
-
|
|
119
|
-
store.globalLiveRuns.clear();
|
|
120
|
-
store.batchGroups.clear();
|
|
121
|
-
store.pipelines.clear();
|
|
122
|
-
store.recentLaunchTimestamps.clear();
|
|
123
|
-
store.commandRuns.clear();
|
|
124
|
-
store.commandWidgetCtx = null;
|
|
125
|
-
store.pixelWidgetCtx = null;
|
|
126
|
-
cleanupCommandRunsWidgetTimer();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Sweep active runs for inactivity. The normal run finalizer owns completion
|
|
131
|
-
* delivery so an auto-abort cannot emit two follow-up messages.
|
|
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.
|
|
132
28
|
*/
|
|
133
|
-
export function checkForHungRuns(store: SubagentStore, _pi: ExtensionAPI): void {
|
|
134
|
-
if (store.disposed) return;
|
|
135
|
-
const now = Date.now();
|
|
136
|
-
const processed = new Set<number>();
|
|
137
29
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// Skip if already completed/aborted or not running
|
|
141
|
-
if (run.status !== "running") return;
|
|
142
|
-
if (!run.lastActivityAt) return;
|
|
143
|
-
// Guard: skip runs already auto-aborted (prevents duplicate abort/followUp)
|
|
144
|
-
if (run.lastLine?.startsWith("Auto-aborted:")) return;
|
|
30
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { HANG_CHECK_INTERVAL_MS } from "./constants.js";
|
|
145
32
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const controller = run.abortController ?? globalEntry?.abortController;
|
|
152
|
-
|
|
153
|
-
const reason = `Auto-aborted: no activity for ${Math.round(idleMs / 1000)}s`;
|
|
154
|
-
run.lastLine = reason;
|
|
155
|
-
run.lastOutput = reason;
|
|
156
|
-
run.status = "error";
|
|
157
|
-
run.autoAbortReason = reason;
|
|
158
|
-
|
|
159
|
-
if (controller) {
|
|
160
|
-
controller.abort();
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Sweep commandRuns first
|
|
165
|
-
for (const [runId, run] of store.commandRuns) {
|
|
166
|
-
processed.add(runId);
|
|
167
|
-
tryAbort(runId, run);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Sweep registry-only runs that are not present in the current widget view.
|
|
171
|
-
for (const [runId, entry] of store.globalLiveRuns) {
|
|
172
|
-
if (processed.has(runId)) continue;
|
|
173
|
-
tryAbort(runId, entry.runState);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
updateCommandRunsWidget(store);
|
|
33
|
+
interface SubagentCore {
|
|
34
|
+
store: import("./store.js").SubagentStore;
|
|
35
|
+
commands: typeof import("./commands.js");
|
|
36
|
+
escalation: typeof import("./escalation.js");
|
|
37
|
+
lifecycle: typeof import("./lifecycle.js");
|
|
177
38
|
}
|
|
178
39
|
|
|
179
40
|
export default function (pi: ExtensionAPI) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
41
|
+
let core: SubagentCore | null = null;
|
|
42
|
+
let corePromise: Promise<SubagentCore> | null = null;
|
|
43
|
+
/** Serializes session lifecycle work so events apply in dispatch order. */
|
|
44
|
+
let chain: Promise<void> = Promise.resolve();
|
|
45
|
+
|
|
46
|
+
const loadCore = (): Promise<SubagentCore> => {
|
|
47
|
+
corePromise ??= (async () => {
|
|
48
|
+
const [commands, escalation, lifecycle, storeMod] = await Promise.all([
|
|
49
|
+
import("./commands.js"),
|
|
50
|
+
import("./escalation.js"),
|
|
51
|
+
import("./lifecycle.js"),
|
|
52
|
+
import("./store.js"),
|
|
53
|
+
]);
|
|
54
|
+
const store = storeMod.createStore();
|
|
55
|
+
commands.registerAll(pi, store);
|
|
56
|
+
core = { store, commands, escalation, lifecycle };
|
|
57
|
+
return core;
|
|
58
|
+
})();
|
|
59
|
+
return corePromise;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const enqueue = (fn: (core: SubagentCore) => void): Promise<void> => {
|
|
63
|
+
chain = chain
|
|
64
|
+
.then(() => loadCore())
|
|
65
|
+
.then(fn)
|
|
66
|
+
.catch((err) => {
|
|
67
|
+
process.stderr.write(`[subagent] deferred init failed: ${err instanceof Error ? err.message : err}\n`);
|
|
68
|
+
});
|
|
69
|
+
return chain;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Start loading in the background without blocking extension boot.
|
|
73
|
+
// setTimeout keeps the load out of the boot path's microtask drains.
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
void loadCore().catch(() => {
|
|
76
|
+
// Reported when the first enqueue/await surfaces the failure.
|
|
77
|
+
});
|
|
78
|
+
}, 0);
|
|
183
79
|
|
|
184
80
|
let hangCheckTimer: ReturnType<typeof setInterval> | undefined;
|
|
185
|
-
|
|
186
|
-
|
|
81
|
+
|
|
82
|
+
pi.on("session_start", (_event, ctx) => {
|
|
83
|
+
enqueue((c) => {
|
|
84
|
+
c.store.disposed = false;
|
|
85
|
+
c.commands.handleSessionStart(pi, c.store, ctx as unknown as ExtensionContext);
|
|
86
|
+
c.escalation.maybeRegisterAskMaster(pi, ctx);
|
|
87
|
+
});
|
|
187
88
|
if (!hangCheckTimer) {
|
|
188
|
-
hangCheckTimer = setInterval(() =>
|
|
89
|
+
hangCheckTimer = setInterval(() => {
|
|
90
|
+
if (core) core.lifecycle.checkForHungRuns(core.store, pi);
|
|
91
|
+
}, HANG_CHECK_INTERVAL_MS);
|
|
189
92
|
}
|
|
190
93
|
});
|
|
191
94
|
|
|
@@ -194,7 +97,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
194
97
|
clearInterval(hangCheckTimer);
|
|
195
98
|
hangCheckTimer = undefined;
|
|
196
99
|
}
|
|
197
|
-
|
|
198
|
-
|
|
100
|
+
// Wait for any queued session_start work so its loadCore() is visible here.
|
|
101
|
+
await chain;
|
|
102
|
+
if (!corePromise) return;
|
|
103
|
+
const c = await loadCore();
|
|
104
|
+
c.lifecycle.shutdownSubagentRuns(c.store, pi, event.reason);
|
|
105
|
+
c.commands.handleSessionShutdown();
|
|
106
|
+
c.lifecycle.cleanupPixelTimer();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
110
|
+
const c = await loadCore();
|
|
111
|
+
await chain;
|
|
112
|
+
return c.commands.handleBeforeAgentStart(event, ctx, c.store);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// If input arrives while the core is still loading, awaiting here lets the
|
|
116
|
+
// live handler-array dispatch reach the shortcut handlers registerAll adds.
|
|
117
|
+
pi.on("input", async () => {
|
|
118
|
+
if (!core) await loadCore().catch(() => {});
|
|
119
|
+
return { action: "continue" as const };
|
|
199
120
|
});
|
|
200
121
|
}
|
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,97 +1,98 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
2
|
+
"name": "@ryan_nookpi/pi-extension-subagent",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Jonghakseo/pi-extension.git",
|
|
9
|
+
"directory": "packages/subagent"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Jonghakseo/pi-extension/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/subagent#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"subagent",
|
|
19
|
+
"delegation"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"above-widget.ts",
|
|
23
|
+
"agents.ts",
|
|
24
|
+
"claude-args.ts",
|
|
25
|
+
"claude-sdk-runner.ts",
|
|
26
|
+
"claude-sdk-stream-adapter.ts",
|
|
27
|
+
"claude-sidecar-writer.ts",
|
|
28
|
+
"claude-stream-parser.ts",
|
|
29
|
+
"cli.ts",
|
|
30
|
+
"commands.ts",
|
|
31
|
+
"config.ts",
|
|
32
|
+
"constants.ts",
|
|
33
|
+
"context-limits.ts",
|
|
34
|
+
"display-task.ts",
|
|
35
|
+
"escalation.ts",
|
|
36
|
+
"format.ts",
|
|
37
|
+
"group-pending.ts",
|
|
38
|
+
"index.ts",
|
|
39
|
+
"lifecycle.ts",
|
|
40
|
+
"invocation-queue.ts",
|
|
41
|
+
"live-preview.ts",
|
|
42
|
+
"persisted-session.ts",
|
|
43
|
+
"replay.ts",
|
|
44
|
+
"retry.ts",
|
|
45
|
+
"run-utils.ts",
|
|
46
|
+
"runner.ts",
|
|
47
|
+
"session.ts",
|
|
48
|
+
"starter-pack.ts",
|
|
49
|
+
"store.ts",
|
|
50
|
+
"tool-execute.ts",
|
|
51
|
+
"tool-render.ts",
|
|
52
|
+
"types.ts",
|
|
53
|
+
"widget.ts",
|
|
54
|
+
"utils/agent-utils.ts",
|
|
55
|
+
"utils/short-label.ts",
|
|
56
|
+
"utils/string-utils.ts",
|
|
57
|
+
"utils/format-utils.ts",
|
|
58
|
+
"utils/time-utils.ts",
|
|
59
|
+
"seeds",
|
|
60
|
+
"README.md"
|
|
61
|
+
],
|
|
62
|
+
"pi": {
|
|
63
|
+
"extensions": [
|
|
64
|
+
"./index.ts"
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.98",
|
|
69
|
+
"yaml": "^2.9.0"
|
|
70
|
+
},
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"@earendil-works/pi-ai": "*",
|
|
73
|
+
"@earendil-works/pi-agent-core": "*",
|
|
74
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
75
|
+
"@earendil-works/pi-tui": "*",
|
|
76
|
+
"typebox": "*"
|
|
77
|
+
},
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"@earendil-works/pi-ai": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"@earendil-works/pi-agent-core": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
85
|
+
"@earendil-works/pi-coding-agent": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"@earendil-works/pi-tui": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
91
|
+
"typebox": {
|
|
92
|
+
"optional": true
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"publishConfig": {
|
|
96
|
+
"access": "public"
|
|
97
|
+
}
|
|
98
|
+
}
|
package/utils/agent-utils.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface AgentAliasMatch<T extends AgentConfigLike = AgentConfigLike> {
|
|
|
20
20
|
ambiguousAgents: T[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export const AGENT_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
23
|
+
export const AGENT_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
24
24
|
export type AgentThinkingLevel = (typeof AGENT_THINKING_LEVELS)[number];
|
|
25
25
|
|
|
26
26
|
// ── Constants ────────────────────────────────────────────────────────────────
|