@hellcoder/companion 0.113.2 → 0.113.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.
Files changed (29) hide show
  1. package/dist/assets/{AgentsPage-1K8kPnm0.js → AgentsPage-B2NtxJig.js} +1 -1
  2. package/dist/assets/{CronManager-BZyF07KE.js → CronManager-CES2-E0_.js} +1 -1
  3. package/dist/assets/{DashboardPage-CbI1O6Zk.js → DashboardPage-aFf4uQfU.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-CI70O4xo.js → IntegrationsPage-C_tZ2P0K.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-B4FLu5lN.js → LinearOAuthSettingsPage-DUQik5kh.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-cPsVQjgm.js → LinearSettingsPage-DFh8jfhh.js} +1 -1
  7. package/dist/assets/{Playground-CtT6Yg7z.js → Playground-C4t0-Zia.js} +1 -1
  8. package/dist/assets/{PromptsPage-aPQqBbHT.js → PromptsPage-Ma4gA0R7.js} +1 -1
  9. package/dist/assets/{RunsPage-DN71HYQf.js → RunsPage-jk2yW392.js} +1 -1
  10. package/dist/assets/{SandboxManager-DUH9hgUI.js → SandboxManager-BA6-RlkR.js} +1 -1
  11. package/dist/assets/{SettingsPage-BOzfigwV.js → SettingsPage-BehGv-KV.js} +1 -1
  12. package/dist/assets/{TailscalePage-DyKEeXYp.js → TailscalePage-CZ6V3kc7.js} +1 -1
  13. package/dist/assets/{index-Ck0r9rSX.js → index-B1Oejky6.js} +4 -4
  14. package/dist/assets/{sw-register-U38bnRB2.js → sw-register-B9GK06Mo.js} +1 -1
  15. package/dist/index.html +1 -1
  16. package/dist/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/server/claude-adapter.ts +26 -1
  19. package/server/fs-utils.test.ts +105 -0
  20. package/server/fs-utils.ts +100 -2
  21. package/server/logger.test.ts +69 -6
  22. package/server/logger.ts +49 -6
  23. package/server/proc-diagnostics.test.ts +44 -0
  24. package/server/proc-diagnostics.ts +28 -1
  25. package/server/recorder.test.ts +12 -10
  26. package/server/recorder.ts +30 -7
  27. package/server/session-git-info.ts +34 -19
  28. package/server/ws-bridge.test.ts +34 -10
  29. package/server/ws-bridge.ts +63 -6
@@ -7,6 +7,7 @@ import {
7
7
  hasLiveDescendants,
8
8
  countDescendants,
9
9
  findOrphanedMcpProcesses,
10
+ stdoutFdOpen,
10
11
  } from "./proc-diagnostics.js";
11
12
 
12
13
  /**
@@ -287,3 +288,46 @@ describe("findOrphanedMcpProcesses", () => {
287
288
  expect(typeof found?.comm).toBe("string");
288
289
  });
289
290
  });
291
+
292
+ describe("stdoutFdOpen", () => {
293
+ const spawned: ReturnType<typeof spawn>[] = [];
294
+
295
+ afterEach(() => {
296
+ for (const p of spawned.splice(0)) {
297
+ try { p.kill("SIGKILL"); } catch { /* already gone */ }
298
+ }
299
+ });
300
+
301
+ // This probe decides how a mid-stream stdout EOF is attributed: a pipe only
302
+ // EOFs at the read end when every write end has closed, so "our reader saw
303
+ // EOF but fd 1 is still open" proves a reader-side (server) stream failure
304
+ // rather than a CLI wedge. Production sampling showed every wedge-kill
305
+ // victim healthy — this is the field that finally distinguishes the cases.
306
+ it.runIf(isLinux)("returns true for a live process holding stdout open", async () => {
307
+ const child = spawn("sleep", ["30"], { stdio: "ignore" });
308
+ spawned.push(child);
309
+ await new Promise((r) => setTimeout(r, 100));
310
+ expect(stdoutFdOpen(child.pid)).toBe(true);
311
+ });
312
+
313
+ it.runIf(isLinux)("returns false when the process closed its own stdout", async () => {
314
+ // exec 1>&- closes fd 1 in the shell itself, then keeps the process alive.
315
+ const child = spawn("/bin/bash", ["-c", "exec 1>&-; sleep 30"], { stdio: "ignore" });
316
+ spawned.push(child);
317
+ await new Promise((r) => setTimeout(r, 300));
318
+ expect(stdoutFdOpen(child.pid)).toBe(false);
319
+ });
320
+
321
+ it.runIf(isLinux)("returns false for an exited process", async () => {
322
+ const child = spawn("true", [], { stdio: "ignore" });
323
+ spawned.push(child);
324
+ await new Promise((r) => setTimeout(r, 300));
325
+ expect(stdoutFdOpen(child.pid)).toBe(false);
326
+ });
327
+
328
+ it("returns null for a missing pid and never throws", () => {
329
+ expect(stdoutFdOpen(undefined)).toBeNull();
330
+ expect(stdoutFdOpen(null)).toBeNull();
331
+ expect(stdoutFdOpen(0)).toBeNull();
332
+ });
333
+ });
@@ -8,7 +8,7 @@
8
8
  // Everything here is best-effort and must never throw: this runs on a recovery
9
9
  // path, and a diagnostic that breaks recovery is worse than no diagnostic.
10
10
 
11
- import { readFileSync, readdirSync } from "node:fs";
11
+ import { readFileSync, readdirSync, readlinkSync } from "node:fs";
12
12
 
13
13
  /** A point-in-time snapshot of kernel state for a process. */
14
14
  export interface ProcSnapshot {
@@ -281,3 +281,30 @@ export function captureProcState(pid: number | undefined): ProcSnapshot {
281
281
  if (!readAnything) snapshot.error = "process_gone_or_unreadable";
282
282
  return snapshot;
283
283
  }
284
+
285
+ /**
286
+ * Does the process still hold its stdout (fd 1) open?
287
+ *
288
+ * The discriminating probe for a mid-stream stdout EOF. A pipe only EOFs at
289
+ * the read end once every write end is closed — so if OUR reader reported EOF
290
+ * while the CLI's fd 1 is still open, the "EOF" was synthetic on the reader
291
+ * (Bun stream) side, not the CLI closing its output. That distinction decides
292
+ * whether a "wedged" kill is recovering from a real CLI failure or destroying
293
+ * a healthy process because of our own stream handling. Production sampling
294
+ * showed every wedge-kill victim was healthy (S/ep_poll), which is what
295
+ * motivated recording this at kill time.
296
+ *
297
+ * Returns true if fd 1 is open, false if it is definitely gone (fd closed or
298
+ * process exited), and null when it cannot be determined (non-Linux,
299
+ * permissions).
300
+ */
301
+ export function stdoutFdOpen(pid: number | undefined | null): boolean | null {
302
+ if (!pid || process.platform !== "linux") return null;
303
+ try {
304
+ readlinkSync(`/proc/${pid}/fd/1`);
305
+ return true;
306
+ } catch (err) {
307
+ const code = (err as NodeJS.ErrnoException)?.code;
308
+ return code === "ENOENT" || code === "ESRCH" ? false : null;
309
+ }
310
+ }
@@ -350,7 +350,7 @@ describe("RecorderManager", () => {
350
350
  // ─── Cleanup / Rotation ─────────────────────────────────────────────────────
351
351
 
352
352
  describe("cleanup / rotation", () => {
353
- it("deletes oldest files when total lines exceed maxLines", () => {
353
+ it("deletes oldest files when total lines exceed maxLines", async () => {
354
354
  // Create 3 files with 10 entries each (= 11 lines each including header, 33 total)
355
355
  // Use different mtimes so we control which is "oldest"
356
356
  const now = Date.now();
@@ -365,7 +365,7 @@ describe("cleanup / rotation", () => {
365
365
  maxLines: 20,
366
366
  });
367
367
 
368
- const deleted = mgr.cleanup();
368
+ const deleted = await mgr.cleanup();
369
369
 
370
370
  // Should have deleted at least the oldest file (11 lines), bringing total to 22,
371
371
  // still > 20, so the mid file (11 lines) gets deleted too → total 11 lines
@@ -376,7 +376,7 @@ describe("cleanup / rotation", () => {
376
376
  expect(remaining[0]).toContain("new_claude");
377
377
  });
378
378
 
379
- it("does not delete files from active recording sessions", () => {
379
+ it("does not delete files from active recording sessions", async () => {
380
380
  // Create an old file that would normally be deleted
381
381
  const now = Date.now();
382
382
  createFakeRecording(tempDir, "stale_claude_2025-01-01.jsonl", 10, new Date(now - 3000));
@@ -390,7 +390,7 @@ describe("cleanup / rotation", () => {
390
390
  mgr.record("active-sess", "in", "msg", "cli", "claude", "/cwd");
391
391
 
392
392
  // Now cleanup should delete the stale file but NOT the active recording's file
393
- const deleted = mgr.cleanup();
393
+ const deleted = await mgr.cleanup();
394
394
 
395
395
  // stale file deleted
396
396
  expect(existsSync(join(tempDir, "stale_claude_2025-01-01.jsonl"))).toBe(false);
@@ -403,7 +403,7 @@ describe("cleanup / rotation", () => {
403
403
  mgr.closeAll();
404
404
  });
405
405
 
406
- it("is a no-op when total lines are under the limit", () => {
406
+ it("is a no-op when total lines are under the limit", async () => {
407
407
  // 2 files × 3 entries = 2 × 4 lines = 8 total, well under 100
408
408
  createFakeRecording(tempDir, "a_claude_2025-01-01.jsonl", 3);
409
409
  createFakeRecording(tempDir, "b_claude_2025-01-02.jsonl", 3);
@@ -414,36 +414,38 @@ describe("cleanup / rotation", () => {
414
414
  maxLines: 100,
415
415
  });
416
416
 
417
- const deleted = mgr.cleanup();
417
+ const deleted = await mgr.cleanup();
418
418
  expect(deleted).toBe(0);
419
419
 
420
420
  expect(readDirSafe(tempDir).length).toBe(2);
421
421
  });
422
422
 
423
- it("handles empty recordings directory gracefully", () => {
423
+ it("handles empty recordings directory gracefully", async () => {
424
424
  const mgr = new RecorderManager({
425
425
  globalEnabled: false,
426
426
  recordingsDir: tempDir,
427
427
  maxLines: 10,
428
428
  });
429
429
 
430
- const deleted = mgr.cleanup();
430
+ const deleted = await mgr.cleanup();
431
431
  expect(deleted).toBe(0);
432
432
  });
433
433
 
434
- it("runs cleanup at construction when globally enabled", () => {
434
+ it("runs cleanup at construction when globally enabled", async () => {
435
435
  // Pre-fill the directory over the limit
436
436
  const now = Date.now();
437
437
  createFakeRecording(tempDir, "old_claude_2025-01-01.jsonl", 20, new Date(now - 2000));
438
438
  createFakeRecording(tempDir, "new_claude_2025-01-02.jsonl", 5, new Date(now - 1000));
439
439
 
440
440
  // Total = 21 + 6 = 27 lines, maxLines = 10
441
- // Constructor should run cleanup immediately, deleting the old file
441
+ // Constructor should kick off cleanup immediately; it is async now, so
442
+ // await the coalesced in-flight pass before asserting.
442
443
  const mgr = new RecorderManager({
443
444
  globalEnabled: true,
444
445
  recordingsDir: tempDir,
445
446
  maxLines: 10,
446
447
  });
448
+ await mgr.cleanup();
447
449
 
448
450
  const remaining = readDirSafe(tempDir);
449
451
  expect(remaining.length).toBe(1);
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import type { BackendType } from "./session-types.js";
5
5
  import { COMPANION_HOME } from "./paths.js";
6
- import { countFileLines } from "./fs-utils.js";
6
+ import { countFileLines, countFileLinesCached, cachedFileLines, type FileLinesCacheEntry } from "./fs-utils.js";
7
7
 
8
8
  const DEFAULT_MAX_LINES = 1_000_000;
9
9
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
@@ -152,6 +152,13 @@ export class RecorderManager {
152
152
  private globalEnabled: boolean;
153
153
  private recordingsDir: string;
154
154
  private maxLines: number;
155
+ /** Stat-validated line-count cache: finished recordings are immutable, so
156
+ * only actively-written files are ever re-read. Keeps the periodic scan of
157
+ * a multi-hundred-MB recordings dir from stalling the event loop (which
158
+ * starved live CLI stdio streams). */
159
+ private lineCountCache = new Map<string, FileLinesCacheEntry>();
160
+ /** Serializes cleanup passes so a slow scan cannot overlap the next tick. */
161
+ private cleanupInFlight: Promise<number> | null = null;
155
162
  private perSessionEnabled = new Set<string>();
156
163
  private perSessionDisabled = new Set<string>();
157
164
  private recorders = new Map<string, SessionRecorder>();
@@ -174,8 +181,8 @@ export class RecorderManager {
174
181
 
175
182
  if (this.globalEnabled) {
176
183
  // Run cleanup at startup (async, non-blocking) and periodically
177
- this.cleanup();
178
- this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
184
+ void this.cleanup();
185
+ this.cleanupTimer = setInterval(() => void this.cleanup(), CLEANUP_INTERVAL_MS);
179
186
  if (this.cleanupTimer.unref) this.cleanupTimer.unref();
180
187
  }
181
188
  }
@@ -279,8 +286,10 @@ export class RecorderManager {
279
286
  if (firstUnderscore === -1 || secondUnderscore === -1) {
280
287
  return { filename, sessionId: "", backendType: "", startedAt: "", lines: 0 };
281
288
  }
282
- // Count lines — fast: just count newlines
283
- const lines = countFileLines(join(this.recordingsDir, filename));
289
+ // Count lines — prefer counts warmed by the periodic async scan;
290
+ // fall back to a direct count for files the scan has not seen yet.
291
+ const fullPath = join(this.recordingsDir, filename);
292
+ const lines = cachedFileLines(fullPath, this.lineCountCache) ?? countFileLines(fullPath);
284
293
  return {
285
294
  filename,
286
295
  sessionId: withoutExt.substring(0, firstUnderscore),
@@ -309,7 +318,20 @@ export class RecorderManager {
309
318
  * Delete oldest recording files until total lines are under maxLines.
310
319
  * Skips files that belong to active (currently recording) sessions.
311
320
  */
312
- cleanup(): number {
321
+ /**
322
+ * Async and cache-backed on purpose: the previous sync implementation
323
+ * re-read every byte of the recordings directory (385MB observed) on the
324
+ * event loop every 5 minutes. Overlapping passes are coalesced.
325
+ */
326
+ cleanup(): Promise<number> {
327
+ if (this.cleanupInFlight) return this.cleanupInFlight;
328
+ this.cleanupInFlight = this.runCleanup().finally(() => {
329
+ this.cleanupInFlight = null;
330
+ });
331
+ return this.cleanupInFlight;
332
+ }
333
+
334
+ private async runCleanup(): Promise<number> {
313
335
  try {
314
336
  this.ensureDir();
315
337
  const files = readdirSync(this.recordingsDir).filter((f) => f.endsWith(".jsonl"));
@@ -326,7 +348,7 @@ export class RecorderManager {
326
348
 
327
349
  for (const filename of files) {
328
350
  const fullPath = join(this.recordingsDir, filename);
329
- const lines = countFileLines(fullPath);
351
+ const lines = await countFileLinesCached(fullPath, this.lineCountCache);
330
352
  let mtimeMs = 0;
331
353
  try {
332
354
  mtimeMs = statSync(fullPath).mtimeMs;
@@ -349,6 +371,7 @@ export class RecorderManager {
349
371
  if (activeFiles.has(entry.path)) continue;
350
372
  try {
351
373
  unlinkSync(entry.path);
374
+ this.lineCountCache.delete(entry.path);
352
375
  totalLines -= entry.lines;
353
376
  deleted++;
354
377
  } catch {
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { exec } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
3
  import type { SessionState } from "./session-types.js";
4
4
  import { containerManager } from "./container-manager.js";
@@ -7,23 +7,38 @@ function shellEscapeSingle(value: string): string {
7
7
  return value.replace(/'/g, "'\\''");
8
8
  }
9
9
 
10
- function runGitCommand(sessionId: string, state: SessionState, command: string): string {
10
+ /**
11
+ * Run a git (or docker-wrapped git) command asynchronously.
12
+ *
13
+ * This used to be execSync. Resolving git info runs up to four commands per
14
+ * session and fires for EVERY session on browser connect — a browser
15
+ * reconnect storm over a dozen sessions meant dozens of back-to-back
16
+ * synchronous subprocess calls (3s timeout each) on the event loop. Those
17
+ * stalls starved live CLI stdio streams mid-turn, which the wedge detector
18
+ * then misread as dead transports. Async keeps the loop free; the git info
19
+ * lands a few ms later, which nothing here is sensitive to.
20
+ */
21
+ function runGitCommand(sessionId: string, state: SessionState, command: string): Promise<string> {
22
+ let fullCommand = command;
23
+ let cwd: string | undefined = state.cwd;
24
+
11
25
  if (state.is_containerized) {
12
26
  const container = containerManager.getContainer(sessionId);
13
- if (container?.containerId) {
14
- const containerCwd = container.containerCwd || "/workspace";
15
- const inner = `cd '${shellEscapeSingle(containerCwd)}' && ${command}`;
16
- const dockerCmd = `docker exec ${container.containerId} sh -lc ${JSON.stringify(inner)}`;
17
- return execSync(dockerCmd, { encoding: "utf-8", timeout: 3000 }).trim();
27
+ if (!container?.containerId) {
28
+ return Promise.reject(new Error("container not tracked"));
18
29
  }
19
- throw new Error("container not tracked");
30
+ const containerCwd = container.containerCwd || "/workspace";
31
+ const inner = `cd '${shellEscapeSingle(containerCwd)}' && ${command}`;
32
+ fullCommand = `docker exec ${container.containerId} sh -lc ${JSON.stringify(inner)}`;
33
+ cwd = undefined;
20
34
  }
21
35
 
22
- return execSync(command, {
23
- cwd: state.cwd,
24
- encoding: "utf-8",
25
- timeout: 3000,
26
- }).trim();
36
+ return new Promise((resolvePromise, rejectPromise) => {
37
+ exec(fullCommand, { cwd, encoding: "utf-8", timeout: 3000 }, (error, stdout) => {
38
+ if (error) rejectPromise(error);
39
+ else resolvePromise(stdout.trim());
40
+ });
41
+ });
27
42
  }
28
43
 
29
44
  function mapContainerPathToHost(sessionId: string, state: SessionState, pathValue: string): string {
@@ -39,7 +54,7 @@ function mapContainerPathToHost(sessionId: string, state: SessionState, pathValu
39
54
  return pathValue;
40
55
  }
41
56
 
42
- export function resolveSessionGitInfo(sessionId: string, state: SessionState): void {
57
+ export async function resolveSessionGitInfo(sessionId: string, state: SessionState): Promise<void> {
43
58
  if (!state.cwd) return;
44
59
  const wasContainerized = state.is_containerized;
45
60
  const previous = {
@@ -50,10 +65,10 @@ export function resolveSessionGitInfo(sessionId: string, state: SessionState): v
50
65
  git_behind: state.git_behind,
51
66
  };
52
67
  try {
53
- state.git_branch = runGitCommand(sessionId, state, "git rev-parse --abbrev-ref HEAD 2>/dev/null");
68
+ state.git_branch = await runGitCommand(sessionId, state, "git rev-parse --abbrev-ref HEAD 2>/dev/null");
54
69
 
55
70
  try {
56
- const gitDir = runGitCommand(sessionId, state, "git rev-parse --git-dir 2>/dev/null");
71
+ const gitDir = await runGitCommand(sessionId, state, "git rev-parse --git-dir 2>/dev/null");
57
72
  state.is_worktree = gitDir.includes("/worktrees/");
58
73
  } catch {
59
74
  state.is_worktree = false;
@@ -61,10 +76,10 @@ export function resolveSessionGitInfo(sessionId: string, state: SessionState): v
61
76
 
62
77
  try {
63
78
  if (state.is_worktree) {
64
- const commonDir = runGitCommand(sessionId, state, "git rev-parse --git-common-dir 2>/dev/null");
79
+ const commonDir = await runGitCommand(sessionId, state, "git rev-parse --git-common-dir 2>/dev/null");
65
80
  state.repo_root = resolve(state.cwd, commonDir, "..");
66
81
  } else {
67
- state.repo_root = runGitCommand(sessionId, state, "git rev-parse --show-toplevel 2>/dev/null");
82
+ state.repo_root = await runGitCommand(sessionId, state, "git rev-parse --show-toplevel 2>/dev/null");
68
83
  }
69
84
  state.repo_root = mapContainerPathToHost(sessionId, state, state.repo_root);
70
85
  } catch {
@@ -72,7 +87,7 @@ export function resolveSessionGitInfo(sessionId: string, state: SessionState): v
72
87
  }
73
88
 
74
89
  try {
75
- const counts = runGitCommand(
90
+ const counts = await runGitCommand(
76
91
  sessionId,
77
92
  state,
78
93
  "git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null",
@@ -17,7 +17,19 @@ if (typeof globalThis.Bun === "undefined") {
17
17
  }
18
18
 
19
19
  const mockExecSync = vi.hoisted(() => vi.fn());
20
- vi.mock("node:child_process", () => ({ execSync: mockExecSync }));
20
+ // session-git-info now uses async exec (the sync version stalled the event
21
+ // loop on browser-connect storms); delegate it to the same mockExecSync
22
+ // fixtures so every test keeps configuring git output in one place.
23
+ const mockExec = vi.hoisted(() => (cmd: string, opts: unknown, cb: (err: Error | null, stdout: string) => void) => {
24
+ process.nextTick(() => {
25
+ try {
26
+ cb(null, String(mockExecSync(cmd, opts)));
27
+ } catch (err) {
28
+ cb(err as Error, "");
29
+ }
30
+ });
31
+ });
32
+ vi.mock("node:child_process", () => ({ execSync: mockExecSync, exec: mockExec }));
21
33
  vi.mock("node:crypto", () => ({ randomUUID: () => "test-uuid" }));
22
34
 
23
35
  // Mock settings-manager to prevent AI validation from interfering with tests.
@@ -680,7 +692,7 @@ describe("CLI handlers", () => {
680
692
 
681
693
  const state = bridge.getSession("s1")!.state;
682
694
  expect(state.cwd).toBe("/Users/stan/Dev/myproject");
683
- expect(state.git_branch).toBe("container-branch");
695
+ await vi.waitFor(() => expect(state.git_branch).toBe("container-branch"));
684
696
  expect(state.repo_root).toBe("/Users/stan/Dev/myproject");
685
697
  expect(state.git_behind).toBe(1);
686
698
  expect(state.git_ahead).toBe(3);
@@ -716,7 +728,7 @@ describe("CLI handlers", () => {
716
728
  await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/workspace" }));
717
729
 
718
730
  const state = bridge.getSession("s1")!.state;
719
- expect(state.repo_root).toBe("/Users/stan/Dev/myproject/packages/api");
731
+ await vi.waitFor(() => expect(state.repo_root).toBe("/Users/stan/Dev/myproject/packages/api"));
720
732
  expect(getContainerSpy).toHaveBeenCalledWith("s1");
721
733
  getContainerSpy.mockRestore();
722
734
  });
@@ -733,8 +745,9 @@ describe("CLI handlers", () => {
733
745
  bridge.handleCLIOpen(cli, "s1");
734
746
  await bridge.handleCLIMessage(cli, makeInitMsg());
735
747
 
748
+ // Git info now resolves asynchronously (off the event loop) — wait for it.
749
+ await vi.waitFor(() => expect(bridge.getSession("s1")!.state.git_branch).toBe("feat/test-branch"));
736
750
  const state = bridge.getSession("s1")!.state;
737
- expect(state.git_branch).toBe("feat/test-branch");
738
751
  expect(state.repo_root).toBe("/repo");
739
752
  expect(state.git_ahead).toBe(5);
740
753
  expect(state.git_behind).toBe(2);
@@ -754,7 +767,7 @@ describe("CLI handlers", () => {
754
767
  await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/home/user/myproject" }));
755
768
 
756
769
  const state = bridge.getSession("s1")!.state;
757
- expect(state.repo_root).toBe("/home/user/myproject");
770
+ await vi.waitFor(() => expect(state.repo_root).toBe("/home/user/myproject"));
758
771
  });
759
772
 
760
773
  it("handleCLIMessage: system.status updates compacting and permissionMode", async () => {
@@ -1182,7 +1195,7 @@ describe("Browser handlers", () => {
1182
1195
  expect(firstMsg.session.session_id).toBe("s1");
1183
1196
  });
1184
1197
 
1185
- it("handleBrowserOpen: refreshes git branch before sending session snapshot", () => {
1198
+ it("handleBrowserOpen: refreshes git branch and broadcasts the update once it lands", async () => {
1186
1199
  mockExecSync.mockImplementation((cmd: string) => {
1187
1200
  if (cmd.includes("--abbrev-ref HEAD")) return "feat/dynamic-branch\n";
1188
1201
  if (cmd.includes("--git-dir")) return ".git\n";
@@ -1201,9 +1214,17 @@ describe("Browser handlers", () => {
1201
1214
  const browser = makeBrowserSocket("s1");
1202
1215
  bridge.handleBrowserOpen(browser, "s1");
1203
1216
 
1217
+ // The snapshot goes out immediately — git resolution no longer blocks the
1218
+ // event loop (sync git on browser-connect storms starved CLI stdio and got
1219
+ // healthy CLIs killed as "wedged"). The refreshed branch follows as a
1220
+ // session_update broadcast once the async git calls land.
1204
1221
  const firstMsg = JSON.parse(browser.send.mock.calls[0][0]);
1205
1222
  expect(firstMsg.type).toBe("session_init");
1206
- expect(firstMsg.session.git_branch).toBe("feat/dynamic-branch");
1223
+ await vi.waitFor(() => {
1224
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1225
+ const update = calls.find((c: { type: string }) => c.type === "session_update");
1226
+ expect(update?.session?.git_branch).toBe("feat/dynamic-branch");
1227
+ });
1207
1228
  expect(gitInfoCb).toHaveBeenCalledWith("s1", "/repo", "feat/dynamic-branch");
1208
1229
  });
1209
1230
 
@@ -1762,9 +1783,12 @@ describe("CLI message routing", () => {
1762
1783
 
1763
1784
  await bridge.handleCLIMessage(cli, msg);
1764
1785
 
1765
- const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1766
- const updateMsg = calls.find((c: any) => c.type === "session_update");
1767
- expect(updateMsg).toBeDefined();
1786
+ let updateMsg: any;
1787
+ await vi.waitFor(() => {
1788
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1789
+ updateMsg = calls.find((c: any) => c.type === "session_update");
1790
+ expect(updateMsg).toBeDefined();
1791
+ });
1768
1792
  expect(updateMsg.session.git_branch).toBe("feat/new-branch");
1769
1793
  expect(updateMsg.session.git_ahead).toBe(1);
1770
1794
  expect(bridge.getSession("s1")!.state.git_branch).toBe("feat/new-branch");
@@ -187,8 +187,10 @@ export class WsBridge {
187
187
  stateMachine: new SessionStateMachine(p.id, "terminated"),
188
188
  };
189
189
  session.state.backend_type = session.backendType;
190
- // Resolve git info for restored sessions (may have been persisted without it)
191
- resolveSessionGitInfo(session.id, session.state);
190
+ // Resolve git info for restored sessions (may have been persisted
191
+ // without it). Fire-and-forget: the info lands asynchronously and
192
+ // nothing in the restore path reads it synchronously.
193
+ void resolveSessionGitInfo(session.id, session.state);
192
194
  this.sessions.set(p.id, session);
193
195
  // Restored sessions with completed turns don't need auto-naming re-triggered
194
196
  if (session.state.num_turns > 0) {
@@ -207,10 +209,56 @@ export class WsBridge {
207
209
  persistSessionFn(session, this.store);
208
210
  }
209
211
 
212
+ /** In-flight git refresh bookkeeping per session: a refresh requested while
213
+ * one is running is coalesced into a single re-run (options OR-merged),
214
+ * because the running pass may be reading a state object that a
215
+ * `system.init`/`session_update` handler has since replaced. */
216
+ private gitRefreshPending = new Map<string, {
217
+ rerun: boolean;
218
+ options: { broadcastUpdate?: boolean; notifyPoller?: boolean };
219
+ }>();
220
+
221
+ /**
222
+ * Fire-and-forget: git resolution now runs async subprocesses, so this
223
+ * returns immediately and broadcasts/persists when the info lands. A
224
+ * browser reconnect storm used to trigger a burst of synchronous git calls
225
+ * (4 per session × every session) that stalled the event loop and starved
226
+ * live CLI stdio streams; concurrent refreshes for the same session are
227
+ * coalesced instead of stacking.
228
+ */
210
229
  private refreshGitInfo(
211
230
  session: Session,
212
231
  options: { broadcastUpdate?: boolean; notifyPoller?: boolean } = {},
213
232
  ): void {
233
+ const pending = this.gitRefreshPending.get(session.id);
234
+ if (pending) {
235
+ pending.rerun = true;
236
+ pending.options = {
237
+ broadcastUpdate: pending.options.broadcastUpdate || options.broadcastUpdate,
238
+ notifyPoller: pending.options.notifyPoller || options.notifyPoller,
239
+ };
240
+ return;
241
+ }
242
+ const entry = { rerun: false, options: { ...options } };
243
+ this.gitRefreshPending.set(session.id, entry);
244
+ void (async () => {
245
+ try {
246
+ do {
247
+ entry.rerun = false;
248
+ // Reads session.state fresh on every pass, so a re-run picks up a
249
+ // state object swapped in while the previous pass was mid-flight.
250
+ await this.doRefreshGitInfo(session, entry.options);
251
+ } while (entry.rerun);
252
+ } finally {
253
+ this.gitRefreshPending.delete(session.id);
254
+ }
255
+ })();
256
+ }
257
+
258
+ private async doRefreshGitInfo(
259
+ session: Session,
260
+ options: { broadcastUpdate?: boolean; notifyPoller?: boolean } = {},
261
+ ): Promise<void> {
214
262
  const before = {
215
263
  git_branch: session.state.git_branch,
216
264
  is_worktree: session.state.is_worktree,
@@ -220,7 +268,7 @@ export class WsBridge {
220
268
  git_behind: session.state.git_behind,
221
269
  };
222
270
 
223
- resolveSessionGitInfo(session.id, session.state);
271
+ await resolveSessionGitInfo(session.id, session.state);
224
272
 
225
273
  let changed = false;
226
274
  for (const key of WsBridge.GIT_SESSION_KEYS) {
@@ -449,7 +497,10 @@ export class WsBridge {
449
497
  // treated as an auth failure and denied keepalive relaunch. authBlocked
450
498
  // is re-set the moment another auth-error result arrives.
451
499
  session.authBlocked = false;
452
- this.refreshGitInfo(session, { notifyPoller: true });
500
+ // broadcastUpdate: the refresh is async now, so fresh git info can no
501
+ // longer ride along in the snapshot broadcast below — it follows as a
502
+ // session_update once the git calls land (only when something changed).
503
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
453
504
  this.broadcastToBrowsers(session, { type: "session_init", session: session.state });
454
505
  session.stateMachine.transition("ready", "system_init");
455
506
  this.persistSession(session);
@@ -467,7 +518,10 @@ export class WsBridge {
467
518
  ...(skills?.length ? { skills } : {}),
468
519
  backend_type: session.backendType,
469
520
  };
470
- this.refreshGitInfo(session, { notifyPoller: true });
521
+ // broadcastUpdate: the refresh is async now, so fresh git info can no
522
+ // longer ride along in the snapshot broadcast below — it follows as a
523
+ // session_update once the git calls land (only when something changed).
524
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
471
525
  this.persistSession(session);
472
526
  if (session.pendingMessages.length > 0 && adapter.isConnected()) {
473
527
  this.flushQueuedBrowserMessages(session, adapter, "backend_session_update");
@@ -1002,7 +1056,10 @@ export class WsBridge {
1002
1056
  this.stopIdleKillWatchdog(sessionId);
1003
1057
 
1004
1058
  // Refresh git state on browser connect so branch changes made mid-session are reflected.
1005
- this.refreshGitInfo(session, { notifyPoller: true });
1059
+ // broadcastUpdate: the refresh is async now, so fresh git info can no
1060
+ // longer ride along in the snapshot broadcast below — it follows as a
1061
+ // session_update once the git calls land (only when something changed).
1062
+ this.refreshGitInfo(session, { broadcastUpdate: true, notifyPoller: true });
1006
1063
 
1007
1064
  // Send current session state as snapshot
1008
1065
  const snapshot: BrowserIncomingMessage = {