@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/api.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The daemon's method surface (#14). Transport-independent: the socket
2
+ * The daemon's method surface. Transport-independent: the socket
3
3
  * server and the in-process test harness both call these handlers.
4
4
  * The wait contract: verdicts outlive waits - session.wait long-polls,
5
5
  * and a verdict resolved while nobody waited is delivered on next contact.
@@ -8,15 +8,26 @@
8
8
  import {
9
9
  SCHEMA_VERSION,
10
10
  feedbackForSession,
11
+ isAddressed,
12
+ isAgentNote,
13
+ isMarkdownArtifact,
14
+ parseBlocks,
15
+ registerParticipant,
16
+ resolveAnchor,
11
17
  verdictAllows,
12
18
  type Annotation,
13
19
  type Artifact,
20
+ type Identity,
14
21
  type ReviewSession,
15
22
  type Verdict,
16
23
  type VerdictKind,
17
24
  type WorkspaceKey,
18
25
  } from "@cueloop/schema";
19
26
  import { SessionStore } from "./store";
27
+ import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
28
+ import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
29
+ import { DiffWatcher } from "./diff-watcher";
30
+ import { workingTreeDiff } from "./working-tree";
20
31
  import { DaemonError } from "./errors";
21
32
 
22
33
  export type EventName =
@@ -31,31 +42,60 @@ export interface DaemonEvent {
31
42
  sessionId: string;
32
43
  }
33
44
 
34
- type EventListener = (e: DaemonEvent) => void;
45
+ type EventListener = (event: DaemonEvent) => void;
35
46
 
36
47
  export class DaemonCore {
37
48
  readonly store: SessionStore;
49
+ readonly herdrTabs: HerdrTabStore;
38
50
  private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
39
51
  private listeners = new Set<EventListener>();
40
52
  private seq = 0;
53
+ /** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
54
+ private readonly diffWatcher: DiffWatcher;
55
+ /**
56
+ * Per-diff-session capture generation. Bumped when a refresh begins; a
57
+ * capture whose generation is stale by the time it finishes discards its
58
+ * result, so overlapping captures never write an older patch over a newer one.
59
+ */
60
+ private readonly diffRefreshGenerations = new Map<string, number>();
41
61
 
42
62
  constructor(home: string) {
43
63
  this.store = new SessionStore(home);
44
64
  this.store.recover();
65
+ pruneExpiredSessions(this.store, resolveCleanupPeriodDays(), Date.now());
66
+ this.herdrTabs = new HerdrTabStore(home);
67
+ this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
68
+ // resume hot-reload for diff sessions that survived a daemon restart
69
+ for (const session of this.store.list()) this.watchIfDiffSession(session);
70
+ }
71
+
72
+ /** Release the fs watchers behind diff hot-reload; call on daemon shutdown. */
73
+ dispose(): void {
74
+ this.diffWatcher.close();
75
+ }
76
+
77
+ /** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
78
+ herdrGetTab(sessionId: string): HerdrTabHandle | null {
79
+ return this.herdrTabs.get(sessionId);
80
+ }
81
+
82
+ herdrSetTab(sessionId: string, handle: HerdrTabHandle): void {
83
+ this.herdrTabs.set(sessionId, handle);
45
84
  }
46
85
 
47
86
  onEvent(listener: EventListener): () => void {
48
87
  this.listeners.add(listener);
88
+
49
89
  return () => this.listeners.delete(listener);
50
90
  }
51
91
 
52
92
  private emit(event: EventName, sessionId: string): void {
53
- for (const l of this.listeners) l({ event, sessionId });
93
+ for (const listener of this.listeners) listener({ event, sessionId });
54
94
  }
55
95
 
56
96
  /** True when nothing awaits a verdict - drives idle-exit. */
57
97
  hasPendingSessions(): boolean {
58
- return this.store.list().some((s) => s.status === "pending");
98
+ return this.store.list().some((session) => session.status === "pending");
59
99
  }
60
100
 
61
101
  sessionCreate(params: { workspace: WorkspaceKey; artifact: Artifact }): ReviewSession {
@@ -71,23 +111,34 @@ export class DaemonCore {
71
111
  status: "pending",
72
112
  createdAt: now,
73
113
  };
114
+
74
115
  this.store.upsert(session);
116
+ this.watchIfDiffSession(session);
75
117
  this.emit("session.created", session.id);
76
118
  this.emit("inbox.changed", session.id);
119
+
77
120
  return session;
78
121
  }
79
122
 
80
123
  sessionGet(id: string): ReviewSession {
81
- const s = this.store.get(id);
82
- if (!s) throw new DaemonError("not_found", `no session ${id}`);
83
- return s;
124
+ const session = this.store.get(id);
125
+
126
+ if (!session) throw new DaemonError("not_found", `no session ${id}`);
127
+
128
+ return session;
84
129
  }
85
130
 
86
- sessionList(filter?: { status?: "pending" | "resolved"; workspace?: Partial<WorkspaceKey> }): ReviewSession[] {
87
- return this.store.list().filter((s) => {
88
- if (filter?.status && s.status !== filter.status) return false;
89
- if (filter?.workspace?.repoRoot && s.workspace.repoRoot !== filter.workspace.repoRoot) return false;
90
- if (filter?.workspace?.branch && s.workspace.branch !== filter.workspace.branch) return false;
131
+ sessionList(filter?: {
132
+ status?: "pending" | "resolved";
133
+ workspace?: Partial<WorkspaceKey>;
134
+ }): ReviewSession[] {
135
+ return this.store.list().filter((session) => {
136
+ if (filter?.status && session.status !== filter.status) return false;
137
+ if (filter?.workspace?.repoRoot && session.workspace.repoRoot !== filter.workspace.repoRoot)
138
+ return false;
139
+ if (filter?.workspace?.branch && session.workspace.branch !== filter.workspace.branch)
140
+ return false;
141
+
91
142
  return true;
92
143
  });
93
144
  }
@@ -98,16 +149,19 @@ export class DaemonCore {
98
149
  * still pending - the caller re-polls later; the verdict is never lost).
99
150
  */
100
151
  sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
101
- const s = this.sessionGet(id);
102
- if (s.status === "resolved") return Promise.resolve(s);
152
+ const current = this.sessionGet(id);
153
+
154
+ if (current.status === "resolved") return Promise.resolve(current);
155
+
103
156
  return new Promise((resolve) => {
104
157
  const list = this.waiters.get(id) ?? [];
105
158
  let done = false;
106
159
  const timer = setTimeout(() => {
107
160
  if (done) return;
108
161
  done = true;
109
- const idx = list.indexOf(waiter);
110
- if (idx !== -1) list.splice(idx, 1);
162
+ const waiterIndex = list.indexOf(waiter);
163
+
164
+ if (waiterIndex !== -1) list.splice(waiterIndex, 1);
111
165
  resolve(null);
112
166
  }, timeoutMs);
113
167
  const waiter = (session: ReviewSession) => {
@@ -116,85 +170,280 @@ export class DaemonCore {
116
170
  clearTimeout(timer);
117
171
  resolve(session);
118
172
  };
173
+
119
174
  list.push(waiter);
120
175
  this.waiters.set(id, list);
121
176
  });
122
177
  }
123
178
 
124
- sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): ReviewSession {
125
- const s = this.mutable(id);
126
- const existing = s.annotations.findIndex((a) => a.id === annotation.id);
179
+ /**
180
+ * `authorName` registers the annotation's author in the participant registry;
181
+ * a bare author id (no name) keeps its short-handle fallback instead.
182
+ */
183
+ sessionAnnotate(
184
+ id: string,
185
+ annotation: Omit<Annotation, "createdAt">,
186
+ authorName?: string,
187
+ ): ReviewSession {
188
+ const session = this.mutable(id);
189
+ const existing = session.annotations.findIndex((candidate) => candidate.id === annotation.id);
127
190
  const full: Annotation = { ...annotation, createdAt: new Date().toISOString() };
128
- if (existing === -1) s.annotations.push(full);
129
- else s.annotations[existing] = { ...full, createdAt: s.annotations[existing]!.createdAt };
130
- this.store.upsert(s);
191
+
192
+ if (existing === -1) session.annotations.push(full);
193
+ else
194
+ session.annotations[existing] = {
195
+ ...full,
196
+ createdAt: session.annotations[existing]!.createdAt,
197
+ };
198
+ if (annotation.author && authorName)
199
+ session.participants = registerParticipant(
200
+ session,
201
+ annotation.author,
202
+ authorName,
203
+ ).participants;
204
+ this.store.upsert(session);
131
205
  this.emit("session.updated", id);
132
- return s;
206
+
207
+ return session;
133
208
  }
134
209
 
135
210
  sessionRemoveAnnotation(id: string, annotationId: string): ReviewSession {
136
- const s = this.mutable(id);
137
- s.annotations = s.annotations.filter((a) => a.id !== annotationId);
138
- this.store.upsert(s);
211
+ const session = this.mutable(id);
212
+
213
+ session.annotations = session.annotations.filter((candidate) => candidate.id !== annotationId);
214
+ this.store.upsert(session);
139
215
  this.emit("session.updated", id);
140
- return s;
216
+
217
+ return session;
141
218
  }
142
219
 
143
220
  /** The reviewer's working copy; undefined clears it (revert all edits). */
144
221
  sessionSetWorkingCopy(id: string, workingCopy: string | undefined): ReviewSession {
145
- const s = this.mutable(id);
146
- if (workingCopy === undefined || workingCopy === s.artifact.content) delete s.workingCopy;
147
- else s.workingCopy = workingCopy;
148
- this.store.upsert(s);
222
+ const session = this.mutable(id);
223
+
224
+ if (workingCopy === undefined || workingCopy === session.artifact.content)
225
+ delete session.workingCopy;
226
+ else session.workingCopy = workingCopy;
227
+ this.store.upsert(session);
149
228
  this.emit("session.updated", id);
150
- return s;
229
+
230
+ return session;
231
+ }
232
+
233
+ /**
234
+ * The guided walk's viewed marks. Merge-additive: walking only ever adds
235
+ * marks, so concurrent or stale clients converge instead of overwriting
236
+ * each other. An empty array is the explicit reset.
237
+ */
238
+ sessionSetViewed(id: string, viewedPaths: string[]): ReviewSession {
239
+ const session = this.mutable(id);
240
+
241
+ if (viewedPaths.length === 0) delete session.viewedPaths;
242
+ else session.viewedPaths = [...new Set([...(session.viewedPaths ?? []), ...viewedPaths])];
243
+ this.store.upsert(session);
244
+ this.emit("session.updated", id);
245
+
246
+ return session;
247
+ }
248
+
249
+ sessionSetShareId(id: string, shareId: string): ReviewSession {
250
+ const session = this.mutable(id);
251
+
252
+ session.shareId = shareId;
253
+ this.store.upsert(session);
254
+ this.emit("session.updated", id);
255
+
256
+ return session;
257
+ }
258
+
259
+ /** Remove a session for good (inbox delete); resolved or pending, both go. */
260
+ sessionDelete(id: string): void {
261
+ const session = this.store.get(id);
262
+
263
+ if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
264
+ if (session) this.unwatchIfDiffSession(session);
265
+ this.diffRefreshGenerations.delete(id);
266
+ this.herdrTabs.delete(id);
267
+ this.emit("inbox.changed", id);
268
+ }
269
+
270
+ /**
271
+ * Merge a share's collaborator state back into the local session: annotations
272
+ * union by id with existing ones (the planner's) winning, and the participant
273
+ * registry union by id with the incoming identity winning (a collaborator is
274
+ * the authority on their own name). This is how a teammate's name reaches the
275
+ * planner after a pull, alongside their notes.
276
+ */
277
+ sessionMergeShared(
278
+ id: string,
279
+ incoming: { annotations: Annotation[]; participants?: Identity[] },
280
+ ): ReviewSession {
281
+ const session = this.mutable(id);
282
+ const known = new Set(session.annotations.map((annotation) => annotation.id));
283
+
284
+ for (const annotation of incoming.annotations)
285
+ if (!known.has(annotation.id)) session.annotations.push(annotation);
286
+ if (incoming.participants?.length) {
287
+ const registry = new Map(
288
+ (session.participants ?? []).map((participant) => [participant.id, participant]),
289
+ );
290
+
291
+ for (const participant of incoming.participants) registry.set(participant.id, participant);
292
+ session.participants = [...registry.values()];
293
+ }
294
+ this.store.upsert(session);
295
+ this.emit("session.updated", id);
296
+
297
+ return session;
151
298
  }
152
299
 
153
300
  sessionResolve(id: string, verdictKind: VerdictKind, summary: string): ReviewSession {
154
- const s = this.mutable(id);
301
+ const session = this.mutable(id);
155
302
  const verdict: Verdict = {
156
303
  kind: verdictKind,
157
304
  summary,
158
- feedback: feedbackForSession(s, verdictKind, summary),
305
+ feedback: feedbackForSession(session, verdictKind, summary),
159
306
  resolvedAt: new Date().toISOString(),
160
307
  };
161
- s.verdict = verdict;
162
- s.status = "resolved";
163
- this.store.upsert(s);
308
+
309
+ session.verdict = verdict;
310
+ session.status = "resolved";
311
+ this.store.upsert(session);
312
+ // a resolved diff review is frozen; stop hot-reloading its working tree
313
+ this.unwatchIfDiffSession(session);
164
314
  const parked = this.waiters.get(id) ?? [];
315
+
165
316
  this.waiters.delete(id);
166
- for (const w of parked) w(s);
317
+ for (const parkedWaiter of parked) parkedWaiter(session);
167
318
  this.emit("session.resolved", id);
168
319
  this.emit("inbox.changed", id);
169
- return s;
320
+
321
+ return session;
170
322
  }
171
323
 
172
- /** Agent resubmits: new revision becomes the artifact, session reopens. */
173
- sessionSubmitRevision(id: string, content: string): ReviewSession {
174
- const s = this.sessionGet(id);
324
+ /**
325
+ * Agent resubmits: new revision becomes the artifact, session reopens.
326
+ * Annotations the agent reports as acted on (by id) are marked addressed,
327
+ * and for plan revisions any still-open annotation whose quoted text no
328
+ * longer resolves is marked addressed too ("drift" - the line it pointed at
329
+ * was rewritten). Addressed is a marker, never a delete: the reviewer's
330
+ * rail hides them behind a count, and the next feedback document omits them.
331
+ * Unknown ids are ignored, so a stale id in the agent's list never fails
332
+ * the resubmit.
333
+ */
334
+ sessionSubmitRevision(
335
+ id: string,
336
+ content: string,
337
+ addressedAnnotationIds: string[] = [],
338
+ ): ReviewSession {
339
+ const session = this.sessionGet(id);
175
340
  const now = new Date().toISOString();
176
- s.revisions.push({ revision: s.revisions.length + 1, content, submittedAt: now });
177
- s.artifact = { ...s.artifact, content };
178
- delete s.workingCopy;
179
- s.verdict = null;
180
- s.status = "pending";
181
- this.store.upsert(s);
341
+ const revisionNumber = session.revisions.length + 1;
342
+
343
+ session.revisions.push({ revision: revisionNumber, content, submittedAt: now });
344
+ session.artifact = { ...session.artifact, content };
345
+ delete session.workingCopy;
346
+ session.verdict = null;
347
+ session.status = "pending";
348
+
349
+ const reportedIds = new Set(addressedAnnotationIds);
350
+ // drift assist applies to markdown artifacts (plan, reply) only: a diff
351
+ // revision is a whole new patch, where a vanished quote says nothing about
352
+ // the feedback
353
+ const revisedBlocks = isMarkdownArtifact(session.artifact.type) ? parseBlocks(content) : null;
354
+
355
+ for (const annotation of session.annotations) {
356
+ if (isAddressed(annotation) || isAgentNote(annotation)) continue;
357
+ if (reportedIds.has(annotation.id)) {
358
+ annotation.resolution = { revision: revisionNumber, source: "agent" };
359
+ } else if (
360
+ revisedBlocks !== null &&
361
+ resolveAnchor(annotation.anchor, revisedBlocks) === null
362
+ ) {
363
+ annotation.resolution = { revision: revisionNumber, source: "drift" };
364
+ }
365
+ }
366
+
367
+ this.store.upsert(session);
182
368
  this.emit("session.revised", id);
183
369
  this.emit("inbox.changed", id);
184
- return s;
370
+
371
+ return session;
372
+ }
373
+
374
+ /** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
375
+ async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
376
+ const session = this.mutable(id);
377
+
378
+ if (session.artifact.type !== "diff") return { changed: false };
379
+ const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
380
+
381
+ this.diffRefreshGenerations.set(id, generation);
382
+ const diff = await workingTreeDiff(session.workspace.repoRoot);
383
+
384
+ // The capture yields the event loop: a newer refresh may have started, or a
385
+ // concurrent resolve/delete may have closed the session. Discard a stale
386
+ // capture, and re-read so a resolved review is never mutated and a deleted
387
+ // one is never revived by this upsert.
388
+ if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
389
+ const current = this.store.get(id);
390
+
391
+ if (!current || current.status !== "pending" || current.artifact.type !== "diff")
392
+ return { changed: false };
393
+ if (diff.patch === current.artifact.content) return { changed: false };
394
+ current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
395
+ this.store.upsert(current);
396
+ this.emit("session.updated", id);
397
+
398
+ return { changed: true };
399
+ }
400
+
401
+ /** Re-capture every live diff session sharing a repo root (one debounced fs change). */
402
+ private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
403
+ const live = this.store
404
+ .list()
405
+ .filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
406
+
407
+ for (const session of live) {
408
+ // a session resolved or deleted between the change and this tick just skips
409
+ try {
410
+ await this.sessionRefreshDiff(session.id);
411
+ } catch {
412
+ // no-op: the session is gone or already resolved
413
+ }
414
+ }
415
+ }
416
+
417
+ private watchIfDiffSession(session: ReviewSession): void {
418
+ if (isLiveDiffSession(session))
419
+ this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
420
+ }
421
+
422
+ private unwatchIfDiffSession(session: ReviewSession): void {
423
+ if (session.artifact.type === "diff")
424
+ this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
185
425
  }
186
426
 
187
427
  private mutable(id: string): ReviewSession {
188
- const s = this.sessionGet(id);
189
- if (s.status === "resolved") throw new DaemonError("resolved", `session ${id} is already resolved`);
190
- return s;
428
+ const session = this.sessionGet(id);
429
+
430
+ if (session.status === "resolved")
431
+ throw new DaemonError("resolved", `session ${id} is already resolved`);
432
+
433
+ return session;
191
434
  }
192
435
  }
193
436
 
194
437
  export { DaemonError };
195
438
 
439
+ /** A pending diff session: the state that warrants hot-reload watching of its working tree. */
440
+ function isLiveDiffSession(session: ReviewSession): boolean {
441
+ return session.status === "pending" && session.artifact.type === "diff";
442
+ }
443
+
196
444
  /** Convenience for adapters: map a resolved session to the agent contract. */
197
445
  export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
198
446
  if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
447
+
199
448
  return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
200
449
  }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { asDaemonRole, roleAllowsMethod } from "./capabilities";
3
+
4
+ describe("roleAllowsMethod", () => {
5
+ test("the owner may call every verb", () => {
6
+ // Assert
7
+ for (const method of ["session.resolve", "session.submitRevision", "session.delete"])
8
+ expect(roleAllowsMethod("owner", method)).toBe(true);
9
+ });
10
+
11
+ test("a capped role may read and annotate but not escalate", () => {
12
+ // Assert - allowed
13
+ for (const method of ["session.get", "session.list", "session.wait", "session.annotate"])
14
+ expect(roleAllowsMethod("agent", method)).toBe(true);
15
+ // Assert - denied
16
+ for (const method of [
17
+ "session.resolve",
18
+ "session.submitRevision",
19
+ "session.setWorkingCopy",
20
+ "session.delete",
21
+ "session.setShareId",
22
+ "session.refreshDiff",
23
+ ])
24
+ expect(roleAllowsMethod("agent", method)).toBe(false);
25
+ expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe("asDaemonRole", () => {
30
+ test("passes owner and collaborator through and caps anything unknown to agent", () => {
31
+ // Assert
32
+ expect(asDaemonRole("owner")).toBe("owner");
33
+ expect(asDaemonRole("collaborator")).toBe("collaborator");
34
+ expect(asDaemonRole("agent")).toBe("agent");
35
+ expect(asDaemonRole("root")).toBe("agent");
36
+ expect(asDaemonRole(undefined)).toBe("agent");
37
+ });
38
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Daemon role capabilities: which socket methods a connection may call. The
3
+ * owner (the local planner) may call everything; a collaborator or a review-side
4
+ * agent is capped to reading the session and adding annotations, so a bring-your-
5
+ * own agent literally cannot resolve, rewrite, cut, share, or delete (ADR 0009).
6
+ * This is the single source of truth the server dispatch enforces.
7
+ */
8
+
9
+ export type DaemonRole = "owner" | "collaborator" | "agent";
10
+
11
+ /** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
12
+ const NON_OWNER_METHODS = new Set<string>([
13
+ "daemon.ping",
14
+ "daemon.hello",
15
+ "events.subscribe",
16
+ "session.get",
17
+ "session.list",
18
+ "session.wait",
19
+ "session.annotate",
20
+ ]);
21
+
22
+ /** Whether `role` may call `method`. The owner may call anything; others are capped to read + annotate. */
23
+ export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
24
+ return role === "owner" || NON_OWNER_METHODS.has(method);
25
+ }
26
+
27
+ /** Parse an untrusted role string, defaulting unknown values to the capped "agent". */
28
+ export function asDaemonRole(value: unknown): DaemonRole {
29
+ return value === "owner" || value === "collaborator" ? value : "agent";
30
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { daemonSpawnCommand } from "./client";
3
+
4
+ describe("daemonSpawnCommand", () => {
5
+ test("re-execs the binary as `daemon` when compiled (bun virtual-fs markers)", () => {
6
+ for (const moduleUrl of [
7
+ "file:///$bunfs/root/cueloop",
8
+ "file:///B:/~BUN/root/cueloop",
9
+ "file:///B:/%7EBUN/root/cueloop",
10
+ ]) {
11
+ expect(daemonSpawnCommand("/usr/local/bin/cueloop", moduleUrl)).toEqual([
12
+ "/usr/local/bin/cueloop",
13
+ "daemon",
14
+ "--autostart",
15
+ ]);
16
+ }
17
+ });
18
+
19
+ test("runs main.ts with bun from source", () => {
20
+ expect(
21
+ daemonSpawnCommand("/path/to/bun", "file:///repo/packages/daemon/src/client.ts"),
22
+ ).toEqual(["/path/to/bun", "run", "/repo/packages/daemon/src/main.ts"]);
23
+ });
24
+ });