@cueloop/daemon 0.1.0-alpha.5 → 0.1.0-alpha.50

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,85 @@
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
+ client.role = options.role ?? "owner";
31
78
  try {
32
79
  await client.dial(path);
33
80
  return client;
34
81
  } catch (err) {
35
- if (!opts.autostart) throw err;
82
+ if (!options.autostart) throw err;
36
83
  }
37
84
  // Socket dead or absent: clean a stale file and spawn the daemon detached.
38
85
  if (existsSync(path)) rmSync(path, { force: true });
@@ -40,47 +87,53 @@ export class DaemonClient {
40
87
  // Generous: a cold or loaded machine pays for a runtime start before the
41
88
  // socket exists, and giving up early looks to callers like a broken daemon.
42
89
  const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
43
- let lastErr: unknown;
90
+ let lastError: unknown;
44
91
  while (Date.now() < deadline) {
45
92
  try {
46
93
  await client.dial(path);
47
94
  return client;
48
95
  } catch (err) {
49
- lastErr = err;
96
+ lastError = err;
50
97
  await Bun.sleep(50);
51
98
  }
52
99
  }
53
- throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
100
+ throw new Error(`daemon did not come up at ${path}: ${String(lastError)}`);
54
101
  }
55
102
 
56
103
  private async dial(path: string): Promise<void> {
57
104
  const buffer = new LineBuffer();
58
- const self = this;
59
105
  this.socket = await Bun.connect({
60
106
  unix: path,
61
107
  socket: {
62
- data(_socket, data) {
63
- buffer.push(data.toString(), (line) => self.handleFrame(line));
108
+ data: (_socket, data) => {
109
+ buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
110
+ },
111
+ drain: () => {
112
+ this.writer?.drain();
64
113
  },
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();
114
+ close: () => {
115
+ this.closed = true;
116
+ for (const pendingRequest of this.pending.values())
117
+ pendingRequest.reject(new Error("daemon connection closed"));
118
+ this.pending.clear();
69
119
  },
70
120
  error() {},
71
121
  },
72
122
  });
123
+ this.writer = new BackpressureWriter(this.socket);
73
124
  // Verify liveness: a dead socket file accepts connects on some platforms
74
125
  // only to fail later, so a ping is the actual handshake.
75
126
  await this.request("daemon.ping", {}, 2_000);
127
+ // Cap this connection's role for the daemon's capability gate (owner is the default).
128
+ if (this.role !== "owner") await this.request("daemon.hello", { role: this.role }, 2_000);
76
129
  }
77
130
 
78
- onEvent(listener: (e: EventFrame) => void): () => void {
131
+ onEvent(listener: (event: EventFrame) => void): () => void {
79
132
  this.eventListeners.add(listener);
80
133
  return () => this.eventListeners.delete(listener);
81
134
  }
82
135
 
83
- private handleFrame(line: string): void {
136
+ private routeInboundFrame(line: string): void {
84
137
  let frame: Response | EventFrame;
85
138
  try {
86
139
  frame = JSON.parse(line) as Response | EventFrame;
@@ -88,14 +141,15 @@ export class DaemonClient {
88
141
  return;
89
142
  }
90
143
  if ("event" in frame) {
91
- for (const l of this.eventListeners) l(frame);
144
+ for (const listener of this.eventListeners) listener(frame);
92
145
  return;
93
146
  }
94
- const p = this.pending.get(frame.id);
95
- if (!p) return;
147
+ const pendingRequest = this.pending.get(frame.id);
148
+ if (!pendingRequest) return;
96
149
  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);
150
+ if (frame.error)
151
+ pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
152
+ else pendingRequest.resolve(frame.result);
99
153
  }
100
154
 
101
155
  request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
@@ -107,16 +161,16 @@ export class DaemonClient {
107
161
  reject(new Error(`request ${method} timed out`));
108
162
  }, timeoutMs);
109
163
  this.pending.set(id, {
110
- resolve: (v) => {
164
+ resolve: (value) => {
111
165
  clearTimeout(timer);
112
- resolve(v as T);
166
+ resolve(value as T);
113
167
  },
114
- reject: (e) => {
168
+ reject: (error) => {
115
169
  clearTimeout(timer);
116
- reject(e);
170
+ reject(error);
117
171
  },
118
172
  });
119
- this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
173
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
120
174
  });
121
175
  }
122
176
 
@@ -145,8 +199,12 @@ export class DaemonClient {
145
199
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
146
200
  return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
147
201
  }
148
- sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession> {
149
- return this.request("session.annotate", { id, annotation });
202
+ sessionAnnotate(
203
+ id: string,
204
+ annotation: Omit<Annotation, "createdAt">,
205
+ authorName?: string,
206
+ ): Promise<ReviewSession> {
207
+ return this.request("session.annotate", { id, annotation, authorName });
150
208
  }
151
209
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
152
210
  return this.request("session.removeAnnotation", { id, annotationId });
@@ -154,11 +212,45 @@ export class DaemonClient {
154
212
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
155
213
  return this.request("session.setWorkingCopy", { id, workingCopy });
156
214
  }
215
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
216
+ return this.request("session.setViewed", { id, viewedPaths });
217
+ }
218
+ /** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
219
+ sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
220
+ return this.request("session.refreshDiff", { id });
221
+ }
222
+ sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
223
+ return this.request("session.setShareId", { id, shareId });
224
+ }
225
+ sessionMergeShared(
226
+ id: string,
227
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
228
+ ): Promise<ReviewSession> {
229
+ return this.request("session.mergeShared", { id, ...incoming });
230
+ }
231
+ sessionDelete(id: string): Promise<void> {
232
+ return this.request("session.delete", { id });
233
+ }
234
+ /** Local sessions have no collaborator self-name; the share client owns this. */
235
+ sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
236
+ return this.sessionGet(id);
237
+ }
157
238
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
158
239
  return this.request("session.resolve", { id, verdictKind, summary });
159
240
  }
160
- sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
161
- return this.request("session.submitRevision", { id, content });
241
+ sessionSubmitRevision(
242
+ id: string,
243
+ content: string,
244
+ addressedAnnotationIds: string[] = [],
245
+ ): Promise<ReviewSession> {
246
+ return this.request("session.submitRevision", { id, content, addressedAnnotationIds });
247
+ }
248
+ /** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
249
+ herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
250
+ return this.request("herdr.getTab", { id });
251
+ }
252
+ async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
253
+ await this.request("herdr.setTab", { id, ...handle });
162
254
  }
163
255
  shutdown(): Promise<void> {
164
256
  return this.request("daemon.shutdown", {});
@@ -0,0 +1,159 @@
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
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
19
+ }
20
+
21
+ let home: string;
22
+ let repo: string;
23
+ let core: DaemonCore;
24
+
25
+ beforeEach(() => {
26
+ home = mkdtempSync(join(tmpdir(), "cueloop-hotreload-home-"));
27
+ repo = mkdtempSync(join(tmpdir(), "cueloop-hotreload-repo-"));
28
+ git(["init", "-q", "-b", "main"], repo);
29
+ git(["config", "user.email", "t@t"], repo);
30
+ git(["config", "user.name", "t"], repo);
31
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
32
+ git(["add", "."], repo);
33
+ git(["commit", "-qm", "init"], repo);
34
+ core = new DaemonCore(home);
35
+ });
36
+
37
+ afterEach(() => {
38
+ core.dispose();
39
+ rmSync(home, { recursive: true, force: true });
40
+ rmSync(repo, { recursive: true, force: true });
41
+ });
42
+
43
+ /** Open a diff session over the temp repo's current working tree. */
44
+ async function openDiffSession() {
45
+ const workspace = await resolveWorkspace(repo);
46
+ const diff = await workingTreeDiff(repo);
47
+ const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
48
+ return core.sessionCreate({ workspace, artifact });
49
+ }
50
+
51
+ describe("session.refreshDiff", () => {
52
+ test("re-captures the working tree and updates the artifact when the patch changed", async () => {
53
+ // Given an open diff session whose working tree then gains a new change
54
+ const session = await openDiffSession();
55
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
56
+
57
+ // When the diff is refreshed
58
+ const result = await core.sessionRefreshDiff(session.id);
59
+
60
+ // Then the change is reported and the stored artifact carries the fresh patch and files
61
+ expect(result.changed).toBe(true);
62
+ const refreshed = core.sessionGet(session.id);
63
+ expect(refreshed.artifact.content).toContain("+export const a = 2;");
64
+ const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
65
+ expect(modified.newContents).toBe("export const a = 2;\n");
66
+ });
67
+
68
+ test("reports no change and emits nothing when the working tree is unchanged", async () => {
69
+ // Given an open diff session and a listener for session events
70
+ const session = await openDiffSession();
71
+ const events: string[] = [];
72
+ core.onEvent((event) => events.push(event.event));
73
+
74
+ // When the diff is refreshed without any working-tree change
75
+ const result = await core.sessionRefreshDiff(session.id);
76
+
77
+ // Then nothing changed and no session.updated event fired
78
+ expect(result.changed).toBe(false);
79
+ expect(events).not.toContain("session.updated");
80
+ });
81
+
82
+ test("a changed refresh emits session.updated so attached clients re-fetch", async () => {
83
+ // Given an open diff session, a listener, and a fresh working-tree change
84
+ const session = await openDiffSession();
85
+ const events: string[] = [];
86
+ core.onEvent((event) => events.push(event.event));
87
+ writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
88
+
89
+ // When the diff is refreshed
90
+ await core.sessionRefreshDiff(session.id);
91
+
92
+ // Then a session.updated event fired for this session
93
+ expect(events).toContain("session.updated");
94
+ });
95
+
96
+ test("is a no-op for a non-diff (plan) session", async () => {
97
+ // Given a plan session in the same workspace
98
+ const workspace = await resolveWorkspace(repo);
99
+ const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
100
+ const session = core.sessionCreate({ workspace, artifact: plan });
101
+
102
+ // When a refresh is attempted
103
+ const result = await core.sessionRefreshDiff(session.id);
104
+
105
+ // Then it reports no change and leaves the plan content untouched
106
+ expect(result.changed).toBe(false);
107
+ expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
108
+ });
109
+
110
+ test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
111
+ // Given a diff session that has been resolved
112
+ const session = await openDiffSession();
113
+ core.sessionResolve(session.id, "approve", "");
114
+ const resolvedContent = core.sessionGet(session.id).artifact.content;
115
+ writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
116
+
117
+ // When a refresh is attempted on the resolved session
118
+ // Then it is rejected and the frozen artifact is untouched
119
+ expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
120
+ expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
121
+ });
122
+
123
+ test("overlapping refreshes settle to one coherent artifact without error", async () => {
124
+ // Given a live diff session with a fresh working-tree change
125
+ const session = await openDiffSession();
126
+ writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
127
+
128
+ // When two refreshes run concurrently over the same session
129
+ const [first, second] = await Promise.all([
130
+ core.sessionRefreshDiff(session.id),
131
+ core.sessionRefreshDiff(session.id),
132
+ ]);
133
+
134
+ // Then both settle and the stored artifact reflects the current tree exactly
135
+ // once - the generation guard keeps the older capture from regressing it
136
+ expect(first.changed || second.changed).toBe(true);
137
+ expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
138
+ });
139
+ });
140
+
141
+ describe("the fs watcher drives hot-reload", () => {
142
+ test("a working-tree change under a live diff session refreshes it in place", async () => {
143
+ // Given a live diff session whose repo the daemon is watching
144
+ const session = await openDiffSession();
145
+
146
+ // When a tracked file changes on disk
147
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
148
+
149
+ // Then the watcher debounces and re-captures the diff without any manual call.
150
+ // Watchers can be slow under CI contention, so poll generously.
151
+ const deadline = Date.now() + 8_000;
152
+ let content = core.sessionGet(session.id).artifact.content;
153
+ while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
154
+ await Bun.sleep(100);
155
+ content = core.sessionGet(session.id).artifact.content;
156
+ }
157
+ expect(content).toContain("+export const a = 42;");
158
+ }, 12_000);
159
+ });
@@ -0,0 +1,99 @@
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
+ return segments.includes(".git") || segments.includes("node_modules");
24
+ }
25
+
26
+ interface RepoWatch {
27
+ handle: FSWatcher;
28
+ /** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
29
+ sessionIds: Set<string>;
30
+ debounce: ReturnType<typeof setTimeout> | null;
31
+ }
32
+
33
+ /**
34
+ * Owns the fs watchers behind diff hot-reload. `onRepoChange` fires once per
35
+ * debounced burst with the repo root that changed; the caller re-captures every
36
+ * live diff session on that root. A watcher error or an unwatchable root is
37
+ * swallowed - hot-reload is best-effort and must never crash the daemon.
38
+ */
39
+ export class DiffWatcher {
40
+ private readonly repoWatches = new Map<string, RepoWatch>();
41
+
42
+ constructor(private readonly onRepoChange: (repoRoot: string) => void) {}
43
+
44
+ /** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
45
+ trackDiffRepo(repoRoot: string, sessionId: string): void {
46
+ const existing = this.repoWatches.get(repoRoot);
47
+ if (existing) {
48
+ existing.sessionIds.add(sessionId);
49
+ return;
50
+ }
51
+ let handle: FSWatcher;
52
+ try {
53
+ handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
54
+ if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
55
+ this.scheduleRepoRefresh(repoRoot);
56
+ });
57
+ } catch {
58
+ // repo root gone or not watchable on this platform: skip, no hot-reload here
59
+ return;
60
+ }
61
+ // a watcher error (the root is deleted mid-review) must not take down the
62
+ // daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
63
+ (handle as FSWatcher & { on(event: "error", listener: (error: Error) => void): void }).on(
64
+ "error",
65
+ () => {},
66
+ );
67
+ this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
68
+ }
69
+
70
+ /** Drop one diff session; close the repo watch once no diff session tracks it. */
71
+ untrackDiffRepo(repoRoot: string, sessionId: string): void {
72
+ const repoWatch = this.repoWatches.get(repoRoot);
73
+ if (!repoWatch) return;
74
+ repoWatch.sessionIds.delete(sessionId);
75
+ if (repoWatch.sessionIds.size > 0) return;
76
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
77
+ repoWatch.handle.close();
78
+ this.repoWatches.delete(repoRoot);
79
+ }
80
+
81
+ private scheduleRepoRefresh(repoRoot: string): void {
82
+ const repoWatch = this.repoWatches.get(repoRoot);
83
+ if (!repoWatch) return;
84
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
85
+ repoWatch.debounce = setTimeout(() => {
86
+ repoWatch.debounce = null;
87
+ this.onRepoChange(repoRoot);
88
+ }, DIFF_REFRESH_DEBOUNCE_MS);
89
+ }
90
+
91
+ /** Close every watcher and cancel pending debounces (daemon shutdown). */
92
+ close(): void {
93
+ for (const repoWatch of this.repoWatches.values()) {
94
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
95
+ repoWatch.handle.close();
96
+ }
97
+ this.repoWatches.clear();
98
+ }
99
+ }