@ryan_nookpi/pi-extension-subagent 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -238,6 +238,28 @@ Confirm the environment used to start pi has valid Anthropic authentication, suc
238
238
 
239
239
  That is intentional. Hidden `>` runs are human-only UI jobs. Inspect them with `/sub:peek`, `<>runId`, or `/sub:open`.
240
240
 
241
+ ### Debug runner termination
242
+
243
+ The extension records process lifecycle diagnostics as `subagent-runner-diagnostic` custom entries in the parent session JSONL. They include run/batch IDs, parent and child PID/PGID, abort reason, internal kill cause, `exit`/`close` code and signal, settle reason, shutdown reason, and process-error stacks. Custom entries are not sent to the LLM and remain hidden unless an entry renderer is registered, as documented by pi's [`appendEntry` API](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md#piappendentrycustomtype-data).
244
+
245
+ Run `/session` to find the current session file, set `SESSION_FILE` to that path, then extract only the diagnostic entries:
246
+
247
+ ```bash
248
+ jq -c 'select(.type == "custom" and .customType == "subagent-runner-diagnostic") | {timestamp, data}' "$SESSION_FILE"
249
+ ```
250
+
251
+ For batch failures, compare entries by `batchId` and `runId`, then follow each run from `spawn` through `kill_intent`, `exit`, `close`, and `settled`. Share only the selected diagnostic lines—not the full session file, which may contain prompts or tool output.
252
+
253
+ To report a reproducible extension bug, create `subagent-issue.md` with the pi and extension versions, OS and Node version, launch mode/command, reproduction steps, expected and actual behavior, and the sanitized diagnostic lines. Then submit it with:
254
+
255
+ ```bash
256
+ gh issue create --repo Jonghakseo/pi-extension \
257
+ --title "subagent: unexpected child termination" \
258
+ --body-file subagent-issue.md
259
+ ```
260
+
261
+ Without GitHub CLI, use the repository's [new issue page](https://github.com/Jonghakseo/pi-extension/issues/new). Remove secrets and sensitive paths before attaching diagnostics.
262
+
241
263
  ## Security and trust boundary
242
264
 
243
265
  - Claude SDK execution uses `permissionMode: "bypassPermissions"` with `allowDangerouslySkipPermissions`; Claude CLI execution uses `--dangerously-skip-permissions`.
@@ -115,7 +115,7 @@ export async function runClaudeAgentViaSdk(
115
115
 
116
116
  const forwardAbort = () => {
117
117
  externalAbortRequested = true;
118
- abortController.abort();
118
+ abortController.abort(signal?.reason);
119
119
  };
120
120
 
121
121
  if (signal?.aborted) {
package/commands.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  SUBVIEW_OVERLAY_MAX_HEIGHT,
27
27
  SUBVIEW_OVERLAY_WIDTH,
28
28
  } from "./constants.js";
29
+ import { createRunDiagnosticSink } from "./diagnostics.js";
29
30
  import {
30
31
  buildSubagentDisplayTaskFallback,
31
32
  createDisplayTaskRefreshToken,
@@ -42,6 +43,7 @@ import {
42
43
  } from "./group-pending.js";
43
44
  import { enqueueSubagentInvocation } from "./invocation-queue.js";
44
45
  import { appendDisplayTaskUpdate, getSessionFileSize } from "./persisted-session.js";
46
+ import { SUBAGENT_COMMANDS, type SubagentCommandName } from "./registration-manifest.js";
45
47
  import { readSessionReplayItems, SubagentSessionReplayOverlay } from "./replay.js";
46
48
  import { invokeWithAutoRetry, MAX_SUBAGENT_AUTO_RETRIES } from "./retry.js";
47
49
  import { getLatestRun, removeRun, trimCommandRunHistory } from "./run-utils.js";
@@ -890,7 +892,18 @@ function finalizeHumanOnlyCompletion(
890
892
  store.globalLiveRuns.delete(runId);
891
893
  }
892
894
 
893
- export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
895
+ export type SubagentCommandDefinition = Parameters<ExtensionAPI["registerCommand"]>[1];
896
+
897
+ export interface SubagentRegistrations {
898
+ commands: ReadonlyMap<SubagentCommandName, SubagentCommandDefinition>;
899
+ }
900
+
901
+ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentRegistrations {
902
+ const commandDefinitions = new Map<SubagentCommandName, SubagentCommandDefinition>();
903
+ const defineCommand = (name: SubagentCommandName, definition: SubagentCommandDefinition): void => {
904
+ commandDefinitions.set(name, definition);
905
+ };
906
+
894
907
  pi.registerTool({
895
908
  name: "list-agents",
896
909
  label: "List Agents",
@@ -958,8 +971,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
958
971
  });
959
972
 
960
973
  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)",
974
+ description: SUBAGENT_COMMANDS["sub:isolate"],
963
975
  getArgumentCompletions: (argumentPrefix: string) => {
964
976
  const trimmedStart = argumentPrefix.trimStart();
965
977
  if (trimmedStart.includes(" ")) return null;
@@ -1423,6 +1435,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1423
1435
  resumeSessionId: runState.claudeSessionId,
1424
1436
  sidecarSessionFile: runState.sessionFile,
1425
1437
  persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1438
+ onDiagnostic: createRunDiagnosticSink(pi, runState),
1426
1439
  },
1427
1440
  ),
1428
1441
  );
@@ -1574,10 +1587,10 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1574
1587
  },
1575
1588
  };
1576
1589
 
1577
- pi.registerCommand("sub:isolate", subCommand);
1590
+ defineCommand("sub:isolate", subCommand);
1578
1591
 
1579
- pi.registerCommand("sub:main", {
1580
- description: "Run a subagent with main-session context inheritance: /sub:main <agent|alias> <task>",
1592
+ defineCommand("sub:main", {
1593
+ description: SUBAGENT_COMMANDS["sub:main"],
1581
1594
  getArgumentCompletions: subCommand.getArgumentCompletions,
1582
1595
  handler: async (args, ctx) => {
1583
1596
  captureSwitchSession(store, ctx);
@@ -1586,8 +1599,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1586
1599
  },
1587
1600
  });
1588
1601
 
1589
- pi.registerCommand("subagents", {
1590
- description: "List available subagents and offer the starter pack when none are configured",
1602
+ defineCommand("subagents", {
1603
+ description: SUBAGENT_COMMANDS.subagents,
1591
1604
  handler: async (_args, ctx) => {
1592
1605
  captureSwitchSession(store, ctx);
1593
1606
  const starterPack = await offerStarterPackIfEmpty(ctx);
@@ -1619,8 +1632,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1619
1632
  },
1620
1633
  });
1621
1634
 
1622
- pi.registerCommand("sub:peek", {
1623
- description: "Show the latest response from a subagent in an overlay: /sub:peek [runId]",
1635
+ defineCommand("sub:peek", {
1636
+ description: SUBAGENT_COMMANDS["sub:peek"],
1624
1637
  getArgumentCompletions: (argumentPrefix) => {
1625
1638
  const trimmedStart = argumentPrefix.trimStart();
1626
1639
  if (trimmedStart.includes(" ")) return null;
@@ -1643,8 +1656,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1643
1656
  },
1644
1657
  });
1645
1658
 
1646
- pi.registerCommand("sub:open", {
1647
- description: "Open a subagent session replay overlay: /sub:open [runId]",
1659
+ defineCommand("sub:open", {
1660
+ description: SUBAGENT_COMMANDS["sub:open"],
1648
1661
  getArgumentCompletions: (argumentPrefix) => {
1649
1662
  const trimmedStart = argumentPrefix.trimStart();
1650
1663
  if (trimmedStart.includes(" ")) return null;
@@ -1735,8 +1748,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1735
1748
  },
1736
1749
  });
1737
1750
 
1738
- pi.registerCommand("sub:history", {
1739
- description: "Show all subagent run history (including removed) in an overlay: /sub:history",
1751
+ defineCommand("sub:history", {
1752
+ description: SUBAGENT_COMMANDS["sub:history"],
1740
1753
  handler: async (_args, ctx) => {
1741
1754
  captureSwitchSession(store, ctx);
1742
1755
 
@@ -1787,8 +1800,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1787
1800
  },
1788
1801
  });
1789
1802
 
1790
- pi.registerCommand("sub:rm", {
1791
- description: "Remove one /sub job entry (aborts it if running): /sub:rm [runId]",
1803
+ defineCommand("sub:rm", {
1804
+ description: SUBAGENT_COMMANDS["sub:rm"],
1792
1805
  handler: async (args, ctx) => {
1793
1806
  captureSwitchSession(store, ctx);
1794
1807
  const raw = (args ?? "").trim();
@@ -1871,8 +1884,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1871
1884
  ctx.ui.notify(`Cleared ${removed} finished subagent job(s).`, "info");
1872
1885
  };
1873
1886
 
1874
- pi.registerCommand("sub:clear", {
1875
- description: "Clear /sub job widget entries. /sub:clear (finished only) or /sub:clear all",
1887
+ defineCommand("sub:clear", {
1888
+ description: SUBAGENT_COMMANDS["sub:clear"],
1876
1889
  handler: async (args, ctx) => {
1877
1890
  await handleSubClear(args, ctx);
1878
1891
  },
@@ -1896,7 +1909,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1896
1909
  if (!controller) return false;
1897
1910
  run.lastLine = "Aborting by user...";
1898
1911
  run.lastOutput = run.lastLine;
1899
- controller.abort();
1912
+ controller.abort({ source: "sub_abort_command", runId: run.id });
1900
1913
  return true;
1901
1914
  };
1902
1915
 
@@ -1947,23 +1960,16 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
1947
1960
  ctx.ui.notify("Usage: /sub:abort [runId|all]", "info");
1948
1961
  };
1949
1962
 
1950
- pi.registerCommand("sub:abort", {
1951
- description: "Abort running subagent job(s). /sub:abort [runId|all]",
1963
+ defineCommand("sub:abort", {
1964
+ description: SUBAGENT_COMMANDS["sub:abort"],
1952
1965
  handler: async (args, ctx) => {
1953
1966
  captureSwitchSession(store, ctx);
1954
1967
  await handleSubAbort(args, ctx);
1955
1968
  },
1956
1969
  });
1957
1970
 
1958
- // Only actual keyboard shortcuts appear in the /hotkeys Extensions section.
1959
- // NOTE: plain ">" is a real keybinding and hijacks editor text input,
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
- });
1971
+ // Text-prefix shortcuts are registered synchronously in index.ts for
1972
+ // /hotkeys visibility. Their actual routing remains in the input handlers below.
1967
1973
 
1968
1974
  pi.on("input", async (event, ctx) => {
1969
1975
  if (event.source === "extension") {
@@ -2073,13 +2079,6 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
2073
2079
  });
2074
2080
 
2075
2081
  // #<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
2082
  pi.on("input", async (event, ctx) => {
2084
2083
  if (event.source === "extension") {
2085
2084
  return { action: "continue" as const };
@@ -2126,20 +2125,6 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
2126
2125
  });
2127
2126
 
2128
2127
  // << 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
2128
  pi.on("input", async (event, ctx) => {
2144
2129
  if (event.source === "extension") {
2145
2130
  return { action: "continue" as const };
@@ -2204,7 +2189,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
2204
2189
  if (run.status === "running" && shortcutController) {
2205
2190
  run.lastLine = "Aborting by user...";
2206
2191
  run.lastOutput = run.lastLine;
2207
- shortcutController.abort();
2192
+ shortcutController.abort({ source: "sub_abort_shortcut", runId: id });
2208
2193
  aborted++;
2209
2194
  } else if (run.status !== "running") {
2210
2195
  const result = removeRun(store, id, {
@@ -2226,6 +2211,15 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
2226
2211
  ctx.ui.notify(parts.join(", ") || "Nothing to do.", parts.length ? (aborted ? "warning" : "info") : "info");
2227
2212
  return { action: "handled" as const };
2228
2213
  });
2214
+
2215
+ const missingCommands = (Object.keys(SUBAGENT_COMMANDS) as SubagentCommandName[]).filter(
2216
+ (name) => !commandDefinitions.has(name),
2217
+ );
2218
+ if (missingCommands.length > 0) {
2219
+ throw new Error(`Missing subagent command definitions: ${missingCommands.join(", ")}`);
2220
+ }
2221
+
2222
+ return { commands: commandDefinitions };
2229
2223
  }
2230
2224
 
2231
2225
  // ── onTerminalInput hack: auto-redirect <>7 to /sub:peek 7 ───────────
package/diagnostics.ts ADDED
@@ -0,0 +1,145 @@
1
+ import * as childProcess from "node:child_process";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentRuntime } from "./agents.js";
4
+ import type { CommandRunState } from "./types.js";
5
+
6
+ export const RUNNER_DIAGNOSTIC_CUSTOM_TYPE = "subagent-runner-diagnostic";
7
+ export const RUNNER_DIAGNOSTIC_SCHEMA_VERSION = 1;
8
+
9
+ export type RunnerDiagnosticEventName =
10
+ | "spawn"
11
+ | "abort_received"
12
+ | "kill_intent"
13
+ | "kill_result"
14
+ | "process_error"
15
+ | "exit"
16
+ | "close"
17
+ | "settled"
18
+ | "session_shutdown";
19
+
20
+ export interface RunnerDiagnosticEvent {
21
+ event: RunnerDiagnosticEventName;
22
+ runtime?: AgentRuntime;
23
+ parentPid?: number;
24
+ parentProcessGroupId?: number;
25
+ childPid?: number;
26
+ childParentPid?: number;
27
+ childProcessGroupId?: number;
28
+ processGroupMode?: "inherited";
29
+ code?: number | null;
30
+ signal?: NodeJS.Signals | null;
31
+ cause?: string;
32
+ abortReason?: unknown;
33
+ killSent?: boolean;
34
+ settleReason?: string;
35
+ stopReason?: string;
36
+ activeRunIds?: number[];
37
+ sessionShutdownReason?: string;
38
+ error?: DiagnosticError;
39
+ }
40
+
41
+ export type RunnerDiagnosticSink = (event: RunnerDiagnosticEvent) => void;
42
+
43
+ interface DiagnosticError {
44
+ name?: string;
45
+ message: string;
46
+ stack?: string;
47
+ }
48
+
49
+ interface ProcessIdentity {
50
+ pid: number;
51
+ parentPid?: number;
52
+ processGroupId?: number;
53
+ }
54
+
55
+ function asFiniteInteger(value: string | undefined): number | undefined {
56
+ if (!value) return undefined;
57
+ const parsed = Number(value);
58
+ return Number.isInteger(parsed) ? parsed : undefined;
59
+ }
60
+
61
+ /** Best-effort POSIX process identity lookup. Diagnostics must never affect execution. */
62
+ export function readProcessIdentity(pid: number | undefined): ProcessIdentity | undefined {
63
+ if (!pid || !Number.isInteger(pid) || pid <= 0) return undefined;
64
+ if (process.platform === "win32") return { pid };
65
+
66
+ try {
67
+ const result = childProcess.spawnSync("ps", ["-o", "pid=,ppid=,pgid=", "-p", String(pid)], {
68
+ encoding: "utf8",
69
+ stdio: ["ignore", "pipe", "ignore"],
70
+ });
71
+ const fields = result.stdout?.trim().split(/\s+/);
72
+ if (!fields || fields.length < 3) return { pid };
73
+ return {
74
+ pid: asFiniteInteger(fields[0]) ?? pid,
75
+ parentPid: asFiniteInteger(fields[1]),
76
+ processGroupId: asFiniteInteger(fields[2]),
77
+ };
78
+ } catch {
79
+ return { pid };
80
+ }
81
+ }
82
+
83
+ export function serializeDiagnosticValue(value: unknown): unknown {
84
+ if (value instanceof Error) {
85
+ return {
86
+ name: value.name,
87
+ message: value.message,
88
+ stack: value.stack,
89
+ };
90
+ }
91
+ if (value === undefined || value === null) return value;
92
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
93
+
94
+ try {
95
+ return JSON.parse(JSON.stringify(value));
96
+ } catch {
97
+ return String(value);
98
+ }
99
+ }
100
+
101
+ export function toDiagnosticError(error: unknown): DiagnosticError {
102
+ if (error instanceof Error) {
103
+ return { name: error.name, message: error.message, stack: error.stack };
104
+ }
105
+ return { message: String(error) };
106
+ }
107
+
108
+ /** Emit best-effort diagnostics without allowing logging failures to affect a run. */
109
+ export function emitRunnerDiagnostic(sink: RunnerDiagnosticSink | undefined, event: RunnerDiagnosticEvent): void {
110
+ if (!sink) return;
111
+ try {
112
+ sink(event);
113
+ } catch {
114
+ // Diagnostics must never alter subagent execution or completion delivery.
115
+ }
116
+ }
117
+
118
+ export function appendRunnerDiagnostic(pi: Pick<ExtensionAPI, "appendEntry">, data: Record<string, unknown>): void {
119
+ try {
120
+ pi.appendEntry(RUNNER_DIAGNOSTIC_CUSTOM_TYPE, {
121
+ schemaVersion: RUNNER_DIAGNOSTIC_SCHEMA_VERSION,
122
+ recordedAt: new Date().toISOString(),
123
+ ...data,
124
+ });
125
+ } catch {
126
+ // Session teardown may invalidate the old extension runtime.
127
+ }
128
+ }
129
+
130
+ export function createRunDiagnosticSink(
131
+ pi: Pick<ExtensionAPI, "appendEntry">,
132
+ runState: CommandRunState,
133
+ ): RunnerDiagnosticSink {
134
+ return (event) => {
135
+ appendRunnerDiagnostic(pi, {
136
+ runId: runState.id,
137
+ agent: runState.agent,
138
+ batchId: runState.batchId,
139
+ pipelineId: runState.pipelineId,
140
+ pipelineStepIndex: runState.pipelineStepIndex,
141
+ ...event,
142
+ abortReason: serializeDiagnosticValue(event.abortReason),
143
+ });
144
+ };
145
+ }
package/index.ts CHANGED
@@ -29,10 +29,12 @@
29
29
 
30
30
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
31
31
  import { HANG_CHECK_INTERVAL_MS } from "./constants.js";
32
+ import { SUBAGENT_COMMANDS, SUBAGENT_SHORTCUTS, type SubagentCommandName } from "./registration-manifest.js";
32
33
 
33
34
  interface SubagentCore {
34
35
  store: import("./store.js").SubagentStore;
35
36
  commands: typeof import("./commands.js");
37
+ registrations: import("./commands.js").SubagentRegistrations;
36
38
  escalation: typeof import("./escalation.js");
37
39
  lifecycle: typeof import("./lifecycle.js");
38
40
  }
@@ -52,10 +54,13 @@ export default function (pi: ExtensionAPI) {
52
54
  import("./store.js"),
53
55
  ]);
54
56
  const store = storeMod.createStore();
55
- commands.registerAll(pi, store);
56
- core = { store, commands, escalation, lifecycle };
57
+ const registrations = commands.registerAll(pi, store);
58
+ core = { store, commands, registrations, escalation, lifecycle };
57
59
  return core;
58
- })();
60
+ })().catch((error) => {
61
+ corePromise = null;
62
+ throw error;
63
+ });
59
64
  return corePromise;
60
65
  };
61
66
 
@@ -69,6 +74,39 @@ export default function (pi: ExtensionAPI) {
69
74
  return chain;
70
75
  };
71
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
+ });
99
+ }
100
+
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
+ });
108
+ }
109
+
72
110
  // Start loading in the background without blocking extension boot.
73
111
  // setTimeout keeps the load out of the boot path's microtask drains.
74
112
  setTimeout(() => {
package/lifecycle.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { HANG_TIMEOUT_MS } from "./constants.js";
10
+ import { appendRunnerDiagnostic } from "./diagnostics.js";
10
11
  import { getSessionFileMtimeMs, readPersistedSessionSnapshot } from "./persisted-session.js";
11
12
  import { getLastNonEmptyLine } from "./runner.js";
12
13
  import type { SubagentStore } from "./store.js";
@@ -59,6 +60,16 @@ export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, rea
59
60
  for (const [runId, run] of store.commandRuns) activeRuns.set(runId, run);
60
61
  for (const [runId, entry] of store.globalLiveRuns) activeRuns.set(runId, entry.runState);
61
62
 
63
+ const activeRunIds = Array.from(activeRuns)
64
+ .filter(([, run]) => run.status === "running")
65
+ .map(([runId]) => runId);
66
+ appendRunnerDiagnostic(pi, {
67
+ event: "session_shutdown",
68
+ parentPid: process.pid,
69
+ sessionShutdownReason: reason,
70
+ activeRunIds,
71
+ });
72
+
62
73
  for (const [runId, run] of activeRuns) {
63
74
  if (run.status !== "running") continue;
64
75
  const message = `Aborted because the parent pi session ${reason} is shutting down.`;
@@ -70,7 +81,7 @@ export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, rea
70
81
  run.removed = true;
71
82
 
72
83
  const controller = run.abortController ?? store.globalLiveRuns.get(runId)?.abortController;
73
- controller?.abort();
84
+ controller?.abort({ source: "session_shutdown", reason, runId, activeRunIds });
74
85
  run.abortController = undefined;
75
86
 
76
87
  if (run.deliveryMode === "humanOnly") continue;
@@ -141,7 +152,7 @@ export function checkForHungRuns(store: SubagentStore, _pi: ExtensionAPI): void
141
152
  run.autoAbortReason = reason;
142
153
 
143
154
  if (controller) {
144
- controller.abort();
155
+ controller.abort({ source: "hang_timeout", runId, idleMs, reason });
145
156
  }
146
157
  }
147
158
 
package/package.json CHANGED
@@ -1,98 +1,100 @@
1
1
  {
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
- }
2
+ "name": "@ryan_nookpi/pi-extension-subagent",
3
+ "version": "0.3.2",
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
+ "diagnostics.ts",
35
+ "display-task.ts",
36
+ "escalation.ts",
37
+ "format.ts",
38
+ "group-pending.ts",
39
+ "index.ts",
40
+ "lifecycle.ts",
41
+ "invocation-queue.ts",
42
+ "live-preview.ts",
43
+ "persisted-session.ts",
44
+ "replay.ts",
45
+ "registration-manifest.ts",
46
+ "retry.ts",
47
+ "run-utils.ts",
48
+ "runner.ts",
49
+ "session.ts",
50
+ "starter-pack.ts",
51
+ "store.ts",
52
+ "tool-execute.ts",
53
+ "tool-render.ts",
54
+ "types.ts",
55
+ "widget.ts",
56
+ "utils/agent-utils.ts",
57
+ "utils/short-label.ts",
58
+ "utils/string-utils.ts",
59
+ "utils/format-utils.ts",
60
+ "utils/time-utils.ts",
61
+ "seeds",
62
+ "README.md"
63
+ ],
64
+ "pi": {
65
+ "extensions": [
66
+ "./index.ts"
67
+ ]
68
+ },
69
+ "dependencies": {
70
+ "@anthropic-ai/claude-agent-sdk": "^0.2.98",
71
+ "yaml": "^2.9.0"
72
+ },
73
+ "peerDependencies": {
74
+ "@earendil-works/pi-ai": "*",
75
+ "@earendil-works/pi-agent-core": "*",
76
+ "@earendil-works/pi-coding-agent": "*",
77
+ "@earendil-works/pi-tui": "*",
78
+ "typebox": "*"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "@earendil-works/pi-ai": {
82
+ "optional": true
83
+ },
84
+ "@earendil-works/pi-agent-core": {
85
+ "optional": true
86
+ },
87
+ "@earendil-works/pi-coding-agent": {
88
+ "optional": true
89
+ },
90
+ "@earendil-works/pi-tui": {
91
+ "optional": true
92
+ },
93
+ "typebox": {
94
+ "optional": true
95
+ }
96
+ },
97
+ "publishConfig": {
98
+ "access": "public"
99
+ }
100
+ }
@@ -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;
package/run-utils.ts CHANGED
@@ -92,7 +92,7 @@ export function removeRun(store: SubagentStore, runId: number, options: RemoveRu
92
92
  const reason = options.reason ?? "Aborting by remove...";
93
93
  run.lastLine = reason;
94
94
  run.lastOutput = reason;
95
- controller.abort();
95
+ controller.abort({ source: "remove_run", runId, reason, removalReason: options.removalReason });
96
96
  aborted = true;
97
97
  }
98
98
 
package/runner.ts CHANGED
@@ -17,6 +17,13 @@ import {
17
17
  } from "./claude-stream-parser.js";
18
18
  import { resolveClaudeRuntimeMode } from "./config.js";
19
19
  import { CONTEXT_GUARD_SIGNATURE, resolveContextGuardCeiling, shouldTripContextGuard } from "./context-limits.js";
20
+ import {
21
+ emitRunnerDiagnostic,
22
+ type RunnerDiagnosticSink,
23
+ readProcessIdentity,
24
+ serializeDiagnosticValue,
25
+ toDiagnosticError,
26
+ } from "./diagnostics.js";
20
27
  import { formatToolCallPlain } from "./format.js";
21
28
  import {
22
29
  extractActivityPreviewFromTextDelta,
@@ -39,6 +46,7 @@ export interface RunSingleAgentSessionConfig {
39
46
  resumeSessionId?: string;
40
47
  sidecarSessionFile?: string;
41
48
  persistedSessionBaseOffset?: number;
49
+ onDiagnostic?: RunnerDiagnosticSink;
42
50
  }
43
51
 
44
52
  function isUuidLikeSessionId(value: string | undefined): boolean {
@@ -199,6 +207,7 @@ export async function runSingleAgent(
199
207
  const resumeSessionId = normalizedSessionConfig.resumeSessionId;
200
208
  const sidecarSessionFile = normalizedSessionConfig.sidecarSessionFile ?? sessionFile;
201
209
  const persistedSessionBaseOffset = normalizedSessionConfig.persistedSessionBaseOffset ?? 0;
210
+ const onDiagnostic = normalizedSessionConfig.onDiagnostic;
202
211
  const agent = agents.find((a) => a.name === agentName);
203
212
 
204
213
  if (!agent) {
@@ -240,6 +249,7 @@ export async function runSingleAgent(
240
249
  makeDetails,
241
250
  resumeSessionId,
242
251
  sidecarSessionFile,
252
+ onDiagnostic,
243
253
  );
244
254
  }
245
255
 
@@ -254,6 +264,7 @@ export async function runSingleAgent(
254
264
  makeDetails,
255
265
  sessionFile,
256
266
  persistedSessionBaseOffset,
267
+ onDiagnostic,
257
268
  );
258
269
  }
259
270
 
@@ -267,6 +278,7 @@ async function runClaudeAgent(
267
278
  makeDetails: (results: SingleResult[]) => SubagentDetails,
268
279
  resumeSessionId?: string,
269
280
  sidecarSessionFile?: string,
281
+ onDiagnostic?: RunnerDiagnosticSink,
270
282
  ): Promise<SingleResult> {
271
283
  try {
272
284
  validateClaudeRuntimeModel(agent.model);
@@ -315,6 +327,10 @@ async function runClaudeAgent(
315
327
  let completionMarkerWritten = false;
316
328
  let wasAborted = false;
317
329
 
330
+ const diagnose = (event: Parameters<RunnerDiagnosticSink>[0]) => {
331
+ emitRunnerDiagnostic(onDiagnostic, { runtime: "claude", ...event });
332
+ };
333
+
318
334
  const emitUpdate = () => {
319
335
  if (!onUpdate) return;
320
336
  const text = streamState.liveText ?? getFinalOutput(streamState.messages) ?? "(running...)";
@@ -362,9 +378,57 @@ async function runClaudeAgent(
362
378
  let exitFallbackTimer: ReturnType<typeof setTimeout> | undefined;
363
379
  let resultLingerTimer: ReturnType<typeof setTimeout> | undefined;
364
380
  let lastExitCode = 0;
381
+ let lastProcessSignal: NodeJS.Signals | null = null;
365
382
  let settleReason = "unknown";
366
383
  let unparsedStdoutCount = 0;
367
384
  const unparsedStdoutTail: string[] = [];
385
+ const parentIdentity = onDiagnostic ? readProcessIdentity(process.pid) : undefined;
386
+ const childIdentity = onDiagnostic ? readProcessIdentity(proc.pid) : undefined;
387
+
388
+ diagnose({
389
+ event: "spawn",
390
+ parentPid: process.pid,
391
+ parentProcessGroupId: parentIdentity?.processGroupId,
392
+ childPid: proc.pid,
393
+ childParentPid: childIdentity?.parentPid,
394
+ childProcessGroupId: childIdentity?.processGroupId,
395
+ processGroupMode: "inherited",
396
+ });
397
+
398
+ const terminateProcess = (terminationSignal: NodeJS.Signals, cause: string): boolean => {
399
+ diagnose({
400
+ event: "kill_intent",
401
+ childPid: proc.pid,
402
+ signal: terminationSignal,
403
+ cause,
404
+ stopReason: streamState.stopReason,
405
+ });
406
+ try {
407
+ const killSent = proc.kill(terminationSignal);
408
+ diagnose({
409
+ event: "kill_result",
410
+ childPid: proc.pid,
411
+ signal: terminationSignal,
412
+ cause,
413
+ killSent,
414
+ });
415
+ return killSent;
416
+ } catch (error) {
417
+ diagnose({
418
+ event: "process_error",
419
+ childPid: proc.pid,
420
+ cause: `kill:${cause}`,
421
+ error: toDiagnosticError(error),
422
+ });
423
+ return false;
424
+ }
425
+ };
426
+
427
+ const scheduleKillEscalation = (cause: string) => {
428
+ setTimeout(() => {
429
+ if (!procExited && proc.exitCode === null) terminateProcess("SIGKILL", `${cause}:grace_timeout`);
430
+ }, 5000);
431
+ };
368
432
 
369
433
  const processLine = (line: string) => {
370
434
  if (!line.trim()) return;
@@ -440,6 +504,14 @@ async function runClaudeAgent(
440
504
  stderrBuf += `[runner] ${tail}\n`;
441
505
  }
442
506
  }
507
+ diagnose({
508
+ event: "settled",
509
+ childPid: proc.pid,
510
+ code: finalCode,
511
+ signal: lastProcessSignal,
512
+ settleReason,
513
+ stopReason: wasAborted ? "aborted" : streamState.stopReason,
514
+ });
443
515
  resolve(finalCode);
444
516
  };
445
517
 
@@ -452,10 +524,8 @@ async function runClaudeAgent(
452
524
  if (resultLingerTimer) clearTimeout(resultLingerTimer);
453
525
  resultLingerTimer = setTimeout(() => {
454
526
  if (settled || procExited) return;
455
- proc.kill("SIGTERM");
456
- setTimeout(() => {
457
- if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
458
- }, 5000);
527
+ terminateProcess("SIGTERM", "result_linger_timeout");
528
+ scheduleKillEscalation("result_linger_timeout");
459
529
  settleReason = "result_linger_timeout";
460
530
  resolveOnce(0);
461
531
  }, 3000);
@@ -472,9 +542,11 @@ async function runClaudeAgent(
472
542
  stderrBuf += data.toString();
473
543
  });
474
544
 
475
- proc.on("exit", (code) => {
545
+ proc.on("exit", (code, terminationSignal) => {
476
546
  procExited = true;
477
547
  lastExitCode = code ?? 0;
548
+ lastProcessSignal = terminationSignal;
549
+ diagnose({ event: "exit", childPid: proc.pid, code, signal: terminationSignal });
478
550
  if (streamState.resultReceived) {
479
551
  settleReason = "exit_after_result";
480
552
  resolveOnce(0);
@@ -486,8 +558,10 @@ async function runClaudeAgent(
486
558
  }, 1500);
487
559
  });
488
560
 
489
- proc.on("close", (code) => {
561
+ proc.on("close", (code, terminationSignal) => {
490
562
  procExited = true;
563
+ lastProcessSignal = terminationSignal ?? lastProcessSignal;
564
+ diagnose({ event: "close", childPid: proc.pid, code, signal: terminationSignal });
491
565
  if (!settled) {
492
566
  settleReason = "close";
493
567
  resolveOnce(streamState.resultReceived ? 0 : (code ?? lastExitCode ?? 0));
@@ -497,6 +571,12 @@ async function runClaudeAgent(
497
571
  proc.on("error", (error) => {
498
572
  procExited = true;
499
573
  stderrBuf += `[runner] process error: ${error?.message || String(error)}\n`;
574
+ diagnose({
575
+ event: "process_error",
576
+ childPid: proc.pid,
577
+ cause: "child_process_event",
578
+ error: toDiagnosticError(error),
579
+ });
500
580
  settleReason = "process_error";
501
581
  resolveOnce(1);
502
582
  });
@@ -505,10 +585,13 @@ async function runClaudeAgent(
505
585
  const killProc = () => {
506
586
  wasAborted = true;
507
587
  settleReason = "aborted_by_signal";
508
- proc.kill("SIGTERM");
509
- setTimeout(() => {
510
- if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
511
- }, 5000);
588
+ diagnose({
589
+ event: "abort_received",
590
+ childPid: proc.pid,
591
+ abortReason: serializeDiagnosticValue(signal.reason),
592
+ });
593
+ terminateProcess("SIGTERM", "abort_signal");
594
+ scheduleKillEscalation("abort_signal");
512
595
  };
513
596
  if (signal.aborted) killProc();
514
597
  else signal.addEventListener("abort", killProc, { once: true });
@@ -549,6 +632,7 @@ async function runPiAgent(
549
632
  makeDetails: (results: SingleResult[]) => SubagentDetails,
550
633
  sessionFile?: string,
551
634
  persistedSessionBaseOffset = 0,
635
+ onDiagnostic?: RunnerDiagnosticSink,
552
636
  ): Promise<SingleResult> {
553
637
  const args: string[] = ["--mode", "json", "-p"];
554
638
  if (sessionFile) args.push("--session", sessionFile);
@@ -578,6 +662,10 @@ async function runPiAgent(
578
662
  let contextGuardTripped = false;
579
663
  let peakContextTokens = 0;
580
664
 
665
+ const diagnose = (event: Parameters<RunnerDiagnosticSink>[0]) => {
666
+ emitRunnerDiagnostic(onDiagnostic, { runtime: "pi", ...event });
667
+ };
668
+
581
669
  const emitUpdate = () => {
582
670
  if (onUpdate) {
583
671
  onUpdate({
@@ -617,11 +705,59 @@ async function runPiAgent(
617
705
  let terminalMessageFallbackTimer: ReturnType<typeof setTimeout> | undefined;
618
706
  let sessionPollTimer: ReturnType<typeof setInterval> | undefined;
619
707
  let lastExitCode = 0;
708
+ let lastProcessSignal: NodeJS.Signals | null = null;
620
709
  let lastEventAt = Date.now();
621
710
  let sawAgentEnd = false;
622
711
  let settleReason = "unknown";
623
712
  let unparsedStdoutCount = 0;
624
713
  const unparsedStdoutTail: string[] = [];
714
+ const parentIdentity = onDiagnostic ? readProcessIdentity(process.pid) : undefined;
715
+ const childIdentity = onDiagnostic ? readProcessIdentity(proc.pid) : undefined;
716
+
717
+ diagnose({
718
+ event: "spawn",
719
+ parentPid: process.pid,
720
+ parentProcessGroupId: parentIdentity?.processGroupId,
721
+ childPid: proc.pid,
722
+ childParentPid: childIdentity?.parentPid,
723
+ childProcessGroupId: childIdentity?.processGroupId,
724
+ processGroupMode: "inherited",
725
+ });
726
+
727
+ const terminateProcess = (terminationSignal: NodeJS.Signals, cause: string): boolean => {
728
+ diagnose({
729
+ event: "kill_intent",
730
+ childPid: proc.pid,
731
+ signal: terminationSignal,
732
+ cause,
733
+ stopReason: currentResult.stopReason,
734
+ });
735
+ try {
736
+ const killSent = proc.kill(terminationSignal);
737
+ diagnose({
738
+ event: "kill_result",
739
+ childPid: proc.pid,
740
+ signal: terminationSignal,
741
+ cause,
742
+ killSent,
743
+ });
744
+ return killSent;
745
+ } catch (error) {
746
+ diagnose({
747
+ event: "process_error",
748
+ childPid: proc.pid,
749
+ cause: `kill:${cause}`,
750
+ error: toDiagnosticError(error),
751
+ });
752
+ return false;
753
+ }
754
+ };
755
+
756
+ const scheduleKillEscalation = (cause: string) => {
757
+ setTimeout(() => {
758
+ if (!procExited && proc.exitCode === null) terminateProcess("SIGKILL", `${cause}:grace_timeout`);
759
+ }, 5000);
760
+ };
625
761
 
626
762
  const syncFromPersistedSession = (allowResolve: boolean): boolean => {
627
763
  if (!sessionFile) return false;
@@ -640,11 +776,12 @@ async function runPiAgent(
640
776
  snapshot.completionMarker?.exitCode ??
641
777
  (snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted" ? 1 : 0);
642
778
  writeCompletionMarkerOnce(forcedCode);
643
- proc.kill("SIGTERM");
644
- setTimeout(() => {
645
- if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
646
- }, 5000);
647
- settleReason = snapshot.completionMarker ? "session_done_marker_fallback" : "session_terminal_message_fallback";
779
+ const fallbackReason = snapshot.completionMarker
780
+ ? "session_done_marker_fallback"
781
+ : "session_terminal_message_fallback";
782
+ terminateProcess("SIGTERM", fallbackReason);
783
+ scheduleKillEscalation(fallbackReason);
784
+ settleReason = fallbackReason;
648
785
  resolveOnce(forcedCode);
649
786
  return true;
650
787
  };
@@ -776,10 +913,8 @@ async function runPiAgent(
776
913
  currentResult.errorMessage =
777
914
  `${CONTEXT_GUARD_SIGNATURE} stopped at ${peakContextTokens} tokens ` +
778
915
  `(ceiling ${contextGuardCeiling}) to preserve partial findings before provider context overflow.`;
779
- proc.kill("SIGTERM");
780
- setTimeout(() => {
781
- if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
782
- }, 5000);
916
+ terminateProcess("SIGTERM", "context_guard");
917
+ scheduleKillEscalation("context_guard");
783
918
  settleReason = "context_guard";
784
919
  resolveOnce(1);
785
920
  return;
@@ -858,6 +993,14 @@ async function runPiAgent(
858
993
  );
859
994
  }
860
995
  }
996
+ diagnose({
997
+ event: "settled",
998
+ childPid: proc.pid,
999
+ code: finalCode,
1000
+ signal: lastProcessSignal,
1001
+ settleReason,
1002
+ stopReason: wasAborted ? "aborted" : currentResult.stopReason,
1003
+ });
861
1004
  resolve(finalCode);
862
1005
  };
863
1006
 
@@ -870,10 +1013,8 @@ async function runPiAgent(
870
1013
 
871
1014
  const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;
872
1015
 
873
- proc.kill("SIGTERM");
874
- setTimeout(() => {
875
- if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
876
- }, 5000);
1016
+ terminateProcess("SIGTERM", "terminal_message_fallback_timeout");
1017
+ scheduleKillEscalation("terminal_message_fallback_timeout");
877
1018
 
878
1019
  settleReason = "terminal_message_fallback_timeout";
879
1020
  resolveOnce(forcedCode);
@@ -894,10 +1035,8 @@ async function runPiAgent(
894
1035
 
895
1036
  const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;
896
1037
 
897
- proc.kill("SIGTERM");
898
- setTimeout(() => {
899
- if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
900
- }, 5000);
1038
+ terminateProcess("SIGTERM", "agent_end_fallback_timeout");
1039
+ scheduleKillEscalation("agent_end_fallback_timeout");
901
1040
 
902
1041
  settleReason = "agent_end_fallback_timeout";
903
1042
  resolveOnce(forcedCode);
@@ -921,9 +1060,11 @@ async function runPiAgent(
921
1060
  currentResult.stderr += data.toString();
922
1061
  });
923
1062
 
924
- proc.on("exit", (code) => {
1063
+ proc.on("exit", (code, terminationSignal) => {
925
1064
  procExited = true;
926
1065
  lastExitCode = code ?? 0;
1066
+ lastProcessSignal = terminationSignal;
1067
+ diagnose({ event: "exit", childPid: proc.pid, code, signal: terminationSignal });
927
1068
  // In rare cases stdout/stderr pipes may stay open after process exit.
928
1069
  // Use a short fallback so runs cannot stay "running" forever.
929
1070
  exitFallbackTimer = setTimeout(() => {
@@ -932,8 +1073,10 @@ async function runPiAgent(
932
1073
  }, 1500);
933
1074
  });
934
1075
 
935
- proc.on("close", (code) => {
1076
+ proc.on("close", (code, terminationSignal) => {
936
1077
  procExited = true;
1078
+ lastProcessSignal = terminationSignal ?? lastProcessSignal;
1079
+ diagnose({ event: "close", childPid: proc.pid, code, signal: terminationSignal });
937
1080
  settleReason = "close";
938
1081
  resolveOnce(code ?? lastExitCode ?? 0);
939
1082
  });
@@ -941,6 +1084,12 @@ async function runPiAgent(
941
1084
  proc.on("error", (error) => {
942
1085
  procExited = true;
943
1086
  appendStderrDiagnostic(currentResult, `process error: ${error?.message || String(error)}`);
1087
+ diagnose({
1088
+ event: "process_error",
1089
+ childPid: proc.pid,
1090
+ cause: "child_process_event",
1091
+ error: toDiagnosticError(error),
1092
+ });
944
1093
  settleReason = "process_error";
945
1094
  resolveOnce(1);
946
1095
  });
@@ -949,10 +1098,13 @@ async function runPiAgent(
949
1098
  const killProc = () => {
950
1099
  wasAborted = true;
951
1100
  settleReason = "aborted_by_signal";
952
- proc.kill("SIGTERM");
953
- setTimeout(() => {
954
- if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
955
- }, 5000);
1101
+ diagnose({
1102
+ event: "abort_received",
1103
+ childPid: proc.pid,
1104
+ abortReason: serializeDiagnosticValue(signal.reason),
1105
+ });
1106
+ terminateProcess("SIGTERM", "abort_signal");
1107
+ scheduleKillEscalation("abort_signal");
956
1108
  };
957
1109
  if (signal.aborted) killProc();
958
1110
  else signal.addEventListener("abort", killProc, { once: true });
package/tool-execute.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  SUBAGENT_STRONG_WAIT_MESSAGE,
23
23
  } from "./constants.js";
24
24
  import { isContextOverflowText } from "./context-limits.js";
25
+ import { createRunDiagnosticSink } from "./diagnostics.js";
25
26
  import {
26
27
  buildSubagentDisplayTaskFallback,
27
28
  createDisplayTaskRefreshToken,
@@ -956,7 +957,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
956
957
 
957
958
  run.lastLine = "Aborting by subagent tool...";
958
959
  run.lastOutput = run.lastLine;
959
- abortCtrl.abort();
960
+ abortCtrl.abort({ source: "subagent_tool_abort", runId });
960
961
  aborting.push(runId);
961
962
  }
962
963
 
@@ -1080,7 +1081,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1080
1081
  clearParentAbortListener(runId);
1081
1082
  if (!signal || shouldRunAsync) return;
1082
1083
 
1083
- const abortFromParent = () => abortController.abort();
1084
+ const abortFromParent = () => abortController.abort({ source: "parent_tool_abort", runId });
1084
1085
  if (signal.aborted) {
1085
1086
  abortFromParent();
1086
1087
  return;
@@ -1201,6 +1202,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1201
1202
  resumeSessionId: runState.claudeSessionId,
1202
1203
  sidecarSessionFile: runState.sessionFile,
1203
1204
  persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1205
+ onDiagnostic: createRunDiagnosticSink(pi, runState),
1204
1206
  },
1205
1207
  ),
1206
1208
  ).then((result) => finalizeRunState(runState, result));