@ryan_nookpi/pi-extension-subagent 0.3.1 → 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,
@@ -1434,6 +1435,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1434
1435
  resumeSessionId: runState.claudeSessionId,
1435
1436
  sidecarSessionFile: runState.sessionFile,
1436
1437
  persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1438
+ onDiagnostic: createRunDiagnosticSink(pi, runState),
1437
1439
  },
1438
1440
  ),
1439
1441
  );
@@ -1907,7 +1909,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1907
1909
  if (!controller) return false;
1908
1910
  run.lastLine = "Aborting by user...";
1909
1911
  run.lastOutput = run.lastLine;
1910
- controller.abort();
1912
+ controller.abort({ source: "sub_abort_command", runId: run.id });
1911
1913
  return true;
1912
1914
  };
1913
1915
 
@@ -2187,7 +2189,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
2187
2189
  if (run.status === "running" && shortcutController) {
2188
2190
  run.lastLine = "Aborting by user...";
2189
2191
  run.lastOutput = run.lastLine;
2190
- shortcutController.abort();
2192
+ shortcutController.abort({ source: "sub_abort_shortcut", runId: id });
2191
2193
  aborted++;
2192
2194
  } else if (run.status !== "running") {
2193
2195
  const result = removeRun(store, id, {
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/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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-subagent",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,7 @@
31
31
  "config.ts",
32
32
  "constants.ts",
33
33
  "context-limits.ts",
34
+ "diagnostics.ts",
34
35
  "display-task.ts",
35
36
  "escalation.ts",
36
37
  "format.ts",
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));