@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.90

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 (60) hide show
  1. package/package.json +39 -22
  2. package/src/api.test.ts +1060 -65
  3. package/src/api.ts +1398 -89
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +142 -0
  10. package/src/diff-hot-reload.test.ts +419 -0
  11. package/src/diff-watcher.ts +293 -0
  12. package/src/gh.test.ts +59 -0
  13. package/src/gh.ts +74 -0
  14. package/src/ghostty-thread-surface-store.test.ts +49 -0
  15. package/src/ghostty-thread-surface-store.ts +101 -0
  16. package/src/ghostty-thread-surface.test.ts +398 -0
  17. package/src/ghostty-thread-surface.ts +267 -0
  18. package/src/harness-delivery.test.ts +268 -0
  19. package/src/harness-state-store.test.ts +93 -0
  20. package/src/harness-state-store.ts +213 -0
  21. package/src/herdr-split.test.ts +106 -0
  22. package/src/herdr-split.ts +92 -0
  23. package/src/herdr-thread-surface-store.test.ts +81 -0
  24. package/src/herdr-thread-surface-store.ts +64 -0
  25. package/src/herdr-thread-surface.test.ts +411 -0
  26. package/src/herdr-thread-surface.ts +355 -0
  27. package/src/index.ts +8 -9
  28. package/src/interruptible-wait.test.ts +127 -0
  29. package/src/interruptible-wait.ts +66 -0
  30. package/src/jj-working-tree.ts +131 -0
  31. package/src/main.ts +4 -1
  32. package/src/paths.ts +49 -0
  33. package/src/pr-poller.test.ts +102 -0
  34. package/src/pr-poller.ts +73 -0
  35. package/src/project-files.test.ts +124 -0
  36. package/src/project-files.ts +56 -0
  37. package/src/protocol.ts +79 -7
  38. package/src/retention.test.ts +191 -0
  39. package/src/retention.ts +123 -0
  40. package/src/server.test.ts +453 -42
  41. package/src/server.ts +433 -88
  42. package/src/share-blob.test.ts +112 -0
  43. package/src/share-blob.ts +55 -0
  44. package/src/single-instance.test.ts +162 -13
  45. package/src/store.conformance.test.ts +41 -0
  46. package/src/store.jsonl.test.ts +159 -0
  47. package/src/store.ts +264 -34
  48. package/src/testing/store-conformance.ts +194 -0
  49. package/src/thread-review.test.ts +483 -0
  50. package/src/thread-review.ts +398 -0
  51. package/src/thread-surface-config.test.ts +63 -0
  52. package/src/thread-surface-config.ts +61 -0
  53. package/src/validate.test.ts +282 -37
  54. package/src/validate.ts +398 -33
  55. package/src/vcs-source.test.ts +180 -0
  56. package/src/vcs-source.ts +228 -0
  57. package/src/version.ts +6 -0
  58. package/src/working-tree.ts +149 -0
  59. package/src/review.test.ts +0 -167
  60. package/src/review.ts +0 -174
package/src/client.ts CHANGED
@@ -1,122 +1,453 @@
1
1
  /**
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
2
+ * Daemon client: the one library every consumer shares - CLI primitives, the TUI,
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
- 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";
10
- import { cueloopHome, socketPath } from "./paths";
7
+ import { spawn } from "node:child_process";
8
+ import { randomBytes } from "node:crypto";
9
+ import {
10
+ appendFileSync,
11
+ closeSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ openSync,
15
+ readFileSync,
16
+ rmSync,
17
+ } from "node:fs";
18
+ import { createConnection } from "node:net";
19
+ import { join } from "node:path";
20
+ import * as v from "valibot";
21
+ import type {
22
+ Annotation,
23
+ Artifact,
24
+ DiffFileStatus,
25
+ DiffFileContents,
26
+ HunkRejection,
27
+ ShareLink,
28
+ Thread,
29
+ HarnessBinding,
30
+ Delivery,
31
+ PendingDelivery,
32
+ MessageOutcome,
33
+ WorkspaceKey,
34
+ TextCut,
35
+ } from "@cueloop/schema";
36
+ import {
37
+ BackpressureWriter,
38
+ LineBuffer,
39
+ parseInboundFrame,
40
+ type EventFrame,
41
+ type Request,
42
+ type Response,
43
+ } from "./protocol";
44
+ import type { DaemonRole } from "./capabilities";
45
+ import type { HerdrThreadSurfaceHandle } from "./herdr-thread-surface-store";
46
+ import type { GhosttyThreadSurfaceHandle } from "./ghostty-thread-surface-store";
47
+ import type { SharedMerge } from "./api";
48
+
49
+ export type { SharedMerge } from "./api";
50
+ import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
51
+ import {
52
+ Params,
53
+ ThreadRecordSchema,
54
+ DiffFileContentsSchema,
55
+ HarnessBindingSchema,
56
+ DeliverySchema,
57
+ PendingDeliverySchema,
58
+ } from "./validate";
59
+ import type { WorkingTreeDiff } from "./working-tree";
60
+ import { DAEMON_VERSION } from "./version";
61
+
62
+ export type { EventFrame } from "./protocol";
11
63
 
12
64
  export interface ConnectOptions {
13
65
  home?: string;
14
66
  /** Spawn the daemon when the socket is not alive. */
15
67
  autostart?: boolean;
68
+ /** Capability role for this connection; a review-side agent connects capped. Defaults to owner. */
69
+ role?: DaemonRole;
70
+ /** The author a non-owner connection acts as; its comments, removals, and name are bound to it. */
71
+ author?: string;
72
+ }
73
+
74
+ type PendingRequest = {
75
+ resolve: (value: Response["result"]) => void;
76
+ reject: (error: Error) => void;
77
+ };
78
+
79
+ const EmptyResultSchema = v.object({});
80
+ // version is optional: a daemon from before the handshake carried one reads as
81
+ // undefined, which never equals this build.
82
+ const PingResultSchema = v.object({ pid: v.number(), version: v.optional(v.string()) });
83
+ const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
84
+ const HerdrThreadSurfaceResultSchema = v.nullable(
85
+ v.object({
86
+ tabId: v.string(),
87
+ paneId: v.string(),
88
+ mode: v.optional(v.picklist(["tab", "pane"])),
89
+ }),
90
+ );
91
+ const GhosttyThreadSurfaceResultSchema = v.nullable(v.object({ terminalId: v.string() }));
92
+
93
+ /**
94
+ * The session primitives the review controller drives. DaemonClient is the local
95
+ * implementation (unix socket); the sharing gateway supplies an in-memory,
96
+ * blob-backed one. Depending on this interface - not DaemonClient - is what
97
+ * lets the same <App> render a local session or a decrypted share unchanged.
98
+ */
99
+ export interface ThreadClient {
100
+ onEvent(listener: (event: EventFrame) => void): () => void;
101
+ subscribe(): Promise<void>;
102
+ sessionGet(id: string): Promise<Thread>;
103
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]>;
104
+ /** Add a comment; the primary annotate method. `sessionAnnotate` is the retained alias. */
105
+ sessionComment(
106
+ id: string,
107
+ annotation: Omit<Annotation, "createdAt">,
108
+ authorName?: string,
109
+ ): Promise<Thread>;
110
+ sessionAnnotate(
111
+ id: string,
112
+ annotation: Omit<Annotation, "createdAt">,
113
+ authorName?: string,
114
+ ): Promise<Thread>;
115
+ /** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
116
+ sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread>;
117
+ sessionSetWorkingCopy(
118
+ id: string,
119
+ workingCopy: string | undefined,
120
+ textCuts?: TextCut[],
121
+ ): Promise<Thread>;
122
+ /** Cut the `blockIndex`-th block of the working copy. */
123
+ sessionCutBlock(id: string, blockIndex: number): Promise<Thread>;
124
+ /** Re-insert the `baseBlockIndex`-th block of the submitted revision before `line` (default: the end). */
125
+ sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<Thread>;
126
+ /** Replace a diff review's reject decisions; the working copy follows. */
127
+ sessionCurate(id: string, rejections: HunkRejection[]): Promise<Thread>;
128
+ /** Replace the private-share allowlist of GitHub logins; presence marks the share private. */
129
+ sessionSetAccess(id: string, githubLogins: string[]): Promise<Thread>;
130
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<Thread>;
131
+ /** Rename a session's display title; an empty title restores the derived default. */
132
+ sessionSetTitle(id: string, title: string): Promise<Thread>;
133
+ /** Tracked, repo-relative file paths for the session's workspace; a client with no local repo (a share) omits it. */
134
+ projectFiles?(sessionId: string): Promise<string[]>;
135
+ /** UTF-8 contents of a repo-relative file, or null when it cannot be read safely; omitted by a client with no local repo. */
136
+ fileContents?(sessionId: string, path: string): Promise<string | null>;
137
+ /** Tracked, repo-relative paths for the git repo containing `cwd`, for the no-session welcome shell. */
138
+ repoFiles?(cwd: string): Promise<string[]>;
139
+ /** UTF-8 contents of a repo-relative file in `cwd`'s repo, or null when unreadable. */
140
+ repoFileContents?(cwd: string, path: string): Promise<string | null>;
141
+ /** Changed files (path plus git status) in the working tree at `cwd`. */
142
+ repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
143
+ /** The live working-tree diff (patch plus per-file contents) at `cwd`. */
144
+ repoDiff?(cwd: string, vcs?: string): Promise<WorkingTreeDiff>;
145
+ /** Refresh a local diff or explicitly pull a moved PR head. */
146
+ sessionRefreshDiff?(id: string): Promise<{ changed: boolean }>;
147
+ /** Find-or-create the per-repo workbench thread for `cwd`, so a bare launch's first comment persists. */
148
+ sessionWorkbench?(cwd: string): Promise<Thread>;
149
+ /** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
150
+ sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Promise<Thread>;
151
+ /** Start a branch at the current tip and switch to it. */
152
+ sessionBranch(id: string, name: string): Promise<Thread>;
153
+ sessionSwitch(id: string, branch: string): Promise<Thread>;
154
+ /** Name the current tip as a checkpoint. */
155
+ sessionLabel(id: string, label: string): Promise<Thread>;
156
+ /** Copy the current path into a new session; returns the fork. */
157
+ sessionFork(id: string): Promise<Thread>;
158
+ sessionSetShareId(id: string, shareId: string): Promise<Thread>;
159
+ sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread>;
160
+ sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread>;
161
+ sessionDelete(id: string): Promise<void>;
162
+ /** Record the caller's own identity name (collaborator self-naming on a share). */
163
+ sessionSetSelfName(id: string, name: string): Promise<Thread>;
164
+ sessionSendMessage(
165
+ id: string,
166
+ outcome: MessageOutcome,
167
+ summary: string,
168
+ actionBodies?: Record<string, string>,
169
+ ): Promise<Thread>;
170
+ harnessBind?(
171
+ threadId: string,
172
+ harness: string,
173
+ harnessSessionId: string,
174
+ ): Promise<HarnessBinding>;
175
+ harnessGetBinding?(bindingId: string): Promise<HarnessBinding>;
176
+ harnessBindingsForSession?(harness: string, harnessSessionId: string): Promise<HarnessBinding[]>;
177
+ harnessConsumeApprovedRetry?(
178
+ bindingId: string,
179
+ messageId: string,
180
+ content: string,
181
+ ): Promise<boolean>;
182
+ deliveryPending?(bindingId: string): Promise<PendingDelivery[]>;
183
+ deliveryAcknowledge?(deliveryId: string): Promise<Delivery>;
184
+ close(): void;
185
+ }
186
+
187
+ /** What a connection says about itself: its role, the owner token when it claims ownership, the author it acts as otherwise. */
188
+ interface HelloParams {
189
+ role: DaemonRole;
190
+ clientVersion: string;
191
+ token?: string;
192
+ author?: string;
16
193
  }
17
194
 
18
- type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void };
195
+ /** The token as the daemon writes it: 32 random bytes in hex. */
196
+ const OwnerTokenSchema = v.pipe(v.string(), v.trim(), v.regex(/^[0-9a-f]{64}$/));
197
+
198
+ /** The owner token in `home`, or undefined when the daemon there never wrote one. */
199
+ function readOwnerToken(home: string): string | undefined {
200
+ const path = ownerTokenPath(home);
201
+
202
+ if (!existsSync(path)) return undefined;
203
+ const parsed = v.safeParse(OwnerTokenSchema, readFileSync(path, "utf8"));
204
+
205
+ if (!parsed.success) {
206
+ throw new DaemonClientError(
207
+ "invalid_owner_token",
208
+ `${path} is not an owner token; restart the daemon to mint a fresh one`,
209
+ );
210
+ }
19
211
 
20
- export class DaemonClient {
21
- private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
22
- private pending = new Map<number, Pending>();
212
+ return parsed.output;
213
+ }
214
+
215
+ export class DaemonClient implements ThreadClient {
216
+ private socket: { end(): void } | null = null;
217
+ private writer: BackpressureWriter | null = null;
218
+ private connectionEpoch = 0;
219
+ private pending = new Map<number, PendingRequest>();
220
+ /** The connected daemon's build version, learned from the ping handshake. */
221
+ private daemonVersion: string | undefined;
23
222
  private nextId = 1;
24
- private eventListeners = new Set<(e: EventFrame) => void>();
223
+ private eventListeners = new Set<(event: EventFrame) => void>();
224
+ private disconnectListeners = new Set<() => void>();
25
225
  private closed = false;
226
+ private role: DaemonRole = "owner";
227
+ private author: string | undefined;
228
+ private home = cueloopHome();
26
229
 
27
- static async connect(opts: ConnectOptions = {}): Promise<DaemonClient> {
28
- const home = opts.home ?? cueloopHome();
230
+ static async connect(options: ConnectOptions = {}): Promise<DaemonClient> {
231
+ const home = options.home ?? cueloopHome();
29
232
  const path = socketPath(home);
30
233
  const client = new DaemonClient();
234
+
235
+ client.role = options.role ?? "owner";
236
+ client.author = options.author;
237
+ client.home = home;
31
238
  try {
32
239
  await client.dial(path);
33
- return client;
240
+ if (client.daemonVersion === DAEMON_VERSION) return client;
241
+ const daemonVersion = client.daemonVersion ?? "unknown";
242
+
243
+ client.close();
244
+ throw new DaemonClientError(
245
+ "version_mismatch",
246
+ `daemon is version ${daemonVersion}, but this client is ${DAEMON_VERSION}; align cueloop versions before restarting the daemon`,
247
+ );
34
248
  } catch (err) {
35
- if (!opts.autostart) throw err;
249
+ // Autostart repairs an unavailable socket, never a live incompatible daemon.
250
+ if (!options.autostart || err instanceof DaemonClientError) {
251
+ client.close();
252
+
253
+ throw err;
254
+ }
36
255
  }
37
- // Socket dead or absent: clean a stale file and spawn the daemon detached.
38
- if (existsSync(path)) rmSync(path, { force: true });
39
- spawnDaemon(home);
256
+
257
+ // Socket dead or absent: let the new daemon own stale socket cleanup.
258
+ return client.attachFreshDaemon(home, path);
259
+ }
260
+
261
+ /** Reset per-connection state so a fresh dial() can reuse this client instance. */
262
+ private resetConnection(): void {
263
+ this.connectionEpoch += 1;
264
+ this.socket = null;
265
+ this.writer = null;
266
+ this.closed = false;
267
+ for (const pendingRequest of this.pending.values())
268
+ pendingRequest.reject(new Error("daemon connection replaced"));
269
+ this.pending.clear();
270
+ }
271
+
272
+ /** Spawn a detached daemon and dial the lock owner until it answers. */
273
+ private async attachFreshDaemon(home: string, path: string): Promise<DaemonClient> {
274
+ const startupLogPath = spawnDaemon(home);
40
275
  // Generous: a cold or loaded machine pays for a runtime start before the
41
276
  // socket exists, and giving up early looks to callers like a broken daemon.
42
277
  const deadline = Date.now() + Number(process.env.CUELOOP_START_TIMEOUT_MS ?? 30_000);
43
- let lastErr: unknown;
44
- while (Date.now() < deadline) {
45
- try {
46
- await client.dial(path);
47
- return client;
48
- } catch (err) {
49
- lastErr = err;
50
- await Bun.sleep(50);
278
+ let lastError: unknown;
279
+
280
+ try {
281
+ while (Date.now() < deadline) {
282
+ try {
283
+ await this.dial(path);
284
+ if (this.daemonVersion !== DAEMON_VERSION) {
285
+ this.close();
286
+ if (typeof Bun === "undefined") {
287
+ throw new DaemonClientError(
288
+ "version_mismatch",
289
+ `installed cueloop is ${this.daemonVersion ?? "unknown"}, but this adapter is ${DAEMON_VERSION}; run cueloop update`,
290
+ );
291
+ }
292
+
293
+ throw new Error("daemon autostarted an older build");
294
+ }
295
+
296
+ return this;
297
+ } catch (err) {
298
+ if (err instanceof DaemonClientError) throw err;
299
+ this.socket?.end();
300
+ this.resetConnection();
301
+ lastError = err;
302
+ await sleep(50);
303
+ }
51
304
  }
305
+ const output = readFileSync(startupLogPath, "utf8").trim();
306
+ const detail = output ? `\ndaemon startup output:\n${output.slice(-4_096)}` : "";
307
+
308
+ throw new Error(`daemon did not come up at ${path}: ${String(lastError)}${detail}`);
309
+ } finally {
310
+ rmSync(startupLogPath, { force: true });
52
311
  }
53
- throw new Error(`daemon did not come up at ${path}: ${String(lastErr)}`);
54
312
  }
55
313
 
56
314
  private async dial(path: string): Promise<void> {
57
315
  const buffer = new LineBuffer();
58
- const self = this;
59
- this.socket = await Bun.connect({
60
- unix: path,
61
- socket: {
62
- data(_socket, data) {
63
- buffer.push(data.toString(), (line) => self.handleFrame(line));
316
+ const epoch = ++this.connectionEpoch;
317
+
318
+ const close = () => {
319
+ if (this.connectionEpoch !== epoch) return;
320
+ this.closed = true;
321
+ for (const pendingRequest of this.pending.values())
322
+ pendingRequest.reject(new Error("daemon connection closed"));
323
+ this.pending.clear();
324
+ for (const listener of this.disconnectListeners) listener();
325
+ };
326
+
327
+ if (typeof Bun !== "undefined") {
328
+ const socket = await Bun.connect({
329
+ unix: path,
330
+ socket: {
331
+ data: (_socket, data) => {
332
+ buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
333
+ },
334
+ drain: () => this.writer?.drain(),
335
+ close,
336
+ error() {},
64
337
  },
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();
338
+ });
339
+
340
+ this.socket = socket;
341
+ this.writer = new BackpressureWriter(socket);
342
+ } else {
343
+ const socket = createConnection(path);
344
+
345
+ await new Promise<void>((resolve, reject) => {
346
+ socket.once("connect", resolve);
347
+ socket.once("error", reject);
348
+ });
349
+ socket.on("data", (data) => {
350
+ buffer.push(data.toString(), (line) => this.routeInboundFrame(line));
351
+ });
352
+ socket.on("close", close);
353
+ socket.on("error", () => {});
354
+ this.socket = socket;
355
+ // Node queues the complete Buffer internally when write returns false.
356
+ this.writer = new BackpressureWriter({
357
+ write(data) {
358
+ socket.write(data);
359
+
360
+ return data.length;
69
361
  },
70
- error() {},
71
- },
72
- });
362
+ });
363
+ }
73
364
  // Verify liveness: a dead socket file accepts connects on some platforms
74
- // only to fail later, so a ping is the actual handshake.
75
- await this.request("daemon.ping", {}, 2_000);
365
+ // only to fail later, so a ping is the actual handshake. It also carries the
366
+ // daemon's build version, so connect() can reject incompatible clients.
367
+ const pong = await this.request("daemon.ping", {}, PingResultSchema, 2_000);
368
+
369
+ this.daemonVersion = pong.version;
370
+ // Every connection starts as a collaborator; the owner proves itself with
371
+ // the token the daemon wrote into the home it serves, which only the home's
372
+ // user can read. A capped role just names itself.
373
+ await this.request("daemon.hello", this.helloParams(), EmptyResultSchema, 2_000);
76
374
  }
77
375
 
78
- onEvent(listener: (e: EventFrame) => void): () => void {
376
+ private helloParams(): HelloParams {
377
+ if (this.role !== "owner") {
378
+ return this.author === undefined
379
+ ? { role: this.role, clientVersion: DAEMON_VERSION }
380
+ : { role: this.role, clientVersion: DAEMON_VERSION, author: this.author };
381
+ }
382
+ const token = readOwnerToken(this.home);
383
+
384
+ // a daemon from before owner tokens has no file; it still knows the bare hello
385
+ return token === undefined
386
+ ? { role: "owner", clientVersion: DAEMON_VERSION }
387
+ : { role: "owner", clientVersion: DAEMON_VERSION, token };
388
+ }
389
+
390
+ onEvent(listener: (event: EventFrame) => void): () => void {
79
391
  this.eventListeners.add(listener);
392
+
80
393
  return () => this.eventListeners.delete(listener);
81
394
  }
82
395
 
83
- private handleFrame(line: string): void {
84
- let frame: Response | EventFrame;
396
+ /** Notify an adapter when its daemon socket closes so it can reconnect and replay Messages. */
397
+ onDisconnect(listener: () => void): () => void {
398
+ this.disconnectListeners.add(listener);
399
+
400
+ return () => this.disconnectListeners.delete(listener);
401
+ }
402
+
403
+ private routeInboundFrame(line: string): void {
404
+ let frame;
405
+
85
406
  try {
86
- frame = JSON.parse(line) as Response | EventFrame;
407
+ frame = parseInboundFrame(line);
87
408
  } catch {
88
409
  return;
89
410
  }
90
411
  if ("event" in frame) {
91
- for (const l of this.eventListeners) l(frame);
412
+ for (const listener of this.eventListeners) listener(frame);
413
+
92
414
  return;
93
415
  }
94
- const p = this.pending.get(frame.id);
95
- if (!p) return;
416
+ const pendingRequest = this.pending.get(frame.id);
417
+
418
+ if (!pendingRequest) return;
96
419
  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);
420
+ if (frame.error)
421
+ pendingRequest.reject(new DaemonClientError(frame.error.code, frame.error.message));
422
+ else pendingRequest.resolve(frame.result);
99
423
  }
100
424
 
101
- request<T = unknown>(method: string, params: unknown, timeoutMs = 30_000): Promise<T> {
425
+ request<TOutput>(
426
+ method: string,
427
+ params: Request["params"],
428
+ resultSchema: v.GenericSchema<unknown, TOutput>,
429
+ timeoutMs = 30_000,
430
+ ): Promise<TOutput> {
102
431
  if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
103
432
  const id = this.nextId++;
104
- return new Promise<T>((resolve, reject) => {
433
+
434
+ return new Promise<TOutput>((resolve, reject) => {
105
435
  const timer = setTimeout(() => {
106
436
  this.pending.delete(id);
107
437
  reject(new Error(`request ${method} timed out`));
108
438
  }, timeoutMs);
439
+
109
440
  this.pending.set(id, {
110
- resolve: (v) => {
441
+ resolve: (value) => {
111
442
  clearTimeout(timer);
112
- resolve(v as T);
443
+ resolve(v.parse(resultSchema, value));
113
444
  },
114
- reject: (e) => {
445
+ reject: (error) => {
115
446
  clearTimeout(timer);
116
- reject(e);
447
+ reject(error);
117
448
  },
118
449
  });
119
- this.socket!.write(JSON.stringify({ id, method, params }) + "\n");
450
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
120
451
  });
121
452
  }
122
453
 
@@ -125,43 +456,249 @@ export class DaemonClient {
125
456
  this.socket?.end();
126
457
  }
127
458
 
128
- // ── typed verbs ─────────────────────────────
459
+ // ── typed primitives ─────────────────────────────
129
460
  ping(): Promise<{ pid: number }> {
130
- return this.request("daemon.ping", {});
461
+ return this.request("daemon.ping", {}, PingResultSchema);
131
462
  }
132
463
  subscribe(): Promise<void> {
133
- return this.request("events.subscribe", {});
464
+ return this.request("events.subscribe", {}, EmptyResultSchema).then(() => undefined);
134
465
  }
135
- sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<ReviewSession> {
136
- return this.request("session.create", { workspace, artifact });
466
+ sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<Thread> {
467
+ return this.request("session.create", { workspace, artifact }, ThreadRecordSchema);
137
468
  }
138
- sessionGet(id: string): Promise<ReviewSession> {
139
- return this.request("session.get", { id });
469
+ sessionGet(id: string): Promise<Thread> {
470
+ return this.request("session.get", { id }, ThreadRecordSchema);
140
471
  }
141
- sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]> {
142
- return this.request("session.list", { filter });
472
+ sessionList(filter?: { status?: "pending" | "resolved" }): Promise<Thread[]> {
473
+ return this.request("session.list", { filter }, v.array(ThreadRecordSchema));
143
474
  }
144
475
  /** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
145
- sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
146
- return this.request("session.wait", { id, timeoutMs }, timeoutMs + 10_000);
476
+ sessionWait(id: string, timeoutMs: number): Promise<Thread | null> {
477
+ return this.request(
478
+ "session.wait",
479
+ { id, timeoutMs },
480
+ v.nullable(ThreadRecordSchema),
481
+ timeoutMs + 10_000,
482
+ );
483
+ }
484
+ sessionComment(
485
+ id: string,
486
+ annotation: Omit<Annotation, "createdAt">,
487
+ authorName?: string,
488
+ ): Promise<Thread> {
489
+ return this.request("session.comment", { id, annotation, authorName }, ThreadRecordSchema);
490
+ }
491
+ sessionAnnotate(
492
+ id: string,
493
+ annotation: Omit<Annotation, "createdAt">,
494
+ authorName?: string,
495
+ ): Promise<Thread> {
496
+ return this.request("session.annotate", { id, annotation, authorName }, ThreadRecordSchema);
497
+ }
498
+ sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread> {
499
+ return this.request("session.removeAnnotation", { id, annotationId }, ThreadRecordSchema);
500
+ }
501
+ /** Register a display name for a participant - a collaborator's or an agent's own, on a share or locally. */
502
+ sessionSetParticipantName(id: string, author: string, name: string): Promise<Thread> {
503
+ return this.request("session.setParticipantName", { id, author, name }, ThreadRecordSchema);
504
+ }
505
+ sessionSetWorkingCopy(
506
+ id: string,
507
+ workingCopy: string | undefined,
508
+ textCuts?: TextCut[],
509
+ ): Promise<Thread> {
510
+ return this.request(
511
+ "session.setWorkingCopy",
512
+ { id, workingCopy, textCuts },
513
+ ThreadRecordSchema,
514
+ );
515
+ }
516
+ sessionCutBlock(id: string, blockIndex: number): Promise<Thread> {
517
+ return this.request("session.cutBlock", { id, blockIndex }, ThreadRecordSchema);
518
+ }
519
+ sessionNavigate(id: string, entryId: string, summary?: string, branch?: string): Promise<Thread> {
520
+ const params: v.InferInput<(typeof Params)["session.navigate"]> = { id, entryId };
521
+
522
+ if (summary !== undefined) params.summary = summary;
523
+ if (branch !== undefined) params.branch = branch;
524
+
525
+ return this.request("session.navigate", params, ThreadRecordSchema);
526
+ }
527
+ sessionBranch(id: string, name: string): Promise<Thread> {
528
+ return this.request("session.branch", { id, name }, ThreadRecordSchema);
529
+ }
530
+ sessionSwitch(id: string, branch: string): Promise<Thread> {
531
+ return this.request("session.switch", { id, branch }, ThreadRecordSchema);
532
+ }
533
+ sessionLabel(id: string, label: string): Promise<Thread> {
534
+ return this.request("session.label", { id, label }, ThreadRecordSchema);
535
+ }
536
+ sessionFork(id: string): Promise<Thread> {
537
+ return this.request("session.fork", { id }, ThreadRecordSchema);
538
+ }
539
+ sessionRestoreBlock(id: string, baseBlockIndex: number, line?: number): Promise<Thread> {
540
+ return this.request(
541
+ "session.restoreBlock",
542
+ line === undefined ? { id, baseBlockIndex } : { id, baseBlockIndex, line },
543
+ ThreadRecordSchema,
544
+ );
545
+ }
546
+ sessionCurate(id: string, rejections: HunkRejection[]): Promise<Thread> {
547
+ return this.request("session.curate", { id, rejections }, ThreadRecordSchema);
548
+ }
549
+ sessionSetAccess(id: string, githubLogins: string[]): Promise<Thread> {
550
+ return this.request("session.setAccess", { id, githubLogins }, ThreadRecordSchema);
551
+ }
552
+ sessionSetViewed(id: string, viewedPaths: string[]): Promise<Thread> {
553
+ return this.request("session.setViewed", { id, viewedPaths }, ThreadRecordSchema);
554
+ }
555
+ sessionSetTitle(id: string, title: string): Promise<Thread> {
556
+ return this.request("session.setTitle", { id, title }, ThreadRecordSchema);
557
+ }
558
+ projectFiles(sessionId: string): Promise<string[]> {
559
+ return this.request("session.projectFiles", { id: sessionId }, v.array(v.string()));
560
+ }
561
+ fileContents(sessionId: string, path: string): Promise<string | null> {
562
+ return this.request("session.fileContents", { id: sessionId, path }, v.nullable(v.string()));
563
+ }
564
+ repoFiles(cwd: string): Promise<string[]> {
565
+ return this.request("repo.files", { cwd }, v.array(v.string()));
566
+ }
567
+ repoFileContents(cwd: string, path: string): Promise<string | null> {
568
+ return this.request("repo.fileContents", { cwd, path }, v.nullable(v.string()));
569
+ }
570
+ repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
571
+ return this.request(
572
+ "repo.changes",
573
+ { cwd },
574
+ v.array(v.object({ path: v.string(), status: v.picklist(["added", "modified", "deleted"]) })),
575
+ );
576
+ }
577
+ repoDiff(
578
+ cwd: string,
579
+ vcs?: string,
580
+ ): Promise<
581
+ WorkingTreeDiff & { vcs?: string; source?: { changeId?: string; revisionId: string } }
582
+ > {
583
+ return this.request(
584
+ "repo.diff",
585
+ { cwd, vcs },
586
+ v.object({
587
+ patch: v.string(),
588
+ files: v.array(DiffFileContentsSchema),
589
+ vcs: v.optional(v.string()),
590
+ source: v.optional(v.object({ changeId: v.optional(v.string()), revisionId: v.string() })),
591
+ }),
592
+ );
593
+ }
594
+ sessionWorkbench(cwd: string): Promise<Thread> {
595
+ return this.request("session.workbench", { cwd }, ThreadRecordSchema);
596
+ }
597
+ /** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
598
+ sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
599
+ return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
147
600
  }
148
- sessionAnnotate(id: string, annotation: Omit<Annotation, "createdAt">): Promise<ReviewSession> {
149
- return this.request("session.annotate", { id, annotation });
601
+ sessionSetShareId(id: string, shareId: string): Promise<Thread> {
602
+ return this.request("session.setShareId", { id, shareId }, ThreadRecordSchema);
150
603
  }
151
- sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
152
- return this.request("session.removeAnnotation", { id, annotationId });
604
+
605
+ sessionSetShares(id: string, shares: ShareLink[]): Promise<Thread> {
606
+ return this.request("session.setShares", { id, shares }, ThreadRecordSchema);
607
+ }
608
+ sessionMergeShared(id: string, incoming: SharedMerge): Promise<Thread> {
609
+ return this.request("session.mergeShared", { id, ...incoming }, ThreadRecordSchema);
610
+ }
611
+ sessionDelete(id: string): Promise<void> {
612
+ return this.request("session.delete", { id }, EmptyResultSchema).then(() => undefined);
613
+ }
614
+ /** Local sessions have no collaborator self-name; the share client owns this. */
615
+ sessionSetSelfName(id: string, _name: string): Promise<Thread> {
616
+ return this.sessionGet(id);
617
+ }
618
+ sessionSendMessage(
619
+ id: string,
620
+ outcome: MessageOutcome,
621
+ summary: string,
622
+ actionBodies?: Record<string, string>,
623
+ ): Promise<Thread> {
624
+ return this.request(
625
+ "session.sendMessage",
626
+ { id, outcome, summary, actionBodies },
627
+ ThreadRecordSchema,
628
+ );
629
+ }
630
+ harnessBind(
631
+ threadId: string,
632
+ harness: string,
633
+ harnessSessionId: string,
634
+ ): Promise<HarnessBinding> {
635
+ return this.request(
636
+ "harness.bind",
637
+ { threadId, harness, harnessSessionId },
638
+ HarnessBindingSchema,
639
+ );
640
+ }
641
+ harnessGetBinding(bindingId: string): Promise<HarnessBinding> {
642
+ return this.request("harness.getBinding", { bindingId }, HarnessBindingSchema);
643
+ }
644
+ harnessBindingsForSession(harness: string, harnessSessionId: string): Promise<HarnessBinding[]> {
645
+ return this.request(
646
+ "harness.bindingsForSession",
647
+ { harness, harnessSessionId },
648
+ v.array(HarnessBindingSchema),
649
+ );
650
+ }
651
+ harnessConsumeApprovedRetry(
652
+ bindingId: string,
653
+ messageId: string,
654
+ content: string,
655
+ ): Promise<boolean> {
656
+ return this.request(
657
+ "harness.consumeApprovedRetry",
658
+ { bindingId, messageId, content },
659
+ v.boolean(),
660
+ );
661
+ }
662
+ deliveryPending(bindingId: string): Promise<PendingDelivery[]> {
663
+ return this.request("delivery.pending", { bindingId }, v.array(PendingDeliverySchema));
664
+ }
665
+ deliveryAcknowledge(deliveryId: string): Promise<Delivery> {
666
+ return this.request("delivery.acknowledge", { deliveryId }, DeliverySchema);
667
+ }
668
+ sessionSubmitRevision(
669
+ id: string,
670
+ content: string,
671
+ addressedAnnotationIds: string[] = [],
672
+ files?: DiffFileContents[],
673
+ source?: import("@cueloop/schema").DiffSource,
674
+ ): Promise<Thread> {
675
+ return this.request(
676
+ "session.submitRevision",
677
+ { id, content, addressedAnnotationIds, files, source },
678
+ ThreadRecordSchema,
679
+ );
680
+ }
681
+ /** Read Herdr's local Thread surface handle, outside the ThreadClient contract. */
682
+ herdrGetThreadSurface(id: string): Promise<HerdrThreadSurfaceHandle | null> {
683
+ return this.request("herdr.getThreadSurface", { id }, HerdrThreadSurfaceResultSchema);
684
+ }
685
+ async herdrSetThreadSurface(id: string, handle: HerdrThreadSurfaceHandle): Promise<void> {
686
+ await this.request("herdr.setThreadSurface", { id, ...handle }, EmptyResultSchema);
153
687
  }
154
- sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
155
- return this.request("session.setWorkingCopy", { id, workingCopy });
688
+ ghosttyGetThreadSurface(id: string): Promise<GhosttyThreadSurfaceHandle | null> {
689
+ return this.request("ghostty.getThreadSurface", { id }, GhosttyThreadSurfaceResultSchema);
156
690
  }
157
- sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
158
- return this.request("session.resolve", { id, verdictKind, summary });
691
+ async ghosttySetThreadSurface(id: string, handle: GhosttyThreadSurfaceHandle): Promise<void> {
692
+ await this.request("ghostty.setThreadSurface", { id, ...handle }, EmptyResultSchema);
159
693
  }
160
- sessionSubmitRevision(id: string, content: string): Promise<ReviewSession> {
161
- return this.request("session.submitRevision", { id, content });
694
+ ghosttyClaimThreadSurface(id: string): Promise<boolean> {
695
+ return this.request("ghostty.claimThreadSurface", { id }, v.boolean());
696
+ }
697
+ async ghosttyReleaseThreadSurface(id: string): Promise<void> {
698
+ await this.request("ghostty.releaseThreadSurface", { id }, EmptyResultSchema);
162
699
  }
163
700
  shutdown(): Promise<void> {
164
- return this.request("daemon.shutdown", {});
701
+ return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
165
702
  }
166
703
  }
167
704
 
@@ -174,10 +711,56 @@ export class DaemonClientError extends Error {
174
711
  }
175
712
  }
176
713
 
177
- function spawnDaemon(home: string): void {
178
- const entry = new URL("./main.ts", import.meta.url).pathname;
179
- Bun.spawn([process.execPath, "run", entry], {
180
- env: { ...process.env, CUELOOP_HOME: home },
181
- stdio: ["ignore", "ignore", "ignore"],
182
- }).unref();
714
+ // A compiled binary re-execs `cueloop daemon --autostart` (idle-exits like main.ts,
715
+ // unlike the never-exiting foreground daemon); from source, bun runs main.ts. In
716
+ // dev (CUELOOP_DEV_WATCH=1, source only) it runs under --watch so daemon-code edits
717
+ // reload the daemon without a manual restart - the version handshake only catches
718
+ // release upgrades, not same-version source changes.
719
+ export function daemonSpawnCommand(
720
+ execPath: string,
721
+ moduleUrl: string,
722
+ devWatch = process.env.CUELOOP_DEV_WATCH === "1",
723
+ ): string[] {
724
+ const compiled =
725
+ moduleUrl.includes("$bunfs") || moduleUrl.includes("~BUN") || moduleUrl.includes("%7EBUN");
726
+
727
+ if (compiled) return [execPath, "daemon", "--autostart"];
728
+ const mainPath = new URL("./main.ts", moduleUrl).pathname;
729
+
730
+ return devWatch ? [execPath, "--watch", "run", mainPath] : [execPath, "run", mainPath];
731
+ }
732
+
733
+ function spawnDaemon(home: string): string {
734
+ mkdirSync(home, { recursive: true, mode: 0o700 });
735
+ const startupLogPath = join(
736
+ home,
737
+ `daemon-startup-${process.pid}-${randomBytes(6).toString("hex")}.log`,
738
+ );
739
+ const logFd = openSync(startupLogPath, "wx", 0o600);
740
+
741
+ try {
742
+ if (typeof Bun === "undefined") {
743
+ const child = spawn(process.env.CUELOOP_EXECUTABLE ?? "cueloop", ["daemon", "--autostart"], {
744
+ env: { ...process.env, CUELOOP_HOME: home },
745
+ stdio: ["ignore", logFd, logFd],
746
+ detached: true,
747
+ });
748
+
749
+ child.on("error", (error) => appendFileSync(startupLogPath, `${String(error)}\n`));
750
+ child.unref();
751
+ } else {
752
+ Bun.spawn(daemonSpawnCommand(process.execPath, import.meta.url), {
753
+ env: { ...process.env, CUELOOP_HOME: home },
754
+ stdio: ["ignore", logFd, logFd],
755
+ }).unref();
756
+ }
757
+ } finally {
758
+ closeSync(logFd);
759
+ }
760
+
761
+ return startupLogPath;
762
+ }
763
+
764
+ function sleep(durationMs: number): Promise<void> {
765
+ return new Promise((resolve) => setTimeout(resolve, durationMs));
183
766
  }