@hellcoder/companion 0.110.1-preview.20260726074426.b9cd62b → 0.110.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellcoder/companion",
3
- "version": "0.110.1-preview.20260726074426.b9cd62b",
3
+ "version": "0.110.1",
4
4
  "type": "module",
5
5
  "description": "Web UI for launching and interacting with Claude Code agents — Moritz Edition (fork of the-companion)",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@ import type {
46
46
  import type { SocketData } from "./ws-bridge-types.js";
47
47
  import type { PendingControlRequest } from "./ws-bridge-types.js";
48
48
  import type { RecorderManager } from "./recorder.js";
49
- import { captureProcState } from "./proc-diagnostics.js";
49
+ import { captureProcState, hasLiveDescendants } from "./proc-diagnostics.js";
50
50
  import { parseNDJSON, isDuplicateCLIMessage } from "./ws-bridge-cli-ingest.js";
51
51
  import type { CLIDedupState } from "./ws-bridge-cli-ingest.js";
52
52
  import { reportProtocolDrift } from "./protocol-monitor.js";
@@ -75,6 +75,15 @@ const STDOUT_CLOSE_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_GRACE_MS
75
75
  */
76
76
  const STDOUT_CLOSE_RESULT_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_RESULT_GRACE_MS) || 10000;
77
77
 
78
+ /**
79
+ * How long to wait after SIGTERM before escalating to SIGKILL.
80
+ *
81
+ * A CLI that ignores SIGTERM because it is itself blocked reaping children used
82
+ * to be left behind by the old flat kill, so `handleAutoRelaunch`'s PID-liveness
83
+ * guard could still see it alive. Escalating guarantees the pid is gone.
84
+ */
85
+ const SIGKILL_ESCALATION_MS = Number(process.env.COMPANION_SIGKILL_ESCALATION_MS) || 2000;
86
+
78
87
  // --- Claude Code Adapter ------------------------------------------------------
79
88
 
80
89
  export class ClaudeAdapter implements IBackendAdapter {
@@ -322,7 +331,23 @@ export class ClaudeAdapter implements IBackendAdapter {
322
331
  // is STILL alive afterwards, so a true wedge can't block recovery.
323
332
  const proc = this.stdioProc;
324
333
  if (proc && proc.exitCode === null && !proc.killed) {
325
- if (this.lastInboundWasResult) {
334
+ // Which grace applies is decided by whether teardown is actually in
335
+ // progress, NOT by whether the last message happened to be a `result`.
336
+ //
337
+ // Every CLI spawns 2-3 MCP stdio servers, and npm-exec-wrapped servers
338
+ // plus headless chromium do not reap inside the short grace. The old
339
+ // `lastInboundWasResult` discriminator was a proxy for "clean shutdown"
340
+ // that has nothing to do with teardown cost, so a healthy CLI landed on
341
+ // the short grace two ways: any non-result/keep_alive/system message
342
+ // trailing the turn, or a fresh adapter (the flag initialises false).
343
+ // It was then SIGTERMed mid-teardown, exiting 143 and taking the user's
344
+ // turn with it.
345
+ //
346
+ // Live descendants are the direct signal: a genuine wedge is idle with
347
+ // no children, whereas a CLI still reaping MCP servers has children
348
+ // mid-exit.
349
+ const teardownInProgress = hasLiveDescendants(proc.pid);
350
+ if (this.lastInboundWasResult || teardownInProgress) {
326
351
  // Clean end-of-turn shutdown: let the process flush teardown (MCP
327
352
  // servers, etc.) and exit code-0 instead of SIGTERM-ing a code-0 exit.
328
353
  // Use a generous grace so we don't clobber a slow-but-clean exit, but
@@ -340,13 +365,10 @@ export class ClaudeAdapter implements IBackendAdapter {
340
365
  sessionId: this.sessionId,
341
366
  pid: proc.pid,
342
367
  graceMs: STDOUT_CLOSE_RESULT_GRACE_MS,
368
+ graceReason: this.lastInboundWasResult ? "result" : "descendants_alive",
343
369
  proc: captureProcState(proc.pid),
344
370
  });
345
- try {
346
- proc.kill();
347
- } catch {
348
- // Process may have exited between the check and the kill.
349
- }
371
+ await this.killWithEscalation(proc);
350
372
  }
351
373
  } else {
352
374
  const exitedOnOwn = await Promise.race([
@@ -354,21 +376,17 @@ export class ClaudeAdapter implements IBackendAdapter {
354
376
  new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_GRACE_MS)),
355
377
  ]);
356
378
  if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
357
- // Same rationale as the after-result branch above: snapshot kernel
358
- // state before the kill destroys it. This is the dominant wedge
359
- // variant (23 of 29 observed), so it is the one most likely to
360
- // carry the answer.
379
+ // Reaching here now means a genuine wedge: stdout closed, no live
380
+ // descendants to reap, and still not exited. Snapshot kernel state
381
+ // before the kill destroys it.
361
382
  log.warn("claude-adapter", "stdout closed mid-stream and process did not exit within grace; killing wedged process", {
362
383
  sessionId: this.sessionId,
363
384
  pid: proc.pid,
364
385
  graceMs: STDOUT_CLOSE_GRACE_MS,
386
+ graceReason: "no_descendants",
365
387
  proc: captureProcState(proc.pid),
366
388
  });
367
- try {
368
- proc.kill();
369
- } catch {
370
- // Process may have exited between the check and the kill.
371
- }
389
+ await this.killWithEscalation(proc);
372
390
  }
373
391
  }
374
392
  }
@@ -376,6 +394,40 @@ export class ClaudeAdapter implements IBackendAdapter {
376
394
  }
377
395
  }
378
396
 
397
+ /**
398
+ * SIGTERM a process, then SIGKILL it if it has not exited.
399
+ *
400
+ * A flat SIGTERM is not enough for the case this path exists to handle: a CLI
401
+ * blocked reaping its own children may never act on the signal, staying alive
402
+ * and satisfying `handleAutoRelaunch`'s PID-liveness guard, which blocks
403
+ * recovery indefinitely — the exact failure the kill was meant to prevent.
404
+ * Never throws; the process may exit between any two steps here.
405
+ */
406
+ private async killWithEscalation(proc: Subprocess): Promise<void> {
407
+ try {
408
+ proc.kill();
409
+ } catch {
410
+ return; // Already gone.
411
+ }
412
+
413
+ const exited = await Promise.race([
414
+ proc.exited.then(() => true),
415
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), SIGKILL_ESCALATION_MS)),
416
+ ]);
417
+ if (exited || proc.exitCode !== null) return;
418
+
419
+ log.warn("claude-adapter", "process ignored SIGTERM; escalating to SIGKILL", {
420
+ sessionId: this.sessionId,
421
+ pid: proc.pid,
422
+ afterMs: SIGKILL_ESCALATION_MS,
423
+ });
424
+ try {
425
+ proc.kill("SIGKILL");
426
+ } catch {
427
+ // Exited between the check and the escalation.
428
+ }
429
+ }
430
+
379
431
  /**
380
432
  * Send the one-time `initialize` control_request before the first outbound
381
433
  * message in stdio mode. This registers the canUseTool capability so the CLI
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { captureProcState, isProcAvailable } from "./proc-diagnostics.js";
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ captureProcState,
5
+ isProcAvailable,
6
+ getDescendants,
7
+ hasLiveDescendants,
8
+ } from "./proc-diagnostics.js";
3
9
 
4
10
  /**
5
11
  * These tests cover the diagnostic that runs on the wedge-kill recovery path in
@@ -70,3 +76,68 @@ describe("captureProcState", () => {
70
76
  expect(JSON.stringify(snapshot)).toContain("state");
71
77
  });
72
78
  });
79
+
80
+ /**
81
+ * Descendant detection is what replaced `lastInboundWasResult` as the grace
82
+ * discriminator in claude-adapter. Its correctness decides whether a CLI that
83
+ * is mid-teardown gets the generous grace or gets SIGTERMed at 2s — the latter
84
+ * being the bug that killed users' turns.
85
+ */
86
+ describe("getDescendants / hasLiveDescendants", () => {
87
+ it("returns empty for undefined pid without throwing", () => {
88
+ expect(getDescendants(undefined)).toEqual([]);
89
+ expect(hasLiveDescendants(undefined)).toBe(false);
90
+ });
91
+
92
+ it("returns empty on platforms without /proc", () => {
93
+ vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
94
+ expect(getDescendants(1)).toEqual([]);
95
+ expect(hasLiveDescendants(1)).toBe(false);
96
+ });
97
+
98
+ it("returns empty for a pid that does not exist", () => {
99
+ expect(getDescendants(2_147_483_646)).toEqual([]);
100
+ expect(hasLiveDescendants(2_147_483_646)).toBe(false);
101
+ });
102
+
103
+ it.runIf(isLinux)("detects a live child and reports it gone after exit", async () => {
104
+ // Mirrors the real shape: a parent holding a child that has not yet reaped.
105
+ // `sleep` stands in for an MCP stdio server still shutting down.
106
+ const child = spawn("sleep", ["30"], { stdio: "ignore" });
107
+ await new Promise((r) => setTimeout(r, 150)); // let the fork land in /proc
108
+
109
+ expect(hasLiveDescendants(process.pid)).toBe(true);
110
+ const descendants = getDescendants(process.pid);
111
+ const found = descendants.find((d) => d.pid === child.pid);
112
+ expect(found).toBeDefined();
113
+ expect(found?.comm).toBe("sleep");
114
+ // A running-but-idle child reports S (sleeping), not Z (reaped).
115
+ expect(found?.state).toBe("S");
116
+
117
+ // After the child exits and is reaped, it must no longer count — otherwise
118
+ // a genuinely wedged process would be granted the long grace forever.
119
+ child.kill("SIGKILL");
120
+ await new Promise((r) => child.on("exit", r));
121
+ await new Promise((r) => setTimeout(r, 150));
122
+
123
+ expect(getDescendants(process.pid).some((d) => d.pid === child.pid)).toBe(false);
124
+ });
125
+
126
+ it.runIf(isLinux)("bounds the walk so a large tree cannot stall the kill path", () => {
127
+ // maxNodes is a safety bound: this runs immediately before a kill that
128
+ // unblocks a stuck session, so it must terminate regardless of tree size.
129
+ expect(getDescendants(1, 2).length).toBeLessThanOrEqual(2);
130
+ });
131
+
132
+ it.runIf(isLinux)("includes descendants in the captured snapshot", async () => {
133
+ const child = spawn("sleep", ["30"], { stdio: "ignore" });
134
+ await new Promise((r) => setTimeout(r, 150));
135
+
136
+ const snapshot = captureProcState(process.pid);
137
+ expect(snapshot.descendants?.some((d) => d.pid === child.pid)).toBe(true);
138
+ expect(() => JSON.stringify(snapshot)).not.toThrow();
139
+
140
+ child.kill("SIGKILL");
141
+ await new Promise((r) => child.on("exit", r));
142
+ });
143
+ });
@@ -26,10 +26,105 @@ export interface ProcSnapshot {
26
26
  wchan?: string;
27
27
  /** Open file descriptor count for this process. */
28
28
  fdCount?: number;
29
+ /**
30
+ * Live descendants at capture time. This is the field that separates the two
31
+ * reasons a process sits in `ep_poll` with stdout closed: a genuine wedge is
32
+ * idle with NO children, whereas a CLI still reaping MCP stdio servers has
33
+ * children mid-exit. Without it, both look identical.
34
+ */
35
+ descendants?: ProcDescendant[];
29
36
  /** Why the snapshot is incomplete, when it is. */
30
37
  error?: string;
31
38
  }
32
39
 
40
+ /** A live descendant process, as seen from /proc. */
41
+ export interface ProcDescendant {
42
+ pid: number;
43
+ /** Command name from /proc/<pid>/comm, e.g. "npm exec", "node", "chrome". */
44
+ comm?: string;
45
+ /** Scheduler state letter, e.g. "S", "R", "Z". "Z" means mid-reap. */
46
+ state?: string;
47
+ }
48
+
49
+ /** Read the direct children of a pid via /proc/<pid>/task/<tid>/children. */
50
+ function readChildren(pid: number): number[] {
51
+ const kids: number[] = [];
52
+ try {
53
+ for (const tid of readdirSync(`/proc/${pid}/task`)) {
54
+ try {
55
+ const raw = readFileSync(`/proc/${pid}/task/${tid}/children`, "utf8").trim();
56
+ if (!raw) continue;
57
+ for (const part of raw.split(/\s+/)) {
58
+ const child = Number(part);
59
+ if (Number.isInteger(child) && child > 0) kids.push(child);
60
+ }
61
+ } catch {
62
+ // Thread exited between readdir and read.
63
+ }
64
+ }
65
+ } catch {
66
+ // Process gone, or kernel built without CONFIG_PROC_CHILDREN.
67
+ }
68
+ return kids;
69
+ }
70
+
71
+ /**
72
+ * Walk the descendant tree of a pid, breadth-first.
73
+ *
74
+ * Bounded by `maxNodes` because this runs on the kill path — a runaway tree
75
+ * must not stall recovery. MCP stdio servers are the expected population here
76
+ * (2-3 per CLI, sometimes with their own children, e.g. headless chromium).
77
+ */
78
+ export function getDescendants(
79
+ pid: number | undefined,
80
+ maxNodes = 32,
81
+ ): ProcDescendant[] {
82
+ if (pid === undefined || !isProcAvailable()) return [];
83
+
84
+ const found: ProcDescendant[] = [];
85
+ const seen = new Set<number>([pid]);
86
+ let frontier = readChildren(pid);
87
+
88
+ while (frontier.length > 0 && found.length < maxNodes) {
89
+ const next: number[] = [];
90
+ for (const child of frontier) {
91
+ if (seen.has(child) || found.length >= maxNodes) continue;
92
+ seen.add(child);
93
+
94
+ const entry: ProcDescendant = { pid: child };
95
+ try {
96
+ entry.comm = readFileSync(`/proc/${child}/comm`, "utf8").trim();
97
+ } catch {
98
+ // Already reaped — still worth reporting the pid.
99
+ }
100
+ try {
101
+ const status = readFileSync(`/proc/${child}/status`, "utf8");
102
+ const line = status.split("\n").find((l) => l.startsWith("State:"));
103
+ if (line) entry.state = line.split(":")[1]?.trim().split(" ")[0];
104
+ } catch {
105
+ // Same.
106
+ }
107
+ found.push(entry);
108
+ next.push(...readChildren(child));
109
+ }
110
+ frontier = next;
111
+ }
112
+
113
+ return found;
114
+ }
115
+
116
+ /**
117
+ * Whether a process still has live descendants.
118
+ *
119
+ * Used to decide how long to wait for a CLI to shut down: one that is still
120
+ * reaping MCP subprocesses deserves the generous grace, regardless of whether
121
+ * the last message it sent happened to be a `result`.
122
+ */
123
+ export function hasLiveDescendants(pid: number | undefined): boolean {
124
+ if (pid === undefined || !isProcAvailable()) return false;
125
+ return readChildren(pid).length > 0;
126
+ }
127
+
33
128
  /** True when /proc-based introspection is available (Linux only). */
34
129
  export function isProcAvailable(): boolean {
35
130
  return process.platform === "linux";
@@ -78,6 +173,12 @@ export function captureProcState(pid: number | undefined): ProcSnapshot {
78
173
  // Reading another process's fd dir can fail on permissions; not fatal.
79
174
  }
80
175
 
176
+ const descendants = getDescendants(pid);
177
+ if (descendants.length > 0) {
178
+ snapshot.descendants = descendants;
179
+ readAnything = true;
180
+ }
181
+
81
182
  if (!readAnything) snapshot.error = "process_gone_or_unreadable";
82
183
  return snapshot;
83
184
  }