@hellcoder/companion 0.104.2 → 0.104.3

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.104.2",
3
+ "version": "0.104.3",
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",
@@ -1530,21 +1530,52 @@ function createMockProc() {
1530
1530
  }
1531
1531
 
1532
1532
  describe("stdio transport disconnect propagation", () => {
1533
- it("fires disconnect and kills a lingering process when stdout closes while alive", async () => {
1533
+ it("kills a wedged process that does not exit within the grace period", async () => {
1534
1534
  // The wedge case: the worker stops emitting (e.g. after a 429) and closes
1535
- // stdout but the process keeps running. The adapter must kill it (so the
1536
- // relaunch path's PID-liveness guard is cleared) and report disconnect.
1537
- const { proc, endStdout } = createMockProc();
1538
- adapter.attachStdio(proc);
1539
- expect(adapter.isConnected()).toBe(true);
1535
+ // stdout but the process keeps running and never exits. After the grace
1536
+ // window the adapter must kill it (so the relaunch path's PID-liveness guard
1537
+ // is cleared) and report disconnect.
1538
+ vi.useFakeTimers();
1539
+ try {
1540
+ const { proc, endStdout } = createMockProc();
1541
+ adapter.attachStdio(proc);
1542
+ expect(adapter.isConnected()).toBe(true);
1543
+
1544
+ endStdout();
1545
+ // Within the grace window the process is still alive → not killed yet.
1546
+ await vi.advanceTimersByTimeAsync(0);
1547
+ expect(proc.kill).not.toHaveBeenCalled();
1548
+
1549
+ // Past the grace window with no self-exit → treated as wedged and killed.
1550
+ await vi.advanceTimersByTimeAsync(2100);
1551
+ expect(proc.kill).toHaveBeenCalledTimes(1);
1552
+ expect(adapter.isConnected()).toBe(false);
1553
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1554
+ } finally {
1555
+ vi.useRealTimers();
1556
+ }
1557
+ });
1540
1558
 
1541
- endStdout();
1542
- // Let the async stdout reader observe the close and run its finally block.
1543
- await new Promise((r) => setTimeout(r, 0));
1559
+ it("does NOT kill a process that exits on its own within the grace period", async () => {
1560
+ // The common, healthy case: the CLI closes stdout a beat before it exits
1561
+ // cleanly (e.g. right after a `result`). Killing here would convert a code-0
1562
+ // exit into a SIGTERM (143) and force a needless relaunch, so the adapter
1563
+ // must wait out the grace and let the process exit on its own.
1564
+ vi.useFakeTimers();
1565
+ try {
1566
+ const { proc, endStdout, resolveExit } = createMockProc();
1567
+ adapter.attachStdio(proc);
1544
1568
 
1545
- expect(proc.kill).toHaveBeenCalledTimes(1);
1546
- expect(adapter.isConnected()).toBe(false);
1547
- expect(disconnectCb).toHaveBeenCalledTimes(1);
1569
+ endStdout(); // stdout closes while the process is still alive
1570
+ resolveExit(); // ...but the process exits on its own during the grace
1571
+ await vi.advanceTimersByTimeAsync(2100);
1572
+
1573
+ expect(proc.kill).not.toHaveBeenCalled();
1574
+ expect(adapter.isConnected()).toBe(false);
1575
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1576
+ } finally {
1577
+ vi.useRealTimers();
1578
+ }
1548
1579
  });
1549
1580
 
1550
1581
  it("fires disconnect on process exit", async () => {
@@ -55,6 +55,16 @@ import { reportProtocolDrift } from "./protocol-monitor.js";
55
55
  /** Number of recent CLI message hashes to track for deduplication on WS reconnect. */
56
56
  const CLI_DEDUP_WINDOW = 2000;
57
57
 
58
+ /**
59
+ * Grace period after stdout closes before we kill a still-alive CLI process.
60
+ * The CLI's stdout often EOFs a beat *before* the process exits cleanly (e.g.
61
+ * right after emitting a `result`). Killing immediately turns that code-0 exit
62
+ * into a SIGTERM (143) and forces a needless relaunch. We wait this long for the
63
+ * process to exit on its own; only a process that is still alive afterwards is
64
+ * treated as genuinely wedged and killed. Overridable for tests/tuning.
65
+ */
66
+ const STDOUT_CLOSE_GRACE_MS = Number(process.env.COMPANION_STDOUT_CLOSE_GRACE_MS) || 2000;
67
+
58
68
  // --- Claude Code Adapter ------------------------------------------------------
59
69
 
60
70
  export class ClaudeAdapter implements IBackendAdapter {
@@ -277,22 +287,33 @@ export class ClaudeAdapter implements IBackendAdapter {
277
287
  });
278
288
  } finally {
279
289
  // The stdout stream ended: the stream-json transport is dead and cannot
280
- // be revived on this process. If the child somehow lingers (e.g. it
281
- // stopped emitting after a fatal API error such as a 429 but never
282
- // exited), kill it so `proc.exited` resolves and the launcher's
283
- // `session:exited` proactive `--resume` relaunch can recover it. Left
284
- // alive, the process would keep `handleAutoRelaunch`'s PID-liveness guard
285
- // satisfied, blocking every relaunch path indefinitely.
290
+ // be revived on this process. The process is now in one of two states:
291
+ // (a) Exiting cleanly its stdout EOFs a beat before it exits (e.g.
292
+ // right after a `result`). Killing here converts a code-0 exit into
293
+ // a SIGTERM (143) and forces a needless relaunch.
294
+ // (b) Wedged it stopped emitting (e.g. after a fatal API error such
295
+ // as a 429) but will never exit, leaving `handleAutoRelaunch`'s
296
+ // PID-liveness guard satisfied and blocking recovery indefinitely.
297
+ // Give it a short grace to exit on its own; only kill if it is STILL alive
298
+ // afterwards. That distinguishes (a) from (b) instead of clobbering every
299
+ // session that simply closed stdout on the way out.
286
300
  const proc = this.stdioProc;
287
301
  if (proc && proc.exitCode === null && !proc.killed) {
288
- log.warn("claude-adapter", "stdout closed while process still alive; killing stale process", {
289
- sessionId: this.sessionId,
290
- pid: proc.pid,
291
- });
292
- try {
293
- proc.kill();
294
- } catch {
295
- // Process may have exited between the check and the kill.
302
+ const exitedOnOwn = await Promise.race([
303
+ proc.exited.then(() => true),
304
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), STDOUT_CLOSE_GRACE_MS)),
305
+ ]);
306
+ if (!exitedOnOwn && proc.exitCode === null && !proc.killed) {
307
+ log.warn("claude-adapter", "stdout closed and process did not exit within grace; killing stale process", {
308
+ sessionId: this.sessionId,
309
+ pid: proc.pid,
310
+ graceMs: STDOUT_CLOSE_GRACE_MS,
311
+ });
312
+ try {
313
+ proc.kill();
314
+ } catch {
315
+ // Process may have exited between the check and the kill.
316
+ }
296
317
  }
297
318
  }
298
319
  this.notifyStdioDisconnect();