@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61

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/src/client.ts CHANGED
@@ -1,38 +1,87 @@
1
1
  /**
2
2
  * Daemon client: the one library every consumer shares - CLI verbs, the TUI,
3
- * adapters, and tests. Also owns the lazy-launch story (#14): connect() with
3
+ * adapters, and tests. Also owns the lazy-launch story: connect() with
4
4
  * autostart spawns a detached daemon when the socket is dead, then attaches.
5
5
  */
6
6
 
7
7
  import { existsSync, rmSync } from "node:fs";
8
- import type { Annotation, Artifact, ReviewSession, VerdictKind, WorkspaceKey } from "@cueloop/schema";
9
- import { LineBuffer, type EventFrame, type Response } from "./protocol";
8
+ import type {
9
+ Annotation,
10
+ Artifact,
11
+ Identity,
12
+ ReviewSession,
13
+ VerdictKind,
14
+ WorkspaceKey,
15
+ } from "@cueloop/schema";
16
+ import { BackpressureWriter, LineBuffer, type EventFrame, type Response } from "./protocol";
17
+ import type { DaemonRole } from "./capabilities";
18
+ import type { HerdrTabHandle } from "./herdr-tab-store";
10
19
  import { cueloopHome, socketPath } from "./paths";
11
20
 
21
+ export type { EventFrame } from "./protocol";
22
+
12
23
  export interface ConnectOptions {
13
24
  home?: string;
14
25
  /** Spawn the daemon when the socket is not alive. */
15
26
  autostart?: boolean;
27
+ /** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
28
+ role?: DaemonRole;
16
29
  }
17
30
 
18
- type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
31
+ type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
19
32
 
20
- export class DaemonClient {
33
+ /**
34
+ * The session verbs the review controller drives. DaemonClient is the local
35
+ * implementation (unix socket); the sharing gateway supplies an in-memory,
36
+ * blob-backed one. Depending on this interface - not DaemonClient - is what
37
+ * lets the same <App> render a local session or a decrypted share unchanged.
38
+ */
39
+ export interface SessionClient {
40
+ onEvent(listener: (event: EventFrame) => void): () => void;
41
+ subscribe(): Promise<void>;
42
+ sessionGet(id: string): Promise<ReviewSession>;
43
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]>;
44
+ sessionAnnotate(
45
+ id: string,
46
+ annotation: Omit<Annotation, "createdAt">,
47
+ authorName?: string,
48
+ ): Promise<ReviewSession>;
49
+ sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession>;
50
+ sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession>;
51
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
52
+ sessionSetShareId(id: string, shareId: string): Promise<ReviewSession>;
53
+ sessionMergeShared(
54
+ id: string,
55
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
56
+ ): Promise<ReviewSession>;
57
+ sessionDelete(id: string): Promise<void>;
58
+ /** Record the caller's own identity name (collaborator self-naming on a share). */
59
+ sessionSetSelfName(id: string, name: string): Promise<ReviewSession>;
60
+ sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession>;
61
+ close(): void;
62
+ }
63
+
64
+ export class DaemonClient implements SessionClient {
21
65
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
22
- private pending = new Map<number, Pending>();
66
+ private writer: BackpressureWriter | null = null;
67
+ private pending = new Map<number, PendingRequest>();
23
68
  private nextId = 1;
24
- private eventListeners = new Set<(e: EventFrame) => void>();
69
+ private eventListeners = new Set<(event: EventFrame) => void>();
25
70
  private closed = false;
71
+ private role: DaemonRole = "owner";
26
72
 
27
- static async connect(opts: ConnectOptions = {}): Promise<DaemonClient> {
28
- const home = opts.home ?? cueloopHome();
73
+ static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
74
+ const home = options.home ?? cueloopHome();
29
75
  const path = socketPath(home);
30
76
  const client = new DaemonClient();
77
+
78
+ client.role = options.role ?? "owner";
31
79
  try {
32
80
  await client.dial(path);
81
+
33
82
  return client;
34
83
  } catch (err) {
35
- if (!opts.autostart) throw err;
84
+ if (!options.autostart) throw err;
36
85
  }
37
86
  // Socket dead or absent: clean a stale file and spawn the daemon detached.
38
87
  if (existsSync(path)) rmSync(path, { force: true });
@@ -40,83 +89,99 @@ export class DaemonClient {
40
89
  // Generous: a cold or loaded machine pays for a runtime start before the
41
90
  // socket exists, and giving up early looks to callers like a broken daemon.
42
91
  const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
43
- let lastErr: unknown;
92
+ let lastError: unknown;
93
+
44
94
  while (Date.now() < deadline) {
45
95
  try {
46
96
  await client.dial(path);
97
+
47
98
  return client;
48
99
  } catch (err) {
49
- lastErr = err;
100
+ lastError = err;
50
101
  await Bun.sleep(50);
51
102
  }
52
103
  }
53
- throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
104
+ throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
54
105
  }
55
106
 
56
107
  private async dial(path: string): Promise<void> {
57
108
  const buffer = new LineBuffer();
58
- const self = this;
109
+
59
110
  this.socket = await Bun.connect({
60
111
  unix: path,
61
112
  socket: {
62
- data(_socket, data) {
63
- buffer.push(data.toString(), (line) => self.handleFrame(line));
113
+ data: (_socket, data) => {
114
+ buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
115
+ },
116
+ drain: () => {
117
+ this.writer?.drain();
64
118
  },
65
- close() {
66
- self.closed = true;
67
- for (const p of self.pending.values()) p.reject(new Error("daemon connection closed"));
68
- self.pending.clear();
119
+ close: () => {
120
+ this.closed = true;
121
+ for (const pendingRequest of this.pending.values())
122
+ pendingRequest.reject(new Error("daemon connection closed"));
123
+ this.pending.clear();
69
124
  },
70
125
  error() {},
71
126
  },
72
127
  });
128
+ this.writer = new BackpressureWriter(this.socket);
73
129
  // Verify liveness: a dead socket file accepts connects on some platforms
74
130
  // only to fail later, so a ping is the actual handshake.
75
131
  await this.request("daemon.ping", {}, 2_000);
132
+ // Cap this connection's role for the daemon's capability gate (owner is the default).
133
+ if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
76
134
  }
77
135
 
78
- onEvent(listener: (e: EventFrame) => void): () => void {
136
+ onEvent(listener: (event: EventFrame) => void): () => void {
79
137
  this.eventListeners.add(listener);
138
+
80
139
  return () => this.eventListeners.delete(listener);
81
140
  }
82
141
 
83
- private handleFrame(line: string): void {
142
+ private routeInboundFrame(line: string): void {
84
143
  let frame: Response | EventFrame;
144
+
85
145
  try {
86
146
  frame = JSON.parse(line) as Response | EventFrame;
87
147
  } catch {
88
148
  return;
89
149
  }
90
150
  if ("event" in frame) {
91
- for (const l of this.eventListeners) l(frame);
151
+ for (const listener of this.eventListeners) listener(frame);
152
+
92
153
  return;
93
154
  }
94
- const p = this.pending.get(frame.id);
95
- if (!p) return;
155
+ const pendingRequest = this.pending.get(frame.id);
156
+
157
+ if (!pendingRequest) return;
96
158
  this.pending.delete(frame.id);
97
- if (frame.error) p.reject(new DaemonClientError(frame.error.code, frame.error.message));
98
- else p.resolve(frame.result);
159
+ if (frame.error)
160
+ pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
161
+ else pendingRequest.resolve(frame.result);
99
162
  }
100
163
 
101
164
  request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
102
165
  if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
103
166
  const id = this.nextId++;
167
+
104
168
  return new Promise<T>((resolve, reject) => {
105
169
  const timer = setTimeout(() => {
106
170
  this.pending.delete(id);
107
171
  reject(new Error(`request ${method} timed out`));
108
172
  }, timeoutMs);
173
+
109
174
  this.pending.set(id, {
110
- resolve: (v) => {
175
+ resolve: (value) => {
111
176
  clearTimeout(timer);
112
- resolve(v as T);
177
+ resolve(value as T);
113
178
  },
114
- reject: (e) => {
179
+ reject: (error) => {
115
180
  clearTimeout(timer);
116
- reject(e);
181
+ reject(error);
117
182
  },
118
183
  });
119
- this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
184
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
120
185
  });
121
186
  }
122
187
 
@@ -145,8 +210,12 @@ export class DaemonClient {
145
210
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
146
211
  return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
147
212
  }
148
- sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession> {
149
- return this.request("session.annotate", { id, annotation });
213
+ sessionAnnotate(
214
+ id: string,
215
+ annotation: Omit<Annotation, "createdAt">,
216
+ authorName?: string,
217
+ ): Promise<ReviewSession> {
218
+ return this.request("session.annotate", { id, annotation, authorName });
150
219
  }
151
220
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
152
221
  return this.request("session.removeAnnotation", { id, annotationId });
@@ -154,11 +223,45 @@ export class DaemonClient {
154
223
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
155
224
  return this.request("session.setWorkingCopy", { id, workingCopy });
156
225
  }
226
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
227
+ return this.request("session.setViewed", { id, viewedPaths });
228
+ }
229
+ /** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
230
+ sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
231
+ return this.request("session.refreshDiff", { id });
232
+ }
233
+ sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
234
+ return this.request("session.setShareId", { id, shareId });
235
+ }
236
+ sessionMergeShared(
237
+ id: string,
238
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
239
+ ): Promise<ReviewSession> {
240
+ return this.request("session.mergeShared", { id, ...incoming });
241
+ }
242
+ sessionDelete(id: string): Promise<void> {
243
+ return this.request("session.delete", { id });
244
+ }
245
+ /** Local sessions have no collaborator self-name; the share client owns this. */
246
+ sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
247
+ return this.sessionGet(id);
248
+ }
157
249
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
158
250
  return this.request("session.resolve", { id, verdictKind, summary });
159
251
  }
160
- sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
161
- return this.request("session.submitRevision", { id, content });
252
+ sessionSubmitRevision(
253
+ id: string,
254
+ content: string,
255
+ addressedAnnotationIds: string[] = [],
256
+ ): Promise<ReviewSession> {
257
+ return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
258
+ }
259
+ /** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
260
+ herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
261
+ return this.request("herdr.getTab", { id });
262
+ }
263
+ async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
264
+ await this.request("herdr.setTab", { id, ...handle });
162
265
  }
163
266
  shutdown(): Promise<void> {
164
267
  return this.request("daemon.shutdown", {});
@@ -174,9 +277,19 @@ export class DaemonClientError extends Error {
174
277
  }
175
278
  }
176
279
 
280
+ // A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
281
+ // unlike the never-exiting foreground daemon); from source, bun runs main.ts.
282
+ export function daemonSpawnCommand(execPath: string, moduleUrl: string): string[] {
283
+ const compiled =
284
+ moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
285
+
286
+ return compiled
287
+ ? [execPath, "daemon", "--autostart"]
288
+ : [execPath, "run", new URL("./main.ts", moduleUrl).pathname];
289
+ }
290
+
177
291
  function spawnDaemon(home: string): void {
178
- const entry = new URL("./main.ts", import.meta.url).pathname;
179
- Bun.spawn([process.execPath, "run", entry], {
292
+ Bun.spawn(daemonSpawnCommand(process.execPath, import.meta.url), {
180
293
  env: { ...process.env, CUELOOP_HOME: home },
181
294
  stdio: ["ignore", "ignore", "ignore"],
182
295
  }).unref();
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Diff hot-reload: session.refreshDiff re-captures a diff session's working
3
+ * tree, and the fs watcher drives it on a real change. Exercised against a
4
+ * real temp git repo so the capture path is the production one.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { DaemonCore } from "./api";
12
+ import { workingTreeDiff } from "./working-tree";
13
+ import { resolveWorkspace } from "./review";
14
+ import type { Artifact } from "@cueloop/schema";
15
+
16
+ function git(args: string[], cwd: string): void {
17
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
18
+
19
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
20
+ }
21
+
22
+ let home: string;
23
+ let repo: string;
24
+ let core: DaemonCore;
25
+
26
+ beforeEach(() => {
27
+ home = mkdtempSync(join(tmpdir(), "cueloop-hotreload-home-"));
28
+ repo = mkdtempSync(join(tmpdir(), "cueloop-hotreload-repo-"));
29
+ git(["init", "-q", "-b", "main"], repo);
30
+ git(["config", "user.email", "t@t"], repo);
31
+ git(["config", "user.name", "t"], repo);
32
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
33
+ git(["add", "."], repo);
34
+ git(["commit", "-qm", "init"], repo);
35
+ core = new DaemonCore(home);
36
+ });
37
+
38
+ afterEach(() => {
39
+ core.dispose();
40
+ rmSync(home, { recursive: true, force: true });
41
+ rmSync(repo, { recursive: true, force: true });
42
+ });
43
+
44
+ /** Open a diff session over the temp repo's current working tree. */
45
+ async function openDiffSession() {
46
+ const workspace = await resolveWorkspace(repo);
47
+ const diff = await workingTreeDiff(repo);
48
+ const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
49
+
50
+ return core.sessionCreate({ workspace, artifact });
51
+ }
52
+
53
+ describe("session.refreshDiff", () => {
54
+ test("re-captures the working tree and updates the artifact when the patch changed", async () => {
55
+ // Given an open diff session whose working tree then gains a new change
56
+ const session = await openDiffSession();
57
+
58
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
59
+
60
+ // When the diff is refreshed
61
+ const result = await core.sessionRefreshDiff(session.id);
62
+
63
+ // Then the change is reported and the stored artifact carries the fresh patch and files
64
+ expect(result.changed).toBe(true);
65
+ const refreshed = core.sessionGet(session.id);
66
+
67
+ expect(refreshed.artifact.content).toContain("+export const a = 2;");
68
+ const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
69
+
70
+ expect(modified.newContents).toBe("export const a = 2;\n");
71
+ });
72
+
73
+ test("reports no change and emits nothing when the working tree is unchanged", async () => {
74
+ // Given an open diff session and a listener for session events
75
+ const session = await openDiffSession();
76
+ const events: string[] = [];
77
+
78
+ core.onEvent((event) => events.push(event.event));
79
+
80
+ // When the diff is refreshed without any working-tree change
81
+ const result = await core.sessionRefreshDiff(session.id);
82
+
83
+ // Then nothing changed and no session.updated event fired
84
+ expect(result.changed).toBe(false);
85
+ expect(events).not.toContain("session.updated");
86
+ });
87
+
88
+ test("a changed refresh emits session.updated so attached clients re-fetch", async () => {
89
+ // Given an open diff session, a listener, and a fresh working-tree change
90
+ const session = await openDiffSession();
91
+ const events: string[] = [];
92
+
93
+ core.onEvent((event) => events.push(event.event));
94
+ writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
95
+
96
+ // When the diff is refreshed
97
+ await core.sessionRefreshDiff(session.id);
98
+
99
+ // Then a session.updated event fired for this session
100
+ expect(events).toContain("session.updated");
101
+ });
102
+
103
+ test("is a no-op for a non-diff (plan) session", async () => {
104
+ // Given a plan session in the same workspace
105
+ const workspace = await resolveWorkspace(repo);
106
+ const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
107
+ const session = core.sessionCreate({ workspace, artifact: plan });
108
+
109
+ // When a refresh is attempted
110
+ const result = await core.sessionRefreshDiff(session.id);
111
+
112
+ // Then it reports no change and leaves the plan content untouched
113
+ expect(result.changed).toBe(false);
114
+ expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
115
+ });
116
+
117
+ test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
118
+ // Given a diff session that has been resolved
119
+ const session = await openDiffSession();
120
+
121
+ core.sessionResolve(session.id, "approve", "");
122
+ const resolvedContent = core.sessionGet(session.id).artifact.content;
123
+
124
+ writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
125
+
126
+ // When a refresh is attempted on the resolved session
127
+ // Then it is rejected and the frozen artifact is untouched
128
+ expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
129
+ expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
130
+ });
131
+
132
+ test("overlapping refreshes settle to one coherent artifact without error", async () => {
133
+ // Given a live diff session with a fresh working-tree change
134
+ const session = await openDiffSession();
135
+
136
+ writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
137
+
138
+ // When two refreshes run concurrently over the same session
139
+ const [first, second] = await Promise.all([
140
+ core.sessionRefreshDiff(session.id),
141
+ core.sessionRefreshDiff(session.id),
142
+ ]);
143
+
144
+ // Then both settle and the stored artifact reflects the current tree exactly
145
+ // once - the generation guard keeps the older capture from regressing it
146
+ expect(first.changed || second.changed).toBe(true);
147
+ expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
148
+ });
149
+ });
150
+
151
+ describe("the fs watcher drives hot-reload", () => {
152
+ test("a working-tree change under a live diff session refreshes it in place", async () => {
153
+ // Given a live diff session whose repo the daemon is watching
154
+ const session = await openDiffSession();
155
+
156
+ // When a tracked file changes on disk
157
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
158
+
159
+ // Then the watcher debounces and re-captures the diff without any manual call.
160
+ // Watchers can be slow under CI contention, so poll generously.
161
+ const deadline = Date.now() + 8_000;
162
+ let content = core.sessionGet(session.id).artifact.content;
163
+
164
+ while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
165
+ await Bun.sleep(100);
166
+ content = core.sessionGet(session.id).artifact.content;
167
+ }
168
+ expect(content).toContain("+export const a = 42;");
169
+ }, 12_000);
170
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Watches a diff session's repository for working-tree changes and drives the
3
+ * hot-reload: on any change under a repo root that a live diff session tracks,
4
+ * the daemon re-captures that session's diff so the open review updates in place.
5
+ * One recursive fs watcher per repo root - many diff sessions can share a repo -
6
+ * with a debounce so a burst of file writes triggers a single re-capture.
7
+ */
8
+
9
+ import { watch, type FSWatcher } from "node:fs";
10
+ import { sep } from "node:path";
11
+
12
+ /** Debounce window: a save or a checkout writes many files in a burst; collapse them into one re-capture. */
13
+ const DIFF_REFRESH_DEBOUNCE_MS = 300;
14
+
15
+ /**
16
+ * Paths whose churn must never trigger a diff refresh. git rewrites `.git`
17
+ * metadata (the index, lock files) on every read `cueloop diff` itself runs, so
18
+ * watching it would loop; `node_modules` churns on installs and is never review
19
+ * content.
20
+ */
21
+ function isIgnoredWatchPath(relativePath: string): boolean {
22
+ const segments = relativePath.split(sep);
23
+
24
+ return segments.includes(".git") || segments.includes("node_modules");
25
+ }
26
+
27
+ interface RepoWatch {
28
+ handle: FSWatcher;
29
+ /** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
30
+ sessionIds: Set<string>;
31
+ debounce: ReturnType<typeof setTimeout> | null;
32
+ }
33
+
34
+ /**
35
+ * Owns the fs watchers behind diff hot-reload. `onRepoChange` fires once per
36
+ * debounced burst with the repo root that changed; the caller re-captures every
37
+ * live diff session on that root. A watcher error or an unwatchable root is
38
+ * swallowed - hot-reload is best-effort and must never crash the daemon.
39
+ */
40
+ export class DiffWatcher {
41
+ private readonly repoWatches = new Map<string, RepoWatch>();
42
+
43
+ constructor(private readonly onRepoChange: (repoRoot: string) => void) {}
44
+
45
+ /** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
46
+ trackDiffRepo(repoRoot: string, sessionId: string): void {
47
+ const existing = this.repoWatches.get(repoRoot);
48
+
49
+ if (existing) {
50
+ existing.sessionIds.add(sessionId);
51
+
52
+ return;
53
+ }
54
+ let handle: FSWatcher;
55
+
56
+ try {
57
+ handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
58
+ if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
59
+ this.scheduleRepoRefresh(repoRoot);
60
+ });
61
+ } catch {
62
+ // repo root gone or not watchable on this platform: skip, no hot-reload here
63
+ return;
64
+ }
65
+ // a watcher error (the root is deleted mid-review) must not take down the
66
+ // daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
67
+ (handle as FSWatcher & { on(event: "error", listener: (error: Error) => void): void }).on(
68
+ "error",
69
+ () => {},
70
+ );
71
+ this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
72
+ }
73
+
74
+ /** Drop one diff session; close the repo watch once no diff session tracks it. */
75
+ untrackDiffRepo(repoRoot: string, sessionId: string): void {
76
+ const repoWatch = this.repoWatches.get(repoRoot);
77
+
78
+ if (!repoWatch) return;
79
+ repoWatch.sessionIds.delete(sessionId);
80
+ if (repoWatch.sessionIds.size > 0) return;
81
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
82
+ repoWatch.handle.close();
83
+ this.repoWatches.delete(repoRoot);
84
+ }
85
+
86
+ private scheduleRepoRefresh(repoRoot: string): void {
87
+ const repoWatch = this.repoWatches.get(repoRoot);
88
+
89
+ if (!repoWatch) return;
90
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
91
+ repoWatch.debounce = setTimeout(() => {
92
+ repoWatch.debounce = null;
93
+ this.onRepoChange(repoRoot);
94
+ }, DIFF_REFRESH_DEBOUNCE_MS);
95
+ }
96
+
97
+ /** Close every watcher and cancel pending debounces (daemon shutdown). */
98
+ close(): void {
99
+ for (const repoWatch of this.repoWatches.values()) {
100
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
101
+ repoWatch.handle.close();
102
+ }
103
+ this.repoWatches.clear();
104
+ }
105
+ }