@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/server.ts CHANGED
@@ -1,26 +1,55 @@
1
1
  /**
2
2
  * The socket server: binds DaemonCore's methods to the unix socket, owns the
3
- * pidfile, stale-socket cleanup, and the idle-exit timer (#14). File
3
+ * pidfile, stale-socket cleanup, and the idle-exit timer. File
4
4
  * permissions are the local auth: the socket and state dir are 0700/0600.
5
5
  */
6
6
 
7
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import {
8
+ chmodSync,
9
+ closeSync,
10
+ existsSync,
11
+ fstatSync,
12
+ linkSync,
13
+ mkdirSync,
14
+ openSync,
15
+ readFileSync,
16
+ rmSync,
17
+ statSync,
18
+ writeFileSync,
19
+ } from "node:fs";
8
20
  import { DaemonCore, type DaemonEvent } from "./api";
9
21
  import { DaemonError } from "./errors";
10
- import { isKnownMethod, parseParams } from "./validate";
11
- import { LineBuffer, type Request } from "./protocol";
12
- import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
22
+ import { DEFAULT_ROLE, roleAllowsMethod, type DaemonRole } from "./capabilities";
23
+ import { isKnownMethod, parseParams, type MethodName } from "./validate";
24
+ import {
25
+ BackpressureWriter,
26
+ LineBuffer,
27
+ parseRequestFrame,
28
+ type Request,
29
+ type Response,
30
+ } from "./protocol";
31
+ import { cueloopHome, lockPath, ownerTokenPath, pidPath, socketPath } from "./paths";
32
+ import { DAEMON_VERSION } from "./version";
33
+ import { randomBytes } from "node:crypto";
13
34
 
14
- interface Conn {
35
+ interface Connection {
36
+ /** The author a non-owner connection acts as, bound in the handshake. */
37
+ author?: string;
15
38
  write(data: string): void;
16
39
  subscribed: boolean;
40
+ /** Capability role for this connection; the owner until a daemon.hello caps it. */
41
+ role: DaemonRole;
17
42
  }
18
43
 
44
+ type MethodHandler = (connection: Connection, request: Request) => Response["result"];
45
+
19
46
  export interface DaemonOptions {
20
47
  home?: string;
21
48
  /** Idle-exit delay; 0 disables (tests, foreground runs). */
22
49
  idleExitMs?: number;
23
50
  onIdleExit?: () => void;
51
+ /** The version the ping handshake reports; defaults to this build. Tests override it to pose as a stale daemon. */
52
+ version?: string;
24
53
  }
25
54
 
26
55
  /**
@@ -29,24 +58,29 @@ export interface DaemonOptions {
29
58
  * so in-process ownership is tracked separately.
30
59
  */
31
60
  const HELD_HOMES = new Set<string>();
61
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
32
62
 
33
63
  export class DaemonServer {
34
64
  readonly core: DaemonCore;
35
65
  readonly home: string;
36
- private conns = new Set<Conn>();
66
+ private connections = new Set<Connection>();
37
67
  private server: ReturnType<typeof Bun.listen> | null = null;
38
68
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
69
  private lockFd: number | null = null;
70
+ /** The secret that proves ownership; minted per daemon run, readable by the home's user only. */
71
+ private ownerToken = "";
40
72
  private readonly idleExitMs: number;
41
73
  private readonly onIdleExit: () => void;
74
+ private readonly version: string;
42
75
 
43
- constructor(opts: DaemonOptions = {}) {
44
- this.home = opts.home ?? cueloopHome();
45
- this.idleExitMs = opts.idleExitMs ?? 15 * 60 * 1000;
46
- this.onIdleExit = opts.onIdleExit ?? (() => process.exit(0));
76
+ constructor(options: DaemonOptions = {}) {
77
+ this.home = options.home ?? cueloopHome();
78
+ this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
79
+ this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
80
+ this.version = options.version ?? DAEMON_VERSION;
47
81
  mkdirSync(this.home, { recursive: true, mode: 0o700 });
48
82
  this.core = new DaemonCore(this.home);
49
- this.core.onEvent((e) => this.broadcast(e));
83
+ this.core.onEvent((event) => this.broadcast(event));
50
84
  }
51
85
 
52
86
  /**
@@ -59,18 +93,37 @@ export class DaemonServer {
59
93
  private acquireLock(): boolean {
60
94
  if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
61
95
  const path = lockPath(this.home);
96
+
62
97
  for (let attempt = 0; attempt < 3; attempt++) {
98
+ const preparedPath = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
99
+ const fd = openSync(preparedPath, "wx", 0o600);
100
+
63
101
  try {
64
- // "wx" fails when the file exists - the atomic part of the handshake
65
- const fd = openSync(path, "wx");
102
+ // Publish the complete pid atomically. A competing daemon must never
103
+ // mistake a newly created, still-empty lock for a crashed owner.
66
104
  writeFileSync(fd, String(process.pid));
105
+ linkSync(preparedPath, path);
67
106
  this.lockFd = fd;
68
107
  HELD_HOMES.add(this.home);
108
+
69
109
  return true;
70
- } catch {
110
+ } catch (error) {
111
+ closeSync(fd);
112
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
113
+
71
114
  let ownerPid = 0;
115
+
72
116
  try {
73
- ownerPid = Number(readFileSync(path, "utf8").trim());
117
+ let ownerText = readFileSync(path, "utf8").trim();
118
+
119
+ // Older daemon builds published the lock before writing their pid.
120
+ // Give that live owner time to finish instead of stealing its socket.
121
+ for (let probe = 0; !ownerText && probe < 25; probe++) {
122
+ Atomics.wait(LOCK_WAIT, 0, 0, 20);
123
+ ownerText = readFileSync(path, "utf8").trim();
124
+ }
125
+ if (!ownerText && Date.now() - statSync(path).mtimeMs < 5_000) return false;
126
+ ownerPid = Number(ownerText);
74
127
  } catch {
75
128
  // the owner vanished between open and read; retry
76
129
  continue;
@@ -78,6 +131,7 @@ export class DaemonServer {
78
131
  if (ownerPid && ownerPid !== process.pid) {
79
132
  try {
80
133
  process.kill(ownerPid, 0); // throws when the pid is gone
134
+
81
135
  return false; // a live daemon owns this home
82
136
  } catch {
83
137
  // stale lock from a crashed daemon
@@ -90,14 +144,23 @@ export class DaemonServer {
90
144
  } catch {
91
145
  return false;
92
146
  }
147
+ } finally {
148
+ rmSync(preparedPath, { force: true });
93
149
  }
94
150
  }
151
+
95
152
  return false;
96
153
  }
97
154
 
98
155
  private releaseLock(): void {
99
156
  HELD_HOMES.delete(this.home);
100
157
  if (this.lockFd !== null) {
158
+ try {
159
+ if (statSync(lockPath(this.home)).ino === fstatSync(this.lockFd).ino)
160
+ rmSync(lockPath(this.home), { force: true });
161
+ } catch {
162
+ // the lock was already removed or replaced
163
+ }
101
164
  try {
102
165
  closeSync(this.lockFd);
103
166
  } catch {
@@ -105,7 +168,6 @@ export class DaemonServer {
105
168
  }
106
169
  this.lockFd = null;
107
170
  }
108
- rmSync(lockPath(this.home), { force: true });
109
171
  }
110
172
 
111
173
  /**
@@ -115,27 +177,47 @@ export class DaemonServer {
115
177
  start(): string | null {
116
178
  if (!this.acquireLock()) return null;
117
179
  const path = socketPath(this.home);
180
+
118
181
  // safe now: holding the lock means no live daemon owns this home, so any
119
182
  // socket file left behind is stale
120
183
  if (existsSync(path)) rmSync(path, { force: true });
121
- const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
184
+ // the token exists before the socket does: the first client to connect
185
+ // must be able to prove ownership
186
+ this.ownerToken = randomBytes(32).toString("hex");
187
+ writeFileSync(ownerTokenPath(this.home), this.ownerToken, { mode: 0o600 });
188
+ chmodSync(ownerTokenPath(this.home), 0o600);
189
+ this.server = Bun.listen<{
190
+ buffer: LineBuffer;
191
+ connection: Connection;
192
+ writer: BackpressureWriter;
193
+ }>({
123
194
  unix: path,
124
195
  socket: {
125
- open(socket) {
126
- const conn: Conn = { write: (d) => socket.write(d), subscribed: false };
127
- socket.data = { buffer: new LineBuffer(), conn };
128
- self.conns.add(conn);
129
- self.scheduleIdleCheck();
196
+ open: (socket) => {
197
+ const writer = new BackpressureWriter(socket);
198
+ const connection: Connection = {
199
+ write: (data) => writer.write(data),
200
+ subscribed: false,
201
+ role: DEFAULT_ROLE,
202
+ };
203
+
204
+ socket.data = { buffer: new LineBuffer(), connection, writer };
205
+ this.connections.add(connection);
206
+ this.scheduleIdleCheck();
130
207
  },
131
- data(socket, data) {
208
+ data: (socket, data) => {
132
209
  socket.data.buffer.push(data.toString(), (line) => {
133
- void self.handleLine(socket.data.conn, line);
210
+ void this.respondToRequestLine(socket.data.connection, line);
134
211
  });
135
212
  },
136
- close(socket) {
137
- self.conns.delete(socket.data.conn);
138
- self.scheduleIdleCheck();
213
+ drain(socket) {
214
+ // drain can fire before open has attached data on some platforms;
215
+ // a throw here would take the whole daemon down with the socket
216
+ socket.data?.writer.drain();
217
+ },
218
+ close: (socket) => {
219
+ this.connections.delete(socket.data.connection);
220
+ this.scheduleIdleCheck();
139
221
  },
140
222
  error() {},
141
223
  },
@@ -143,21 +225,26 @@ export class DaemonServer {
143
225
  chmodSync(path, 0o600);
144
226
  writeFileSync(pidPath(this.home), String(process.pid));
145
227
  this.scheduleIdleCheck();
228
+
146
229
  return path;
147
230
  }
148
231
 
149
232
  stop(): void {
233
+ this.core.dispose();
234
+ if (this.lockFd === null) return;
150
235
  this.server?.stop(true);
151
236
  this.server = null;
152
237
  rmSync(socketPath(this.home), { force: true });
153
238
  rmSync(pidPath(this.home), { force: true });
239
+ rmSync(ownerTokenPath(this.home), { force: true });
154
240
  if (this.idleTimer) clearTimeout(this.idleTimer);
155
241
  this.releaseLock();
156
242
  }
157
243
 
158
- private broadcast(e: DaemonEvent): void {
159
- const frame = JSON.stringify(e) + "\n";
160
- for (const c of this.conns) if (c.subscribed) c.write(frame);
244
+ private broadcast(event: DaemonEvent): void {
245
+ const frame = JSON.stringify(event) + "\n";
246
+
247
+ for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
161
248
  this.scheduleIdleCheck();
162
249
  }
163
250
 
@@ -166,7 +253,7 @@ export class DaemonServer {
166
253
  if (this.idleExitMs <= 0) return;
167
254
  if (this.idleTimer) clearTimeout(this.idleTimer);
168
255
  this.idleTimer = setTimeout(() => {
169
- if (this.conns.size === 0 && !this.core.hasPendingSessions()) {
256
+ if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
170
257
  this.stop();
171
258
  this.onIdleExit();
172
259
  } else {
@@ -175,76 +262,334 @@ export class DaemonServer {
175
262
  }, this.idleExitMs);
176
263
  }
177
264
 
178
- private async handleLine(conn: Conn, line: string): Promise<void> {
179
- let req: Request;
265
+ private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
266
+ let request: Request;
267
+
180
268
  try {
181
- req = JSON.parse(line) as Request;
269
+ request = parseRequestFrame(line);
182
270
  } catch {
183
- conn.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
271
+ connection.write(
272
+ JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
273
+ "\n",
274
+ );
275
+
184
276
  return;
185
277
  }
186
278
  try {
187
- const result = await this.dispatch(conn, req);
188
- conn.write(JSON.stringify({ id: req.id, result }) + "\n");
279
+ const result = await this.dispatch(connection, request);
280
+
281
+ connection.write(JSON.stringify({ id: request.id, result }) + "\n");
189
282
  } catch (err) {
190
283
  const code = err instanceof DaemonError ? err.code : "internal";
191
284
  const message = err instanceof Error ? err.message : String(err);
192
- conn.write(JSON.stringify({ id: req.id, error: { code, message } }) + "\n");
285
+
286
+ connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
193
287
  }
194
288
  }
195
289
 
196
- private async dispatch(conn: Conn, req: Request): Promise<unknown> {
197
- // The wire is untrusted JSON: validate before DaemonCore sees anything.
198
- if (typeof req.method !== "string" || !isKnownMethod(req.method)) {
199
- throw new DaemonError("unknown_method", `unknown method ${String(req.method)}`);
200
- }
201
- const core = this.core;
202
- switch (req.method) {
203
- case "daemon.ping":
204
- return { pid: process.pid };
205
- case "daemon.shutdown":
206
- setTimeout(() => {
207
- this.stop();
208
- this.onIdleExit();
209
- }, 10);
210
- return {};
211
- case "events.subscribe":
212
- conn.subscribed = true;
213
- return {};
214
- case "session.create": {
215
- const p = parseParams("session.create", req.params);
216
- return core.sessionCreate({ workspace: p.workspace, artifact: p.artifact });
217
- }
218
- case "session.get":
219
- return core.sessionGet(parseParams("session.get", req.params).id);
220
- case "session.list":
221
- return core.sessionList(parseParams("session.list", req.params).filter);
222
- case "session.wait": {
223
- const p = parseParams("session.wait", req.params);
224
- return core.sessionWait(p.id, p.timeoutMs);
225
- }
226
- case "session.annotate": {
227
- const p = parseParams("session.annotate", req.params);
228
- return core.sessionAnnotate(p.id, p.annotation);
229
- }
230
- case "session.removeAnnotation": {
231
- const p = parseParams("session.removeAnnotation", req.params);
232
- return core.sessionRemoveAnnotation(p.id, p.annotationId);
290
+ private readonly handlers: Record<MethodName, MethodHandler> = {
291
+ "daemon.ping": () => ({ pid: process.pid, version: this.version }),
292
+ "daemon.hello": (connection, request) => {
293
+ const params = parseParams("daemon.hello", request.params);
294
+
295
+ // ownership is proven, never declared: the token lives in the home
296
+ // directory the daemon serves, readable by its user alone
297
+ if (params.role === "owner" && params.token !== this.ownerToken) {
298
+ throw new DaemonError("forbidden", "owner token required");
233
299
  }
234
- case "session.setWorkingCopy": {
235
- const p = parseParams("session.setWorkingCopy", req.params);
236
- return core.sessionSetWorkingCopy(p.id, p.workingCopy);
300
+ if (params.clientVersion !== this.version) {
301
+ throw new DaemonError(
302
+ "version_mismatch",
303
+ `daemon is version ${this.version}, but this client is ${params.clientVersion ?? "unknown"}; align cueloop versions before restarting the daemon`,
304
+ );
237
305
  }
238
- case "session.resolve": {
239
- const p = parseParams("session.resolve", req.params);
240
- return core.sessionResolve(p.id, p.verdictKind, p.summary);
306
+
307
+ connection.role = params.role;
308
+ // identity is bound once, here; a non-owner never names it per call
309
+ if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
310
+
311
+ return {};
312
+ },
313
+ "daemon.shutdown": () => {
314
+ setTimeout(() => {
315
+ this.stop();
316
+ this.onIdleExit();
317
+ }, 10);
318
+
319
+ return {};
320
+ },
321
+ "events.subscribe": (connection) => {
322
+ connection.subscribed = true;
323
+
324
+ return {};
325
+ },
326
+ "session.create": (_connection, request) => {
327
+ const params = parseParams("session.create", request.params);
328
+
329
+ return this.core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
330
+ },
331
+ "session.get": (_connection, request) =>
332
+ this.core.sessionGet(parseParams("session.get", request.params).id),
333
+ "session.list": (_connection, request) =>
334
+ this.core.sessionList(parseParams("session.list", request.params).filter),
335
+ "session.wait": (_connection, request) => {
336
+ const params = parseParams("session.wait", request.params);
337
+
338
+ return this.core.sessionWait(params.id, params.timeoutMs);
339
+ },
340
+ "session.annotate": (_connection, request) => {
341
+ const params = parseParams("session.annotate", request.params);
342
+
343
+ return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
344
+ },
345
+ "session.comment": (_connection, request) => {
346
+ const params = parseParams("session.comment", request.params);
347
+
348
+ return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
349
+ },
350
+ "session.removeAnnotation": (connection, request) => {
351
+ const params = parseParams("session.removeAnnotation", request.params);
352
+
353
+ // the owner removes any comment; a non-owner only the ones of the author it is bound to
354
+ if (connection.role !== "owner" && connection.author === undefined) {
355
+ throw new DaemonError(
356
+ "forbidden",
357
+ "a non-owner connection removes comments as its bound author",
358
+ );
241
359
  }
242
- case "session.submitRevision": {
243
- const p = parseParams("session.submitRevision", req.params);
244
- return core.sessionSubmitRevision(p.id, p.content);
360
+
361
+ return this.core.sessionRemoveAnnotation(
362
+ params.id,
363
+ params.annotationId,
364
+ connection.role === "owner" ? undefined : connection.author,
365
+ );
366
+ },
367
+ "session.setParticipantName": (connection, request) => {
368
+ const params = parseParams("session.setParticipantName", request.params);
369
+
370
+ // the owner names anyone; a non-owner names only the author it is bound to
371
+ if (connection.role !== "owner" && params.author !== connection.author) {
372
+ throw new DaemonError("forbidden", "a non-owner connection names only its bound author");
245
373
  }
246
- default:
247
- throw new DaemonError("unknown_method", `unknown method ${req.method}`);
374
+
375
+ return this.core.sessionSetParticipantName(params.id, params.author, params.name);
376
+ },
377
+ "session.setWorkingCopy": (_connection, request) => {
378
+ const params = parseParams("session.setWorkingCopy", request.params);
379
+
380
+ return this.core.sessionSetWorkingCopy(params.id, params.workingCopy, params.textCuts);
381
+ },
382
+ "session.cutBlock": (_connection, request) => {
383
+ const params = parseParams("session.cutBlock", request.params);
384
+
385
+ return this.core.sessionCutBlock(params.id, params.blockIndex);
386
+ },
387
+ "session.navigate": (_connection, request) => {
388
+ const params = parseParams("session.navigate", request.params);
389
+
390
+ return this.core.sessionNavigate(params.id, params.entryId, params.summary, params.branch);
391
+ },
392
+ "session.branch": (_connection, request) => {
393
+ const params = parseParams("session.branch", request.params);
394
+
395
+ return this.core.sessionBranch(params.id, params.name);
396
+ },
397
+ "session.switch": (_connection, request) => {
398
+ const params = parseParams("session.switch", request.params);
399
+
400
+ return this.core.sessionSwitch(params.id, params.branch);
401
+ },
402
+ "session.label": (_connection, request) => {
403
+ const params = parseParams("session.label", request.params);
404
+
405
+ return this.core.sessionLabel(params.id, params.label);
406
+ },
407
+ "session.fork": (_connection, request) => {
408
+ const params = parseParams("session.fork", request.params);
409
+
410
+ return this.core.sessionFork(params.id);
411
+ },
412
+ "session.restoreBlock": (_connection, request) => {
413
+ const params = parseParams("session.restoreBlock", request.params);
414
+
415
+ return this.core.sessionRestoreBlock(params.id, params.baseBlockIndex, params.line);
416
+ },
417
+ "session.curate": (_connection, request) => {
418
+ const params = parseParams("session.curate", request.params);
419
+
420
+ return this.core.sessionCurate(params.id, params.rejections);
421
+ },
422
+ "session.setAccess": (_connection, request) => {
423
+ const params = parseParams("session.setAccess", request.params);
424
+
425
+ return this.core.sessionSetAccess(params.id, params.githubLogins);
426
+ },
427
+ "session.setViewed": (_connection, request) => {
428
+ const params = parseParams("session.setViewed", request.params);
429
+
430
+ return this.core.sessionSetViewed(params.id, params.viewedPaths);
431
+ },
432
+ "session.setTitle": (_connection, request) => {
433
+ const params = parseParams("session.setTitle", request.params);
434
+
435
+ return this.core.sessionSetTitle(params.id, params.title);
436
+ },
437
+ "session.projectFiles": (_connection, request) =>
438
+ this.core.projectFiles(parseParams("session.projectFiles", request.params).id),
439
+ "session.fileContents": (_connection, request) => {
440
+ const params = parseParams("session.fileContents", request.params);
441
+
442
+ return this.core.fileContents(params.id, params.path);
443
+ },
444
+ "repo.files": (_connection, request) =>
445
+ this.core.repoFiles(parseParams("repo.files", request.params).cwd),
446
+ "repo.fileContents": (_connection, request) => {
447
+ const params = parseParams("repo.fileContents", request.params);
448
+
449
+ return this.core.repoFileContents(params.cwd, params.path);
450
+ },
451
+ "repo.changes": (_connection, request) =>
452
+ this.core.repoChanges(parseParams("repo.changes", request.params).cwd),
453
+ "repo.diff": (_connection, request) => {
454
+ const params = parseParams("repo.diff", request.params);
455
+
456
+ return this.core.repoDiff(params.cwd, params.vcs);
457
+ },
458
+ "session.workbench": (_connection, request) =>
459
+ this.core.workbenchSession(parseParams("session.workbench", request.params).cwd),
460
+ "session.refreshDiff": (_connection, request) => {
461
+ const params = parseParams("session.refreshDiff", request.params);
462
+
463
+ return this.core.sessionRefreshDiff(params.id);
464
+ },
465
+ "session.setShareId": (_connection, request) => {
466
+ const params = parseParams("session.setShareId", request.params);
467
+
468
+ return this.core.sessionSetShareId(params.id, params.shareId);
469
+ },
470
+ "session.setShares": (_connection, request) => {
471
+ const params = parseParams("session.setShares", request.params);
472
+
473
+ return this.core.sessionSetShares(params.id, params.shares);
474
+ },
475
+ "session.delete": (_connection, request) => {
476
+ this.core.sessionDelete(parseParams("session.delete", request.params).id);
477
+
478
+ return {};
479
+ },
480
+ "session.mergeShared": (_connection, request) => {
481
+ const params = parseParams("session.mergeShared", request.params);
482
+
483
+ return this.core.sessionMergeShared(params.id, {
484
+ annotations: params.annotations,
485
+ participants: params.participants,
486
+ removals: params.removals,
487
+ });
488
+ },
489
+ "session.sendMessage": (_connection, request) => {
490
+ const params = parseParams("session.sendMessage", request.params);
491
+
492
+ return this.core.sessionSendMessage(
493
+ params.id,
494
+ params.outcome,
495
+ params.summary,
496
+ params.actionBodies,
497
+ );
498
+ },
499
+ "harness.bind": (_connection, request) => {
500
+ const params = parseParams("harness.bind", request.params);
501
+
502
+ return this.core.harnessBind(params);
503
+ },
504
+ "harness.getBinding": (_connection, request) => {
505
+ const params = parseParams("harness.getBinding", request.params);
506
+
507
+ return this.core.harnessGetBinding(params.bindingId);
508
+ },
509
+ "harness.bindingsForSession": (_connection, request) => {
510
+ const params = parseParams("harness.bindingsForSession", request.params);
511
+
512
+ return this.core.harnessBindingsForSession(params.harness, params.harnessSessionId);
513
+ },
514
+ "harness.consumeApprovedRetry": (_connection, request) => {
515
+ const params = parseParams("harness.consumeApprovedRetry", request.params);
516
+
517
+ return this.core.harnessConsumeApprovedRetry(
518
+ params.bindingId,
519
+ params.messageId,
520
+ params.content,
521
+ );
522
+ },
523
+ "delivery.pending": (_connection, request) => {
524
+ const params = parseParams("delivery.pending", request.params);
525
+
526
+ return this.core.deliveryPending(params.bindingId);
527
+ },
528
+ "delivery.acknowledge": (_connection, request) => {
529
+ const params = parseParams("delivery.acknowledge", request.params);
530
+
531
+ return this.core.deliveryAcknowledge(params.deliveryId);
532
+ },
533
+ "session.submitRevision": (_connection, request) => {
534
+ const params = parseParams("session.submitRevision", request.params);
535
+
536
+ return this.core.sessionSubmitRevision(
537
+ params.id,
538
+ params.content,
539
+ params.addressedAnnotationIds,
540
+ params.files,
541
+ params.source,
542
+ );
543
+ },
544
+ "herdr.getThreadSurface": (_connection, request) =>
545
+ this.core.herdrGetThreadSurface(parseParams("herdr.getThreadSurface", request.params).id),
546
+ "herdr.setThreadSurface": (_connection, request) => {
547
+ const params = parseParams("herdr.setThreadSurface", request.params);
548
+
549
+ this.core.herdrSetThreadSurface(params.id, {
550
+ tabId: params.tabId,
551
+ paneId: params.paneId,
552
+ mode: params.mode,
553
+ });
554
+
555
+ return {};
556
+ },
557
+ "ghostty.getThreadSurface": (_connection, request) =>
558
+ this.core.ghosttyGetThreadSurface(parseParams("ghostty.getThreadSurface", request.params).id),
559
+ "ghostty.setThreadSurface": (_connection, request) => {
560
+ const params = parseParams("ghostty.setThreadSurface", request.params);
561
+
562
+ this.core.ghosttySetThreadSurface(params.id, { terminalId: params.terminalId });
563
+
564
+ return {};
565
+ },
566
+ "ghostty.claimThreadSurface": (_connection, request) =>
567
+ this.core.ghosttyClaimThreadSurface(
568
+ parseParams("ghostty.claimThreadSurface", request.params).id,
569
+ ),
570
+ "ghostty.releaseThreadSurface": (_connection, request) => {
571
+ this.core.ghosttyReleaseThreadSurface(
572
+ parseParams("ghostty.releaseThreadSurface", request.params).id,
573
+ );
574
+
575
+ return {};
576
+ },
577
+ };
578
+
579
+ private async dispatch(connection: Connection, request: Request): Promise<Response["result"]> {
580
+ // The wire is untrusted JSON: validate before DaemonCore sees anything.
581
+ if (!isKnownMethod(request.method)) {
582
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
583
+ }
584
+ // Capability gate: a capped role (a review-side agent) cannot escalate past
585
+ // read + annotate, whatever primitive it sends.
586
+ if (!roleAllowsMethod(connection.role, request.method)) {
587
+ throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
248
588
  }
589
+ if (!Object.hasOwn(this.handlers, request.method)) {
590
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
591
+ }
592
+
593
+ return this.handlers[request.method](connection, request);
249
594
  }
250
595
  }