@hellcoder/companion 0.111.2 → 0.111.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.111.2",
3
+ "version": "0.111.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",
@@ -42,6 +42,7 @@ beforeEach(() => {
42
42
  updateChannel: "stable",
43
43
  dockerAutoUpdate: false,
44
44
  proactiveKeepaliveEnabled: true,
45
+ keepaliveDetachedSessions: false,
45
46
  wedgeKillEnabled: true,
46
47
  silenceProbeEnabled: true,
47
48
  updatedAt: 0,
@@ -92,6 +93,7 @@ describe("generateSessionTitle", () => {
92
93
  updateChannel: "stable",
93
94
  dockerAutoUpdate: false,
94
95
  proactiveKeepaliveEnabled: true,
96
+ keepaliveDetachedSessions: false,
95
97
  wedgeKillEnabled: true,
96
98
  silenceProbeEnabled: true,
97
99
  updatedAt: 0,
@@ -149,6 +151,7 @@ describe("generateSessionTitle", () => {
149
151
  updateChannel: "stable",
150
152
  dockerAutoUpdate: false,
151
153
  proactiveKeepaliveEnabled: true,
154
+ keepaliveDetachedSessions: false,
152
155
  wedgeKillEnabled: true,
153
156
  silenceProbeEnabled: true,
154
157
  updatedAt: 0,
@@ -235,6 +238,7 @@ describe("generateSessionTitle", () => {
235
238
  updateChannel: "stable",
236
239
  dockerAutoUpdate: false,
237
240
  proactiveKeepaliveEnabled: true,
241
+ keepaliveDetachedSessions: false,
238
242
  wedgeKillEnabled: true,
239
243
  silenceProbeEnabled: true,
240
244
  updatedAt: 0,
@@ -1864,6 +1864,58 @@ describe("stdio silence probe", () => {
1864
1864
  expect(adapter.isConnected()).toBe(true);
1865
1865
  });
1866
1866
 
1867
+ it("does NOT kill a session that is silent because a tool is running", async () => {
1868
+ // Production regression: a session running its test suite (a single ~200s
1869
+ // Bash call) was declared dead at 182s of silence and relaunched, losing the
1870
+ // turn. The CLI does not service control requests while a synchronous tool
1871
+ // runs, so an unanswered probe proves nothing.
1872
+ vi.useFakeTimers();
1873
+ const { proc, pushStdout } = createMockProc();
1874
+ adapter.attachStdio(proc);
1875
+
1876
+ pushStdout(
1877
+ JSON.stringify({
1878
+ type: "assistant",
1879
+ message: {
1880
+ role: "assistant",
1881
+ model: "m",
1882
+ content: [{ type: "tool_use", id: "tu-slow", name: "Bash", input: {} }],
1883
+ },
1884
+ session_id: "s",
1885
+ }) + "\n",
1886
+ );
1887
+
1888
+ // Well past probe-after + probe-timeout, which previously killed it.
1889
+ await vi.advanceTimersByTimeAsync(300_000);
1890
+
1891
+ expect(disconnectCb).not.toHaveBeenCalled();
1892
+ expect(adapter.isConnected()).toBe(true);
1893
+ });
1894
+
1895
+ it("still escalates when a tool call blows the grace ceiling", async () => {
1896
+ // A tool that never returns must not suppress recovery forever.
1897
+ vi.useFakeTimers();
1898
+ const { proc, pushStdout } = createMockProc();
1899
+ adapter.attachStdio(proc);
1900
+
1901
+ pushStdout(
1902
+ JSON.stringify({
1903
+ type: "assistant",
1904
+ message: {
1905
+ role: "assistant",
1906
+ model: "m",
1907
+ content: [{ type: "tool_use", id: "tu-stuck", name: "Bash", input: {} }],
1908
+ },
1909
+ session_id: "s",
1910
+ }) + "\n",
1911
+ );
1912
+
1913
+ await vi.advanceTimersByTimeAsync(700_000); // past the 660s ceiling
1914
+
1915
+ expect(disconnectCb).toHaveBeenCalledTimes(1);
1916
+ expect(adapter.isConnected()).toBe(false);
1917
+ });
1918
+
1867
1919
  it("does nothing when the silence probe is disabled", async () => {
1868
1920
  vi.useFakeTimers();
1869
1921
  mockSettings.silenceProbeEnabled = false;
@@ -159,6 +159,18 @@ const SILENCE_CHECK_INTERVAL_MS = Number(process.env.COMPANION_SILENCE_CHECK_INT
159
159
  */
160
160
  const TURN_STALL_AFTER_MS = Number(process.env.COMPANION_TURN_STALL_AFTER_MS) || 300_000;
161
161
 
162
+ /**
163
+ * How long a CLI with an outstanding tool call is allowed to stay silent before
164
+ * the liveness probe is permitted to escalate anyway.
165
+ *
166
+ * The CLI does not answer control requests while a synchronous tool runs, so
167
+ * during a long `Bash` call the probe goes unanswered for reasons that have
168
+ * nothing to do with health. Set above the CLI's own maximum background-command
169
+ * duration (10 minutes), on the same reasoning as TEARDOWN_MAX_MS: only a tool
170
+ * that blows this ceiling is treated as a genuine hang.
171
+ */
172
+ const TOOL_CALL_PROBE_GRACE_MS = Number(process.env.COMPANION_TOOL_CALL_PROBE_GRACE_MS) || 660_000;
173
+
162
174
  /** How often to re-check the descendant count while waiting for teardown. */
163
175
  const TEARDOWN_POLL_MS = Number(process.env.COMPANION_TEARDOWN_POLL_MS) || 500;
164
176
 
@@ -626,6 +638,22 @@ export class ClaudeAdapter implements IBackendAdapter {
626
638
  // An unanswered probe is the actual failure signal: a healthy CLI replies
627
639
  // to control requests promptly even while working.
628
640
  if (this.probeSentAt !== null) {
641
+ // A long tool call is silence with a cause. The CLI does not service
642
+ // control requests while a synchronous tool runs, so an unanswered probe
643
+ // proves nothing here — and killing on it destroys a healthy session
644
+ // mid-work. Observed in production: a session running its test suite (a
645
+ // single ~200s Bash call) was declared dead at 182s of silence and
646
+ // relaunched, losing the turn.
647
+ //
648
+ // Bounded by TOOL_CALL_PROBE_GRACE_MS so a tool that never returns cannot
649
+ // suppress recovery forever; the ceiling sits above the CLI's own maximum
650
+ // background-command duration, matching TEARDOWN_MAX_MS's reasoning.
651
+ if (
652
+ this.pendingToolUses.size > 0 &&
653
+ now - this.lastInboundAt < TOOL_CALL_PROBE_GRACE_MS
654
+ ) {
655
+ return;
656
+ }
629
657
  if (now - this.probeSentAt >= SILENCE_PROBE_TIMEOUT_MS) {
630
658
  log.warn("claude-adapter", "CLI silent with stdout open and probe unanswered; treating transport as dead", {
631
659
  sessionId: this.sessionId,
@@ -68,6 +68,7 @@ beforeEach(() => {
68
68
  updateChannel: "stable",
69
69
  dockerAutoUpdate: false,
70
70
  proactiveKeepaliveEnabled: true,
71
+ keepaliveDetachedSessions: false,
71
72
  wedgeKillEnabled: true,
72
73
  silenceProbeEnabled: true,
73
74
  updatedAt: 0,
@@ -206,6 +207,7 @@ describe("linear-connections", () => {
206
207
  updateChannel: "stable",
207
208
  dockerAutoUpdate: false,
208
209
  proactiveKeepaliveEnabled: true,
210
+ keepaliveDetachedSessions: false,
209
211
  wedgeKillEnabled: true,
210
212
  silenceProbeEnabled: true,
211
213
  updatedAt: 0,
@@ -254,6 +256,7 @@ describe("linear-connections", () => {
254
256
  updateChannel: "stable",
255
257
  dockerAutoUpdate: false,
256
258
  proactiveKeepaliveEnabled: true,
259
+ keepaliveDetachedSessions: false,
257
260
  wedgeKillEnabled: true,
258
261
  silenceProbeEnabled: true,
259
262
  updatedAt: 0,
@@ -321,6 +324,7 @@ describe("linear-connections", () => {
321
324
  updateChannel: "stable",
322
325
  dockerAutoUpdate: false,
323
326
  proactiveKeepaliveEnabled: true,
327
+ keepaliveDetachedSessions: false,
324
328
  wedgeKillEnabled: true,
325
329
  silenceProbeEnabled: true,
326
330
  updatedAt: 0,
@@ -34,6 +34,7 @@ export function registerSettingsRoutes(api: Hono): void {
34
34
  updateChannel: settings.updateChannel,
35
35
  dockerAutoUpdate: settings.dockerAutoUpdate,
36
36
  proactiveKeepaliveEnabled: settings.proactiveKeepaliveEnabled,
37
+ keepaliveDetachedSessions: settings.keepaliveDetachedSessions,
37
38
  wedgeKillEnabled: settings.wedgeKillEnabled,
38
39
  silenceProbeEnabled: settings.silenceProbeEnabled,
39
40
  cliBridgeMode: settings.cliBridgeMode,
@@ -134,6 +135,9 @@ export function registerSettingsRoutes(api: Hono): void {
134
135
  if (body.proactiveKeepaliveEnabled !== undefined && typeof body.proactiveKeepaliveEnabled !== "boolean") {
135
136
  return c.json({ error: "proactiveKeepaliveEnabled must be a boolean" }, 400);
136
137
  }
138
+ if (body.keepaliveDetachedSessions !== undefined && typeof body.keepaliveDetachedSessions !== "boolean") {
139
+ return c.json({ error: "keepaliveDetachedSessions must be a boolean" }, 400);
140
+ }
137
141
  if (body.wedgeKillEnabled !== undefined && typeof body.wedgeKillEnabled !== "boolean") {
138
142
  return c.json({ error: "wedgeKillEnabled must be a boolean" }, 400);
139
143
  }
@@ -160,6 +164,7 @@ export function registerSettingsRoutes(api: Hono): void {
160
164
  || body.updateChannel !== undefined
161
165
  || body.dockerAutoUpdate !== undefined
162
166
  || body.proactiveKeepaliveEnabled !== undefined
167
+ || body.keepaliveDetachedSessions !== undefined
163
168
  || body.wedgeKillEnabled !== undefined
164
169
  || body.silenceProbeEnabled !== undefined
165
170
  || body.cliBridgeMode !== undefined;
@@ -276,6 +281,10 @@ export function registerSettingsRoutes(api: Hono): void {
276
281
  typeof body.proactiveKeepaliveEnabled === "boolean"
277
282
  ? body.proactiveKeepaliveEnabled
278
283
  : undefined,
284
+ keepaliveDetachedSessions:
285
+ typeof body.keepaliveDetachedSessions === "boolean"
286
+ ? body.keepaliveDetachedSessions
287
+ : undefined,
279
288
  wedgeKillEnabled:
280
289
  typeof body.wedgeKillEnabled === "boolean"
281
290
  ? body.wedgeKillEnabled
@@ -317,6 +326,7 @@ export function registerSettingsRoutes(api: Hono): void {
317
326
  updateChannel: settings.updateChannel,
318
327
  dockerAutoUpdate: settings.dockerAutoUpdate,
319
328
  proactiveKeepaliveEnabled: settings.proactiveKeepaliveEnabled,
329
+ keepaliveDetachedSessions: settings.keepaliveDetachedSessions,
320
330
  wedgeKillEnabled: settings.wedgeKillEnabled,
321
331
  silenceProbeEnabled: settings.silenceProbeEnabled,
322
332
  cliBridgeMode: settings.cliBridgeMode,
@@ -1158,6 +1158,7 @@ describe("GET /api/sessions/:id/archive-info", () => {
1158
1158
  updateChannel: "stable",
1159
1159
  dockerAutoUpdate: false,
1160
1160
  proactiveKeepaliveEnabled: true,
1161
+ keepaliveDetachedSessions: false,
1161
1162
  wedgeKillEnabled: true,
1162
1163
  silenceProbeEnabled: true,
1163
1164
  updatedAt: 0,
@@ -1535,6 +1536,7 @@ describe("GET /api/settings", () => {
1535
1536
  updateChannel: "stable",
1536
1537
  dockerAutoUpdate: false,
1537
1538
  proactiveKeepaliveEnabled: true,
1539
+ keepaliveDetachedSessions: false,
1538
1540
  wedgeKillEnabled: true,
1539
1541
  silenceProbeEnabled: true,
1540
1542
  updatedAt: 123,
@@ -1570,6 +1572,7 @@ describe("GET /api/settings", () => {
1570
1572
  updateChannel: "stable",
1571
1573
  dockerAutoUpdate: false,
1572
1574
  proactiveKeepaliveEnabled: true,
1575
+ keepaliveDetachedSessions: false,
1573
1576
  wedgeKillEnabled: true,
1574
1577
  silenceProbeEnabled: true,
1575
1578
  });
@@ -1605,6 +1608,7 @@ describe("GET /api/settings", () => {
1605
1608
  updateChannel: "stable",
1606
1609
  dockerAutoUpdate: false,
1607
1610
  proactiveKeepaliveEnabled: true,
1611
+ keepaliveDetachedSessions: false,
1608
1612
  wedgeKillEnabled: true,
1609
1613
  silenceProbeEnabled: true,
1610
1614
  updatedAt: 123,
@@ -1640,6 +1644,7 @@ describe("GET /api/settings", () => {
1640
1644
  updateChannel: "stable",
1641
1645
  dockerAutoUpdate: false,
1642
1646
  proactiveKeepaliveEnabled: true,
1647
+ keepaliveDetachedSessions: false,
1643
1648
  wedgeKillEnabled: true,
1644
1649
  silenceProbeEnabled: true,
1645
1650
  });
@@ -1676,6 +1681,7 @@ describe("GET /api/settings", () => {
1676
1681
  updateChannel: "stable",
1677
1682
  dockerAutoUpdate: false,
1678
1683
  proactiveKeepaliveEnabled: true,
1684
+ keepaliveDetachedSessions: false,
1679
1685
  wedgeKillEnabled: true,
1680
1686
  silenceProbeEnabled: true,
1681
1687
  updatedAt: 100,
@@ -1720,6 +1726,7 @@ describe("PUT /api/settings", () => {
1720
1726
  updateChannel: "stable",
1721
1727
  dockerAutoUpdate: false,
1722
1728
  proactiveKeepaliveEnabled: true,
1729
+ keepaliveDetachedSessions: false,
1723
1730
  wedgeKillEnabled: true,
1724
1731
  silenceProbeEnabled: true,
1725
1732
  updatedAt: 456,
@@ -1777,6 +1784,7 @@ describe("PUT /api/settings", () => {
1777
1784
  updateChannel: "stable",
1778
1785
  dockerAutoUpdate: false,
1779
1786
  proactiveKeepaliveEnabled: true,
1787
+ keepaliveDetachedSessions: false,
1780
1788
  wedgeKillEnabled: true,
1781
1789
  silenceProbeEnabled: true,
1782
1790
  });
@@ -1812,6 +1820,7 @@ describe("PUT /api/settings", () => {
1812
1820
  updateChannel: "stable",
1813
1821
  dockerAutoUpdate: false,
1814
1822
  proactiveKeepaliveEnabled: true,
1823
+ keepaliveDetachedSessions: false,
1815
1824
  wedgeKillEnabled: true,
1816
1825
  silenceProbeEnabled: true,
1817
1826
  updatedAt: 789,
@@ -1864,6 +1873,7 @@ describe("PUT /api/settings", () => {
1864
1873
  updateChannel: "stable",
1865
1874
  dockerAutoUpdate: false,
1866
1875
  proactiveKeepaliveEnabled: true,
1876
+ keepaliveDetachedSessions: false,
1867
1877
  wedgeKillEnabled: true,
1868
1878
  silenceProbeEnabled: true,
1869
1879
  updatedAt: 999,
@@ -2016,6 +2026,7 @@ describe("PUT /api/settings", () => {
2016
2026
  updateChannel: "stable",
2017
2027
  dockerAutoUpdate: false,
2018
2028
  proactiveKeepaliveEnabled: true,
2029
+ keepaliveDetachedSessions: false,
2019
2030
  wedgeKillEnabled: true,
2020
2031
  silenceProbeEnabled: true,
2021
2032
  updatedAt: 500,
@@ -2274,6 +2285,7 @@ describe("GET /api/linear/issues", () => {
2274
2285
  updateChannel: "stable",
2275
2286
  dockerAutoUpdate: false,
2276
2287
  proactiveKeepaliveEnabled: true,
2288
+ keepaliveDetachedSessions: false,
2277
2289
  wedgeKillEnabled: true,
2278
2290
  silenceProbeEnabled: true,
2279
2291
  updatedAt: 0,
@@ -2316,6 +2328,7 @@ describe("GET /api/linear/issues", () => {
2316
2328
  updateChannel: "stable",
2317
2329
  dockerAutoUpdate: false,
2318
2330
  proactiveKeepaliveEnabled: true,
2331
+ keepaliveDetachedSessions: false,
2319
2332
  wedgeKillEnabled: true,
2320
2333
  silenceProbeEnabled: true,
2321
2334
  updatedAt: 0,
@@ -2411,6 +2424,7 @@ describe("GET /api/linear/issues", () => {
2411
2424
  updateChannel: "stable",
2412
2425
  dockerAutoUpdate: false,
2413
2426
  proactiveKeepaliveEnabled: true,
2427
+ keepaliveDetachedSessions: false,
2414
2428
  wedgeKillEnabled: true,
2415
2429
  silenceProbeEnabled: true,
2416
2430
  updatedAt: 0,
@@ -2513,6 +2527,7 @@ describe("GET /api/linear/issues", () => {
2513
2527
  updateChannel: "stable",
2514
2528
  dockerAutoUpdate: false,
2515
2529
  proactiveKeepaliveEnabled: true,
2530
+ keepaliveDetachedSessions: false,
2516
2531
  wedgeKillEnabled: true,
2517
2532
  silenceProbeEnabled: true,
2518
2533
  updatedAt: 0,
@@ -2580,6 +2595,7 @@ describe("GET /api/linear/connection", () => {
2580
2595
  updateChannel: "stable",
2581
2596
  dockerAutoUpdate: false,
2582
2597
  proactiveKeepaliveEnabled: true,
2598
+ keepaliveDetachedSessions: false,
2583
2599
  wedgeKillEnabled: true,
2584
2600
  silenceProbeEnabled: true,
2585
2601
  updatedAt: 0,
@@ -2622,6 +2638,7 @@ describe("GET /api/linear/connection", () => {
2622
2638
  updateChannel: "stable",
2623
2639
  dockerAutoUpdate: false,
2624
2640
  proactiveKeepaliveEnabled: true,
2641
+ keepaliveDetachedSessions: false,
2625
2642
  wedgeKillEnabled: true,
2626
2643
  silenceProbeEnabled: true,
2627
2644
  updatedAt: 0,
@@ -2686,6 +2703,7 @@ describe("POST /api/linear/issues/:id/transition", () => {
2686
2703
  updateChannel: "stable",
2687
2704
  dockerAutoUpdate: false,
2688
2705
  proactiveKeepaliveEnabled: true,
2706
+ keepaliveDetachedSessions: false,
2689
2707
  wedgeKillEnabled: true,
2690
2708
  silenceProbeEnabled: true,
2691
2709
  updatedAt: 0,
@@ -2732,6 +2750,7 @@ describe("POST /api/linear/issues/:id/transition", () => {
2732
2750
  updateChannel: "stable",
2733
2751
  dockerAutoUpdate: false,
2734
2752
  proactiveKeepaliveEnabled: true,
2753
+ keepaliveDetachedSessions: false,
2735
2754
  wedgeKillEnabled: true,
2736
2755
  silenceProbeEnabled: true,
2737
2756
  updatedAt: 0,
@@ -2777,6 +2796,7 @@ describe("POST /api/linear/issues/:id/transition", () => {
2777
2796
  updateChannel: "stable",
2778
2797
  dockerAutoUpdate: false,
2779
2798
  proactiveKeepaliveEnabled: true,
2799
+ keepaliveDetachedSessions: false,
2780
2800
  wedgeKillEnabled: true,
2781
2801
  silenceProbeEnabled: true,
2782
2802
  updatedAt: 0,
@@ -2824,6 +2844,7 @@ describe("POST /api/linear/issues/:id/transition", () => {
2824
2844
  updateChannel: "stable",
2825
2845
  dockerAutoUpdate: false,
2826
2846
  proactiveKeepaliveEnabled: true,
2847
+ keepaliveDetachedSessions: false,
2827
2848
  wedgeKillEnabled: true,
2828
2849
  silenceProbeEnabled: true,
2829
2850
  updatedAt: 0,
@@ -2905,6 +2926,7 @@ describe("POST /api/linear/issues/:id/transition", () => {
2905
2926
  updateChannel: "stable",
2906
2927
  dockerAutoUpdate: false,
2907
2928
  proactiveKeepaliveEnabled: true,
2929
+ keepaliveDetachedSessions: false,
2908
2930
  wedgeKillEnabled: true,
2909
2931
  silenceProbeEnabled: true,
2910
2932
  updatedAt: 0,
@@ -2965,6 +2987,7 @@ describe("GET /api/linear/projects", () => {
2965
2987
  updateChannel: "stable",
2966
2988
  dockerAutoUpdate: false,
2967
2989
  proactiveKeepaliveEnabled: true,
2990
+ keepaliveDetachedSessions: false,
2968
2991
  wedgeKillEnabled: true,
2969
2992
  silenceProbeEnabled: true,
2970
2993
  updatedAt: 0,
@@ -3007,6 +3030,7 @@ describe("GET /api/linear/projects", () => {
3007
3030
  updateChannel: "stable",
3008
3031
  dockerAutoUpdate: false,
3009
3032
  proactiveKeepaliveEnabled: true,
3033
+ keepaliveDetachedSessions: false,
3010
3034
  wedgeKillEnabled: true,
3011
3035
  silenceProbeEnabled: true,
3012
3036
  updatedAt: 0,
@@ -3079,6 +3103,7 @@ describe("GET /api/linear/project-issues", () => {
3079
3103
  updateChannel: "stable",
3080
3104
  dockerAutoUpdate: false,
3081
3105
  proactiveKeepaliveEnabled: true,
3106
+ keepaliveDetachedSessions: false,
3082
3107
  wedgeKillEnabled: true,
3083
3108
  silenceProbeEnabled: true,
3084
3109
  updatedAt: 0,
@@ -3121,6 +3146,7 @@ describe("GET /api/linear/project-issues", () => {
3121
3146
  updateChannel: "stable",
3122
3147
  dockerAutoUpdate: false,
3123
3148
  proactiveKeepaliveEnabled: true,
3149
+ keepaliveDetachedSessions: false,
3124
3150
  wedgeKillEnabled: true,
3125
3151
  silenceProbeEnabled: true,
3126
3152
  updatedAt: 0,
@@ -3208,6 +3234,7 @@ describe("GET /api/linear/project-issues", () => {
3208
3234
  updateChannel: "stable",
3209
3235
  dockerAutoUpdate: false,
3210
3236
  proactiveKeepaliveEnabled: true,
3237
+ keepaliveDetachedSessions: false,
3211
3238
  wedgeKillEnabled: true,
3212
3239
  silenceProbeEnabled: true,
3213
3240
  updatedAt: 0,
@@ -50,6 +50,7 @@ vi.mock("./settings-manager.js", () => ({
50
50
  openaiApiKey: "",
51
51
  onboardingCompleted: false,
52
52
  proactiveKeepaliveEnabled: true,
53
+ keepaliveDetachedSessions: false,
53
54
  wedgeKillEnabled: true,
54
55
  silenceProbeEnabled: true,
55
56
  })),
@@ -249,6 +250,7 @@ describe("SessionOrchestrator", () => {
249
250
  openaiApiKey: "",
250
251
  onboardingCompleted: false,
251
252
  proactiveKeepaliveEnabled: true,
253
+ keepaliveDetachedSessions: false,
252
254
  wedgeKillEnabled: true,
253
255
  silenceProbeEnabled: true,
254
256
  } as any);
@@ -434,6 +436,102 @@ describe("SessionOrchestrator", () => {
434
436
 
435
437
  // ── Crash-loop relaunch budget ────────────────────────────────────────────
436
438
 
439
+ // ── Browser-aware keepalive ───────────────────────────────────────────────
440
+
441
+ /**
442
+ * Relaunching a session nobody has open buys latency nobody is waiting for,
443
+ * while re-initialising that session's MCP servers from scratch every time.
444
+ * Measured on a 4-core host: 90 relaunches in 21 minutes drove the load
445
+ * average to 36, starving the live sessions into failing and producing more
446
+ * relaunches. Detached sessions revive on demand instead — typing into a
447
+ * terminated session already requests a relaunch and flushes the queued
448
+ * message on attach.
449
+ */
450
+ describe("keepalive is browser-aware", () => {
451
+ async function crash() {
452
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 143 });
453
+ await vi.advanceTimersByTimeAsync(45_000);
454
+ await vi.advanceTimersByTimeAsync(0);
455
+ }
456
+
457
+ beforeEach(() => {
458
+ deps.launcher.getSession.mockReturnValue({ archived: false, state: "exited", pid: undefined } as any);
459
+ deps.launcher.relaunch.mockResolvedValue({ ok: true });
460
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
461
+ });
462
+
463
+ it("does NOT relaunch a session with no browser attached", async () => {
464
+ vi.useFakeTimers();
465
+ deps.wsBridge.getSession.mockReturnValue({ browserSockets: new Set() } as any);
466
+ orchestrator.initialize();
467
+
468
+ await crash();
469
+
470
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
471
+ vi.useRealTimers();
472
+ });
473
+
474
+ it("relaunches a session someone has open", async () => {
475
+ vi.useFakeTimers();
476
+ deps.wsBridge.getSession.mockReturnValue({ browserSockets: new Set(["ws1"]) } as any);
477
+ orchestrator.initialize();
478
+
479
+ await crash();
480
+
481
+ expect(deps.launcher.relaunch).toHaveBeenCalled();
482
+ vi.useRealTimers();
483
+ });
484
+
485
+ it("always relaunches agent/cron sessions, which run detached by design", async () => {
486
+ // For these a dead CLI means dropped work, not just a slower first reply.
487
+ vi.useFakeTimers();
488
+ deps.launcher.getSession.mockReturnValue({
489
+ archived: false, state: "exited", pid: undefined, agentId: "agent-7",
490
+ } as any);
491
+ deps.wsBridge.getSession.mockReturnValue({ browserSockets: new Set() } as any);
492
+ orchestrator.initialize();
493
+
494
+ await crash();
495
+
496
+ expect(deps.launcher.relaunch).toHaveBeenCalled();
497
+ vi.useRealTimers();
498
+ });
499
+
500
+ it("relaunches detached sessions when the setting opts back in", async () => {
501
+ vi.useFakeTimers();
502
+ const { getSettings } = await import("./settings-manager.js");
503
+ const prior = (getSettings as any).getMockImplementation();
504
+ // The gate reads getSettings() more than once per relaunch decision, so
505
+ // this has to hold for the whole cycle rather than a single call.
506
+ (getSettings as any).mockReturnValue({
507
+ proactiveKeepaliveEnabled: true,
508
+ keepaliveDetachedSessions: true,
509
+ wedgeKillEnabled: true,
510
+ silenceProbeEnabled: true,
511
+ });
512
+ deps.wsBridge.getSession.mockReturnValue({ browserSockets: new Set() } as any);
513
+ orchestrator.initialize();
514
+
515
+ await crash();
516
+
517
+ expect(deps.launcher.relaunch).toHaveBeenCalled();
518
+ (getSettings as any).mockImplementation(prior);
519
+ vi.useRealTimers();
520
+ });
521
+
522
+ it("fails open when the bridge has no record of the session", async () => {
523
+ // A bookkeeping gap must never silently cost a session its recovery.
524
+ vi.useFakeTimers();
525
+ deps.wsBridge.getSession.mockReturnValue(null as any);
526
+ orchestrator.initialize();
527
+
528
+ await crash();
529
+
530
+ expect(deps.launcher.relaunch).toHaveBeenCalled();
531
+ vi.useRealTimers();
532
+ });
533
+ });
534
+
437
535
  describe("auto-relaunch crash budget", () => {
438
536
  // Drives one crash→proactive-relaunch cycle: the CLI exits, the keepalive
439
537
  // timer fires, handleAutoRelaunch waits out its grace + cooldown, and the
@@ -1835,6 +1933,7 @@ describe("SessionOrchestrator", () => {
1835
1933
  vi.mocked(settingsManager.getSettings).mockReturnValue({
1836
1934
  ...settingsManager.getSettings(),
1837
1935
  proactiveKeepaliveEnabled: false,
1936
+ keepaliveDetachedSessions: false,
1838
1937
  wedgeKillEnabled: true,
1839
1938
  silenceProbeEnabled: true,
1840
1939
  });
@@ -982,14 +982,14 @@ export class SessionOrchestrator {
982
982
  // ── Private: Proactive keepalive ────────────────────────────────────────────
983
983
 
984
984
  /**
985
- * Schedules a proactive relaunch of a crashed CLI process, regardless of
986
- * whether any browsers are connected. Uses exponential backoff (3s, 6s, 12s)
987
- * based on the auto-relaunch attempt count.
985
+ * Schedules a proactive relaunch of a crashed CLI process. Uses exponential
986
+ * backoff (3s, 6s, 12s) based on the auto-relaunch attempt count.
988
987
  *
989
988
  * Skips relaunch for:
990
989
  * - Intentional kills (idle-kill, manual delete/archive)
991
990
  * - Archived sessions
992
991
  * - Sessions that have exhausted their relaunch budget
992
+ * - Detached sessions, unless `keepaliveDetachedSessions` is on (see below)
993
993
  */
994
994
  private scheduleProactiveRelaunch(sessionId: string): void {
995
995
  // Respect the global kill-switch — lets operators experiment with letting
@@ -1006,6 +1006,35 @@ export class SessionOrchestrator {
1006
1006
  const info = this.launcher.getSession(sessionId);
1007
1007
  if (!info || info.archived) return;
1008
1008
 
1009
+ // Detached sessions: nobody is watching, so nobody is waiting on the
1010
+ // latency this relaunch buys. Relaunching them anyway is what turns a
1011
+ // handful of crashes into a load spike — every relaunch re-initialises the
1012
+ // session's MCP servers from scratch (`npm exec` + node boot, plus chromium
1013
+ // for @playwright/mcp). Measured on a 4-core host: 90 relaunches in 21
1014
+ // minutes drove the load average to 36, which starved the *live* sessions
1015
+ // into failing, producing more relaunches.
1016
+ //
1017
+ // Nothing is lost by waiting. Typing into a terminated session already
1018
+ // requests a relaunch on demand (`terminated -> starting` is legal, and the
1019
+ // queued message flushes on attach), which is the same path that replays an
1020
+ // in-flight turn after a wedge-kill. The cost is a one-time resume on first
1021
+ // message instead of a permanent warm process.
1022
+ //
1023
+ // Agent- and cron-spawned sessions are exempt: they legitimately run with no
1024
+ // browser attached, and for them a dead CLI means dropped work, not just a
1025
+ // slower first reply.
1026
+ // Fails open: only a session the bridge positively reports as having zero
1027
+ // browsers is treated as detached. If the bridge has no record of it we
1028
+ // relaunch as before, so a bookkeeping gap can never silently cost a session
1029
+ // its recovery.
1030
+ if (!info.agentId && !getSettings().keepaliveDetachedSessions) {
1031
+ const tracked = this.wsBridge.getSession(sessionId);
1032
+ if (tracked && tracked.browserSockets.size === 0) {
1033
+ log.info("orchestrator", "Session detached; deferring relaunch until it is opened", { sessionId });
1034
+ return;
1035
+ }
1036
+ }
1037
+
1009
1038
  // Skip if already at relaunch limit
1010
1039
  if (this.relaunchExhaustedNotified.has(sessionId)) return;
1011
1040
 
@@ -53,6 +53,7 @@ describe("settings-manager", () => {
53
53
  updateChannel: "stable",
54
54
  dockerAutoUpdate: false,
55
55
  proactiveKeepaliveEnabled: true,
56
+ keepaliveDetachedSessions: false,
56
57
  wedgeKillEnabled: true,
57
58
  silenceProbeEnabled: true,
58
59
  cliBridgeMode: "loopback",
@@ -119,6 +120,7 @@ describe("settings-manager", () => {
119
120
  updateChannel: "stable",
120
121
  dockerAutoUpdate: false,
121
122
  proactiveKeepaliveEnabled: true,
123
+ keepaliveDetachedSessions: false,
122
124
  wedgeKillEnabled: true,
123
125
  silenceProbeEnabled: true,
124
126
  cliBridgeMode: "loopback",
@@ -211,6 +213,7 @@ describe("settings-manager", () => {
211
213
  updateChannel: "stable",
212
214
  dockerAutoUpdate: false,
213
215
  proactiveKeepaliveEnabled: true,
216
+ keepaliveDetachedSessions: false,
214
217
  wedgeKillEnabled: true,
215
218
  silenceProbeEnabled: true,
216
219
  cliBridgeMode: "loopback",
@@ -92,6 +92,16 @@ export interface CompanionSettings {
92
92
  * cron) alive. Disable to experiment with letting dead sessions stay dead.
93
93
  */
94
94
  proactiveKeepaliveEnabled: boolean;
95
+ /**
96
+ * Also keep *detached* sessions (no browser attached) auto-relaunching.
97
+ *
98
+ * Off by default: relaunching a session nobody has open buys latency nobody
99
+ * is waiting for, while re-initialising its MCP servers each time. On a busy
100
+ * multi-session host that dominates CPU. Detached sessions still revive on
101
+ * demand the moment a message is sent to them. Agent/cron sessions are exempt
102
+ * from this gate and always keep alive.
103
+ */
104
+ keepaliveDetachedSessions: boolean;
95
105
  /**
96
106
  * When true (default), a CLI whose stdout closes but which does not exit is
97
107
  * treated as wedged and killed so recovery can proceed.
@@ -158,6 +168,7 @@ let settings: CompanionSettings = {
158
168
  updateChannel: "stable",
159
169
  dockerAutoUpdate: false,
160
170
  proactiveKeepaliveEnabled: true,
171
+ keepaliveDetachedSessions: false,
161
172
  wedgeKillEnabled: true,
162
173
  silenceProbeEnabled: true,
163
174
  cliBridgeMode: "loopback",
@@ -211,6 +222,7 @@ function normalize(raw: Partial<CompanionSettings> | null | undefined): Companio
211
222
  updateChannel: raw?.updateChannel === "prerelease" ? "prerelease" : "stable",
212
223
  dockerAutoUpdate: typeof raw?.dockerAutoUpdate === "boolean" ? raw.dockerAutoUpdate : false,
213
224
  proactiveKeepaliveEnabled: typeof raw?.proactiveKeepaliveEnabled === "boolean" ? raw.proactiveKeepaliveEnabled : true,
225
+ keepaliveDetachedSessions: typeof raw?.keepaliveDetachedSessions === "boolean" ? raw.keepaliveDetachedSessions : false,
214
226
  wedgeKillEnabled: typeof raw?.wedgeKillEnabled === "boolean" ? raw.wedgeKillEnabled : true,
215
227
  silenceProbeEnabled: typeof raw?.silenceProbeEnabled === "boolean" ? raw.silenceProbeEnabled : true,
216
228
  cliBridgeMode: raw?.cliBridgeMode === "jsonHandoff" ? "jsonHandoff" : "loopback",
@@ -246,7 +258,7 @@ export function getSettings(): CompanionSettings {
246
258
  }
247
259
 
248
260
  export function updateSettings(
249
- patch: Partial<Pick<CompanionSettings, "anthropicApiKey" | "anthropicModel" | "claudeCodeOAuthToken" | "openaiApiKey" | "onboardingCompleted" | "linearApiKey" | "linearAutoTransition" | "linearAutoTransitionStateId" | "linearAutoTransitionStateName" | "linearArchiveTransition" | "linearArchiveTransitionStateId" | "linearArchiveTransitionStateName" | "linearOAuthClientId" | "linearOAuthClientSecret" | "linearOAuthWebhookSecret" | "linearOAuthAccessToken" | "linearOAuthRefreshToken" | "aiValidationEnabled" | "aiValidationAutoApprove" | "aiValidationAutoDeny" | "dashboardEnabled" | "dashboardModel" | "dashboardRunHour" | "dashboardMaxSessionsPerRun" | "publicUrl" | "updateChannel" | "dockerAutoUpdate" | "proactiveKeepaliveEnabled" | "wedgeKillEnabled" | "silenceProbeEnabled" | "cliBridgeMode" | "claudeBridgeMode" | "claudeBridgeIngressUrl" | "claudeCompatBannerDismissedVersion">>,
261
+ patch: Partial<Pick<CompanionSettings, "anthropicApiKey" | "anthropicModel" | "claudeCodeOAuthToken" | "openaiApiKey" | "onboardingCompleted" | "linearApiKey" | "linearAutoTransition" | "linearAutoTransitionStateId" | "linearAutoTransitionStateName" | "linearArchiveTransition" | "linearArchiveTransitionStateId" | "linearArchiveTransitionStateName" | "linearOAuthClientId" | "linearOAuthClientSecret" | "linearOAuthWebhookSecret" | "linearOAuthAccessToken" | "linearOAuthRefreshToken" | "aiValidationEnabled" | "aiValidationAutoApprove" | "aiValidationAutoDeny" | "dashboardEnabled" | "dashboardModel" | "dashboardRunHour" | "dashboardMaxSessionsPerRun" | "publicUrl" | "updateChannel" | "dockerAutoUpdate" | "proactiveKeepaliveEnabled" | "keepaliveDetachedSessions" | "wedgeKillEnabled" | "silenceProbeEnabled" | "cliBridgeMode" | "claudeBridgeMode" | "claudeBridgeIngressUrl" | "claudeCompatBannerDismissedVersion">>,
250
262
  ): CompanionSettings {
251
263
  ensureLoaded();
252
264
  settings = normalize({
@@ -278,6 +290,7 @@ export function updateSettings(
278
290
  updateChannel: patch.updateChannel ?? settings.updateChannel,
279
291
  dockerAutoUpdate: patch.dockerAutoUpdate ?? settings.dockerAutoUpdate,
280
292
  proactiveKeepaliveEnabled: patch.proactiveKeepaliveEnabled ?? settings.proactiveKeepaliveEnabled,
293
+ keepaliveDetachedSessions: patch.keepaliveDetachedSessions ?? settings.keepaliveDetachedSessions,
281
294
  wedgeKillEnabled: patch.wedgeKillEnabled ?? settings.wedgeKillEnabled,
282
295
  silenceProbeEnabled: patch.silenceProbeEnabled ?? settings.silenceProbeEnabled,
283
296
  cliBridgeMode: patch.cliBridgeMode ?? settings.cliBridgeMode,
@@ -140,6 +140,7 @@ describe("attachCodexAdapterHandlers", () => {
140
140
  updateChannel: "stable",
141
141
  dockerAutoUpdate: false,
142
142
  proactiveKeepaliveEnabled: true,
143
+ keepaliveDetachedSessions: false,
143
144
  wedgeKillEnabled: true,
144
145
  silenceProbeEnabled: true,
145
146
  updatedAt: 0,
@@ -1133,6 +1134,7 @@ describe("attachCodexAdapterHandlers", () => {
1133
1134
  updateChannel: "stable",
1134
1135
  dockerAutoUpdate: false,
1135
1136
  proactiveKeepaliveEnabled: true,
1137
+ keepaliveDetachedSessions: false,
1136
1138
  wedgeKillEnabled: true,
1137
1139
  silenceProbeEnabled: true,
1138
1140
  updatedAt: 0,
@@ -1316,6 +1318,7 @@ describe("attachCodexAdapterHandlers", () => {
1316
1318
  updateChannel: "stable",
1317
1319
  dockerAutoUpdate: false,
1318
1320
  proactiveKeepaliveEnabled: true,
1321
+ keepaliveDetachedSessions: false,
1319
1322
  wedgeKillEnabled: true,
1320
1323
  silenceProbeEnabled: true,
1321
1324
  updatedAt: 0,
@@ -1369,6 +1372,7 @@ describe("attachCodexAdapterHandlers", () => {
1369
1372
  updateChannel: "stable",
1370
1373
  dockerAutoUpdate: false,
1371
1374
  proactiveKeepaliveEnabled: true,
1375
+ keepaliveDetachedSessions: false,
1372
1376
  wedgeKillEnabled: true,
1373
1377
  silenceProbeEnabled: true,
1374
1378
  updatedAt: 0,
@@ -1487,6 +1491,7 @@ describe("attachCodexAdapterHandlers", () => {
1487
1491
  updateChannel: "stable",
1488
1492
  dockerAutoUpdate: false,
1489
1493
  proactiveKeepaliveEnabled: true,
1494
+ keepaliveDetachedSessions: false,
1490
1495
  wedgeKillEnabled: true,
1491
1496
  silenceProbeEnabled: true,
1492
1497
  updatedAt: 0,
@@ -1624,6 +1629,7 @@ describe("attachCodexAdapterHandlers", () => {
1624
1629
  updateChannel: "stable",
1625
1630
  dockerAutoUpdate: false,
1626
1631
  proactiveKeepaliveEnabled: true,
1632
+ keepaliveDetachedSessions: false,
1627
1633
  wedgeKillEnabled: true,
1628
1634
  silenceProbeEnabled: true,
1629
1635
  updatedAt: 0,