@cueloop/daemon 0.1.0-alpha.10 → 0.1.0-alpha.101

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 (83) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +98 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1425 -89
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +189 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +820 -94
  15. package/src/curate.ts +109 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +397 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +419 -0
  21. package/src/diff-watcher.test.ts +117 -0
  22. package/src/diff-watcher.ts +326 -0
  23. package/src/examples/daemon-sdk.ts +55 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +74 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +101 -0
  28. package/src/ghostty-thread-surface.test.ts +398 -0
  29. package/src/ghostty-thread-surface.ts +267 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +30 -0
  33. package/src/harness-state-store.test.ts +93 -0
  34. package/src/harness-state-store.ts +213 -0
  35. package/src/herdr-split.test.ts +106 -0
  36. package/src/herdr-split.ts +85 -0
  37. package/src/herdr-thread-surface-store.test.ts +81 -0
  38. package/src/herdr-thread-surface-store.ts +66 -0
  39. package/src/herdr-thread-surface.test.ts +465 -0
  40. package/src/herdr-thread-surface.ts +378 -0
  41. package/src/index.ts +8 -9
  42. package/src/interruptible-wait.test.ts +127 -0
  43. package/src/interruptible-wait.ts +66 -0
  44. package/src/jj-working-tree.ts +131 -0
  45. package/src/main.ts +4 -1
  46. package/src/operation-receipts.test.ts +111 -0
  47. package/src/operation-receipts.ts +98 -0
  48. package/src/paths.ts +49 -0
  49. package/src/pr-poller.test.ts +102 -0
  50. package/src/pr-poller.ts +73 -0
  51. package/src/project-files.test.ts +134 -0
  52. package/src/project-files.ts +68 -0
  53. package/src/protocol.ts +78 -7
  54. package/src/retention.test.ts +191 -0
  55. package/src/retention.ts +123 -0
  56. package/src/sdk-identities.ts +35 -0
  57. package/src/server.test.ts +502 -42
  58. package/src/server.ts +631 -88
  59. package/src/share-blob.test.ts +119 -0
  60. package/src/share-blob.ts +57 -0
  61. package/src/shared-agent-protocol.ts +30 -0
  62. package/src/single-instance.test.ts +162 -13
  63. package/src/store.conformance.test.ts +41 -0
  64. package/src/store.jsonl.test.ts +173 -0
  65. package/src/store.ts +267 -34
  66. package/src/testing/store-conformance.ts +192 -0
  67. package/src/thread-agent-validation.ts +121 -0
  68. package/src/thread-agent.test.ts +719 -0
  69. package/src/thread-agent.ts +835 -0
  70. package/src/thread-review.test.ts +483 -0
  71. package/src/thread-review.ts +382 -0
  72. package/src/thread-subscription.test.ts +174 -0
  73. package/src/thread-subscription.ts +129 -0
  74. package/src/thread-surface-config.test.ts +63 -0
  75. package/src/thread-surface-config.ts +61 -0
  76. package/src/validate.test.ts +279 -37
  77. package/src/validate.ts +422 -33
  78. package/src/vcs-source.test.ts +180 -0
  79. package/src/vcs-source.ts +229 -0
  80. package/src/version.ts +6 -0
  81. package/src/working-tree.ts +150 -0
  82. package/src/review.test.ts +0 -167
  83. package/src/review.ts +0 -174
package/src/server.ts CHANGED
@@ -1,26 +1,56 @@
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";
20
+ import type { AgentHarnessTools } from "@cueloop/schema";
8
21
  import { DaemonCore, type DaemonEvent } from "./api";
9
22
  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";
23
+ import { DEFAULT_ROLE, roleAllowsMethod, type DaemonRole } from "./capabilities";
24
+ import { isKnownMethod, parseParams, type MethodName } from "./validate";
25
+ import {
26
+ BackpressureWriter,
27
+ LineBuffer,
28
+ parseRequestFrame,
29
+ type Request,
30
+ type Response,
31
+ } from "./protocol";
32
+ import { cueloopHome, lockPath, ownerTokenPath, pidPath, socketPath } from "./paths";
33
+ import { DAEMON_VERSION } from "./version";
34
+ import { randomUUID, randomBytes } from "node:crypto";
35
+ import { parseAgentToolInput, assertAgentToolScope } from "./agent-tools";
36
+ import { ThreadAgentManager, type ThreadAgentOptions } from "./thread-agent";
13
37
 
14
- interface Conn {
38
+ interface Connection {
39
+ author?: string;
15
40
  write(data: string): void;
16
41
  subscribed: boolean;
42
+ role: DaemonRole;
17
43
  }
18
44
 
45
+ type MethodHandler = (connection: Connection, request: Request) => Response["result"];
46
+
19
47
  export interface DaemonOptions {
48
+ threadAgent?: Pick<ThreadAgentOptions, "enabled" | "enabledForThread" | "adapter" | "adapters">;
20
49
  home?: string;
21
- /** Idle-exit delay; 0 disables (tests, foreground runs). */
50
+ onEvent?: (event: import("./client").EventFrame) => void;
22
51
  idleExitMs?: number;
23
52
  onIdleExit?: () => void;
53
+ version?: string;
24
54
  }
25
55
 
26
56
  /**
@@ -29,24 +59,90 @@ export interface DaemonOptions {
29
59
  * so in-process ownership is tracked separately.
30
60
  */
31
61
  const HELD_HOMES = new Set<string>();
62
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
32
63
 
33
64
  export class DaemonServer {
65
+ private readonly threadAgent: ThreadAgentManager;
34
66
  readonly core: DaemonCore;
35
67
  readonly home: string;
36
- private conns = new Set<Conn>();
68
+ private connections = new Set<Connection>();
37
69
  private server: ReturnType<typeof Bun.listen> | null = null;
38
70
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
71
  private lockFd: number | null = null;
72
+ /** The secret that proves ownership; minted per daemon run, readable by the home's user only. */
73
+ private ownerToken = "";
40
74
  private readonly idleExitMs: number;
41
75
  private readonly onIdleExit: () => void;
76
+ private readonly version: string;
77
+ private readonly onEvent?: (event: import("./client").EventFrame) => void;
42
78
 
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));
79
+ constructor(options: DaemonOptions = {}) {
80
+ this.onEvent = options.onEvent;
81
+ this.home = options.home ?? cueloopHome();
82
+ this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
83
+ this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
84
+ this.version = options.version ?? DAEMON_VERSION;
47
85
  mkdirSync(this.home, { recursive: true, mode: 0o700 });
48
86
  this.core = new DaemonCore(this.home);
49
- this.core.onEvent((e) => this.broadcast(e));
87
+ this.threadAgent = new ThreadAgentManager({
88
+ home: this.home,
89
+ enabled: options.threadAgent?.enabled ?? false,
90
+ enabledForThread: options.threadAgent?.enabledForThread,
91
+ adapter: options.threadAgent?.adapter,
92
+ adapters: options.threadAgent?.adapters,
93
+ getThread: (id) => this.core.sessionGet(id),
94
+ tools: (thread): AgentHarnessTools => ({
95
+ definitions: [
96
+ {
97
+ name: "send_message",
98
+ description:
99
+ "Return the Thread and its comments to the main session using the existing Send message action.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ id: { type: "string" },
104
+ outcome: { type: "string", enum: ["comment", "approved", "changes_requested"] },
105
+ summary: { type: "string" },
106
+ },
107
+ required: ["id", "outcome"],
108
+ },
109
+ },
110
+ {
111
+ name: "reply_to_comment",
112
+ description:
113
+ "Append a reply to an existing comment discussion, preserving its quote and Changes origin.",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: {
117
+ id: { type: "string" },
118
+ commentId: { type: "string" },
119
+ body: { type: "string" },
120
+ },
121
+ required: ["id", "commentId", "body"],
122
+ },
123
+ },
124
+ {
125
+ name: "cueloop_api",
126
+ description:
127
+ "Call a cueloop API operation on this Thread or its repository. Use session.get to inspect this Thread. Global harness, sharing, deletion and Thread creation operations are unavailable. Parameters follow the cueloop socket API.",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: { method: { type: "string" }, params: { type: "object" } },
131
+ required: ["method", "params"],
132
+ },
133
+ },
134
+ ],
135
+ call: async (name, input) => this.callAgentTool(thread.id, name, input),
136
+ }),
137
+ onChange: (id) => {
138
+ this.onEvent?.({ event: "agent.updated", sessionId: id });
139
+ for (const connection of this.connections) {
140
+ if (connection.subscribed && connection.role === "owner")
141
+ connection.write(JSON.stringify({ event: "agent.updated", sessionId: id }) + "\n");
142
+ }
143
+ },
144
+ });
145
+ this.core.onEvent((event) => this.broadcast(event));
50
146
  }
51
147
 
52
148
  /**
@@ -59,18 +155,37 @@ export class DaemonServer {
59
155
  private acquireLock(): boolean {
60
156
  if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
61
157
  const path = lockPath(this.home);
158
+
62
159
  for (let attempt = 0; attempt < 3; attempt++) {
160
+ const preparedPath = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
161
+ const fd = openSync(preparedPath, "wx", 0o600);
162
+
63
163
  try {
64
- // "wx" fails when the file exists - the atomic part of the handshake
65
- const fd = openSync(path, "wx");
164
+ // Publish the complete pid atomically. A competing daemon must never
165
+ // mistake a newly created, still-empty lock for a crashed owner.
66
166
  writeFileSync(fd, String(process.pid));
167
+ linkSync(preparedPath, path);
67
168
  this.lockFd = fd;
68
169
  HELD_HOMES.add(this.home);
170
+
69
171
  return true;
70
- } catch {
172
+ } catch (error) {
173
+ closeSync(fd);
174
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
175
+
71
176
  let ownerPid = 0;
177
+
72
178
  try {
73
- ownerPid = Number(readFileSync(path, "utf8").trim());
179
+ let ownerText = readFileSync(path, "utf8").trim();
180
+
181
+ // Older daemon builds published the lock before writing their pid.
182
+ // Give that live owner time to finish instead of stealing its socket.
183
+ for (let probe = 0; !ownerText && probe < 25; probe++) {
184
+ Atomics.wait(LOCK_WAIT, 0, 0, 20);
185
+ ownerText = readFileSync(path, "utf8").trim();
186
+ }
187
+ if (!ownerText && Date.now() - statSync(path).mtimeMs < 5_000) return false;
188
+ ownerPid = Number(ownerText);
74
189
  } catch {
75
190
  // the owner vanished between open and read; retry
76
191
  continue;
@@ -78,6 +193,7 @@ export class DaemonServer {
78
193
  if (ownerPid && ownerPid !== process.pid) {
79
194
  try {
80
195
  process.kill(ownerPid, 0); // throws when the pid is gone
196
+
81
197
  return false; // a live daemon owns this home
82
198
  } catch {
83
199
  // stale lock from a crashed daemon
@@ -90,14 +206,23 @@ export class DaemonServer {
90
206
  } catch {
91
207
  return false;
92
208
  }
209
+ } finally {
210
+ rmSync(preparedPath, { force: true });
93
211
  }
94
212
  }
213
+
95
214
  return false;
96
215
  }
97
216
 
98
217
  private releaseLock(): void {
99
218
  HELD_HOMES.delete(this.home);
100
219
  if (this.lockFd !== null) {
220
+ try {
221
+ if (statSync(lockPath(this.home)).ino === fstatSync(this.lockFd).ino)
222
+ rmSync(lockPath(this.home), { force: true });
223
+ } catch {
224
+ // the lock was already removed or replaced
225
+ }
101
226
  try {
102
227
  closeSync(this.lockFd);
103
228
  } catch {
@@ -105,7 +230,6 @@ export class DaemonServer {
105
230
  }
106
231
  this.lockFd = null;
107
232
  }
108
- rmSync(lockPath(this.home), { force: true });
109
233
  }
110
234
 
111
235
  /**
@@ -115,27 +239,47 @@ export class DaemonServer {
115
239
  start(): string | null {
116
240
  if (!this.acquireLock()) return null;
117
241
  const path = socketPath(this.home);
242
+
118
243
  // safe now: holding the lock means no live daemon owns this home, so any
119
244
  // socket file left behind is stale
120
245
  if (existsSync(path)) rmSync(path, { force: true });
121
- const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
246
+ // the token exists before the socket does: the first client to connect
247
+ // must be able to prove ownership
248
+ this.ownerToken = randomBytes(32).toString("hex");
249
+ writeFileSync(ownerTokenPath(this.home), this.ownerToken, { mode: 0o600 });
250
+ chmodSync(ownerTokenPath(this.home), 0o600);
251
+ this.server = Bun.listen<{
252
+ buffer: LineBuffer;
253
+ connection: Connection;
254
+ writer: BackpressureWriter;
255
+ }>({
123
256
  unix: path,
124
257
  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();
258
+ open: (socket) => {
259
+ const writer = new BackpressureWriter(socket);
260
+ const connection: Connection = {
261
+ write: (data) => writer.write(data),
262
+ subscribed: false,
263
+ role: DEFAULT_ROLE,
264
+ };
265
+
266
+ socket.data = { buffer: new LineBuffer(), connection, writer };
267
+ this.connections.add(connection);
268
+ this.scheduleIdleCheck();
130
269
  },
131
- data(socket, data) {
270
+ data: (socket, data) => {
132
271
  socket.data.buffer.push(data.toString(), (line) => {
133
- void self.handleLine(socket.data.conn, line);
272
+ void this.respondToRequestLine(socket.data.connection, line);
134
273
  });
135
274
  },
136
- close(socket) {
137
- self.conns.delete(socket.data.conn);
138
- self.scheduleIdleCheck();
275
+ drain(socket) {
276
+ // drain can fire before open has attached data on some platforms;
277
+ // a throw here would take the whole daemon down with the socket
278
+ socket.data?.writer.drain();
279
+ },
280
+ close: (socket) => {
281
+ this.connections.delete(socket.data.connection);
282
+ this.scheduleIdleCheck();
139
283
  },
140
284
  error() {},
141
285
  },
@@ -143,21 +287,48 @@ export class DaemonServer {
143
287
  chmodSync(path, 0o600);
144
288
  writeFileSync(pidPath(this.home), String(process.pid));
145
289
  this.scheduleIdleCheck();
290
+
146
291
  return path;
147
292
  }
148
293
 
294
+ resumeAgents(): void {
295
+ for (const thread of this.core.sessionList({ status: "pending" })) {
296
+ if (!this.threadAgent.isEnabled(thread.id)) continue;
297
+ const state = this.threadAgent.get(thread.id);
298
+
299
+ if (
300
+ state.harness?.id === "pi" &&
301
+ state.submissions?.some((submission) => submission.status === "queued")
302
+ )
303
+ void this.threadAgent
304
+ .configure({ id: thread.id })
305
+ .catch((error) => console.error("[agent recovery]", error));
306
+ }
307
+ }
308
+
309
+ async shutdown(): Promise<void> {
310
+ await this.threadAgent.dispose();
311
+ this.stop();
312
+ }
313
+
149
314
  stop(): void {
315
+ void this.threadAgent.dispose().catch((error) => console.error("[agent shutdown]", error));
316
+ this.core.dispose();
317
+ if (this.lockFd === null) return;
150
318
  this.server?.stop(true);
151
319
  this.server = null;
152
320
  rmSync(socketPath(this.home), { force: true });
153
321
  rmSync(pidPath(this.home), { force: true });
322
+ rmSync(ownerTokenPath(this.home), { force: true });
154
323
  if (this.idleTimer) clearTimeout(this.idleTimer);
155
324
  this.releaseLock();
156
325
  }
157
326
 
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);
327
+ private broadcast(event: DaemonEvent): void {
328
+ this.onEvent?.(event);
329
+ const frame = JSON.stringify(event) + "\n";
330
+
331
+ for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
161
332
  this.scheduleIdleCheck();
162
333
  }
163
334
 
@@ -166,7 +337,7 @@ export class DaemonServer {
166
337
  if (this.idleExitMs <= 0) return;
167
338
  if (this.idleTimer) clearTimeout(this.idleTimer);
168
339
  this.idleTimer = setTimeout(() => {
169
- if (this.conns.size === 0 && !this.core.hasPendingSessions()) {
340
+ if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
170
341
  this.stop();
171
342
  this.onIdleExit();
172
343
  } else {
@@ -175,76 +346,448 @@ export class DaemonServer {
175
346
  }, this.idleExitMs);
176
347
  }
177
348
 
178
- private async handleLine(conn: Conn, line: string): Promise<void> {
179
- let req: Request;
349
+ private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
350
+ let request: Request;
351
+
180
352
  try {
181
- req = JSON.parse(line) as Request;
353
+ request = parseRequestFrame(line);
182
354
  } catch {
183
- conn.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
355
+ connection.write(
356
+ JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
357
+ "\n",
358
+ );
359
+
184
360
  return;
185
361
  }
186
362
  try {
187
- const result = await this.dispatch(conn, req);
188
- conn.write(JSON.stringify({ id: req.id, result }) + "\n");
363
+ const result = await this.dispatch(connection, request);
364
+
365
+ connection.write(JSON.stringify({ id: request.id, result }) + "\n");
189
366
  } catch (err) {
190
367
  const code = err instanceof DaemonError ? err.code : "internal";
191
368
  const message = err instanceof Error ? err.message : String(err);
192
- conn.write(JSON.stringify({ id: req.id, error: { code, message } }) + "\n");
369
+
370
+ connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
193
371
  }
194
372
  }
195
373
 
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)}`);
374
+ private async callAgentTool(threadId: string, name: string, input: string): Promise<string> {
375
+ const args = parseAgentToolInput(name, input);
376
+ const origin = this.core.sessionGet(threadId);
377
+
378
+ assertAgentToolScope(origin, args);
379
+ if (args.kind === "api" && args.method === "session.list") {
380
+ const { filter } = parseParams("session.list", args.params);
381
+
382
+ return JSON.stringify(!filter?.status || origin.status === filter.status ? [origin] : []);
200
383
  }
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);
384
+ if (args.kind === "reply") {
385
+ const thread = origin;
386
+ const comment = thread.annotations.find((entry) => entry.id === args.commentId);
387
+
388
+ if (!comment) return JSON.stringify(this.replyAgentComment(args));
389
+ const root =
390
+ thread.annotations.find((entry) => entry.id === (comment.replyTo ?? comment.id)) ?? comment;
391
+
392
+ return JSON.stringify(
393
+ this.core.sessionAnnotate(
394
+ args.id,
395
+ {
396
+ id: randomUUID(),
397
+ kind: "comment",
398
+ anchor: root.anchor,
399
+ target: root.target,
400
+ replyTo: root.id,
401
+ body: args.body,
402
+ author: "embedded-agent",
403
+ },
404
+ "Agent",
405
+ ),
406
+ );
407
+ }
408
+ const method = args.method;
409
+
410
+ if (!isKnownMethod(method)) throw new Error("Thread agent tool API method is unavailable");
411
+
412
+ return JSON.stringify(
413
+ await this.handlers[method](
414
+ { role: "owner", subscribed: false, write: () => {} },
415
+ { id: 0, method, params: args.params },
416
+ ),
417
+ );
418
+ }
419
+
420
+ private replyAgentComment(params: {
421
+ id: string;
422
+ commentId: string;
423
+ body: string;
424
+ }): ReturnType<ThreadAgentManager["reply"]> {
425
+ this.threadAgent.assertEnabled(params.id);
426
+
427
+ return this.threadAgent.reply(params.id, params.commentId, params.body);
428
+ }
429
+
430
+ private readonly handlers: Record<MethodName, MethodHandler> = {
431
+ "agent.configure": (_connection, request) => {
432
+ const params = parseParams("agent.configure", request.params);
433
+
434
+ this.threadAgent.assertEnabled(params.id);
435
+
436
+ return this.threadAgent.configure(params);
437
+ },
438
+ "agent.get": (_connection, request) => {
439
+ const { id } = parseParams("agent.get", request.params);
440
+
441
+ this.threadAgent.assertEnabled(id);
442
+
443
+ return this.threadAgent.get(id);
444
+ },
445
+ "agent.prompt": (_connection, request) => {
446
+ const params = parseParams("agent.prompt", request.params);
447
+
448
+ this.threadAgent.assertEnabled(params.id);
449
+
450
+ return this.threadAgent.prompt(params);
451
+ },
452
+ "agent.cancel": (_connection, request) => {
453
+ const { id } = parseParams("agent.cancel", request.params);
454
+
455
+ this.threadAgent.assertEnabled(id);
456
+
457
+ return this.threadAgent.cancel(id);
458
+ },
459
+ "agent.reply": (_connection, request) => {
460
+ const params = parseParams("agent.reply", request.params);
461
+
462
+ return this.replyAgentComment(params);
463
+ },
464
+ "agent.comment": (_connection, request) => {
465
+ const params = parseParams("agent.comment", request.params);
466
+
467
+ this.threadAgent.assertEnabled(params.id);
468
+
469
+ return this.threadAgent.comment(params);
470
+ },
471
+ "agent.permission": (_connection, request) => {
472
+ const params = parseParams("agent.permission", request.params);
473
+
474
+ this.threadAgent.assertEnabled(params.id);
475
+
476
+ return this.threadAgent.permission(params);
477
+ },
478
+ "daemon.ping": () => ({ pid: process.pid, version: this.version }),
479
+ "daemon.hello": (connection, request) => {
480
+ const params = parseParams("daemon.hello", request.params);
481
+
482
+ // ownership is proven, never declared: the token lives in the home
483
+ // directory the daemon serves, readable by its user alone
484
+ if (params.role === "owner" && params.token !== this.ownerToken) {
485
+ throw new DaemonError("forbidden", "owner token required");
233
486
  }
234
- case "session.setWorkingCopy": {
235
- const p = parseParams("session.setWorkingCopy", req.params);
236
- return core.sessionSetWorkingCopy(p.id, p.workingCopy);
487
+ if (params.clientVersion !== this.version) {
488
+ throw new DaemonError(
489
+ "version_mismatch",
490
+ `daemon is version ${this.version}, but this client is ${params.clientVersion ?? "unknown"}; align cueloop versions before restarting the daemon`,
491
+ );
237
492
  }
238
- case "session.resolve": {
239
- const p = parseParams("session.resolve", req.params);
240
- return core.sessionResolve(p.id, p.verdictKind, p.summary);
493
+
494
+ connection.role = params.role;
495
+ // identity is bound once, here; a non-owner never names it per call
496
+ if (params.role !== "owner" && params.author !== undefined) connection.author = params.author;
497
+
498
+ return {};
499
+ },
500
+ "daemon.shutdown": () => {
501
+ setTimeout(() => {
502
+ void this.shutdown()
503
+ .then(() => this.onIdleExit())
504
+ .catch((error) => console.error("[agent shutdown]", error));
505
+ }, 10);
506
+
507
+ return {};
508
+ },
509
+ "events.subscribe": (connection) => {
510
+ connection.subscribed = true;
511
+
512
+ return {};
513
+ },
514
+ "session.create": (_connection, request) => {
515
+ const params = parseParams("session.create", request.params);
516
+
517
+ return this.core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
518
+ },
519
+ "session.get": (_connection, request) =>
520
+ this.core.sessionGet(parseParams("session.get", request.params).id),
521
+ "session.list": (_connection, request) =>
522
+ this.core.sessionList(parseParams("session.list", request.params).filter),
523
+ "session.wait": (_connection, request) => {
524
+ const params = parseParams("session.wait", request.params);
525
+
526
+ return this.core.sessionWait(params.id, params.timeoutMs);
527
+ },
528
+ "session.annotate": (_connection, request) => {
529
+ const params = parseParams("session.annotate", request.params);
530
+
531
+ if (this.threadAgent.isReadOnly(params.id, params.annotation.id))
532
+ throw new Error("Thread agent submitted comments are read-only");
533
+
534
+ return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
535
+ },
536
+ "session.comment": (_connection, request) => {
537
+ const params = parseParams("session.comment", request.params);
538
+
539
+ if (this.threadAgent.isReadOnly(params.id, params.annotation.id))
540
+ throw new Error("Thread agent submitted comments are read-only");
541
+
542
+ return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
543
+ },
544
+ "session.removeAnnotation": (connection, request) => {
545
+ const params = parseParams("session.removeAnnotation", request.params);
546
+
547
+ // the owner removes any comment; a non-owner only the ones of the author it is bound to
548
+ if (connection.role !== "owner" && connection.author === undefined) {
549
+ throw new DaemonError(
550
+ "forbidden",
551
+ "a non-owner connection removes comments as its bound author",
552
+ );
241
553
  }
242
- case "session.submitRevision": {
243
- const p = parseParams("session.submitRevision", req.params);
244
- return core.sessionSubmitRevision(p.id, p.content);
554
+
555
+ if (this.threadAgent.isReadOnly(params.id, params.annotationId))
556
+ throw new Error("Thread agent submitted comments are read-only");
557
+
558
+ return this.core.sessionRemoveAnnotation(
559
+ params.id,
560
+ params.annotationId,
561
+ connection.role === "owner" ? undefined : connection.author,
562
+ );
563
+ },
564
+ "session.setParticipantName": (connection, request) => {
565
+ const params = parseParams("session.setParticipantName", request.params);
566
+
567
+ // the owner names anyone; a non-owner names only the author it is bound to
568
+ if (connection.role !== "owner" && params.author !== connection.author) {
569
+ throw new DaemonError("forbidden", "a non-owner connection names only its bound author");
245
570
  }
246
- default:
247
- throw new DaemonError("unknown_method", `unknown method ${req.method}`);
571
+
572
+ return this.core.sessionSetParticipantName(params.id, params.author, params.name);
573
+ },
574
+ "session.setWorkingCopy": (_connection, request) => {
575
+ const params = parseParams("session.setWorkingCopy", request.params);
576
+
577
+ return this.core.sessionSetWorkingCopy(params.id, params.workingCopy, params.textCuts);
578
+ },
579
+ "session.cutBlock": (_connection, request) => {
580
+ const params = parseParams("session.cutBlock", request.params);
581
+
582
+ return this.core.sessionCutBlock(params.id, params.blockIndex);
583
+ },
584
+ "session.navigate": (_connection, request) => {
585
+ const params = parseParams("session.navigate", request.params);
586
+
587
+ return this.core.sessionNavigate(params.id, params.entryId, params.summary, params.branch);
588
+ },
589
+ "session.branch": (_connection, request) => {
590
+ const params = parseParams("session.branch", request.params);
591
+
592
+ return this.core.sessionBranch(params.id, params.name);
593
+ },
594
+ "session.switch": (_connection, request) => {
595
+ const params = parseParams("session.switch", request.params);
596
+
597
+ return this.core.sessionSwitch(params.id, params.branch);
598
+ },
599
+ "session.label": (_connection, request) => {
600
+ const params = parseParams("session.label", request.params);
601
+
602
+ return this.core.sessionLabel(params.id, params.label);
603
+ },
604
+ "session.fork": (_connection, request) => {
605
+ const params = parseParams("session.fork", request.params);
606
+
607
+ return this.core.sessionFork(params.id);
608
+ },
609
+ "session.restoreBlock": (_connection, request) => {
610
+ const params = parseParams("session.restoreBlock", request.params);
611
+
612
+ return this.core.sessionRestoreBlock(params.id, params.baseBlockIndex, params.line);
613
+ },
614
+ "session.curate": (_connection, request) => {
615
+ const params = parseParams("session.curate", request.params);
616
+
617
+ return this.core.sessionCurate(params.id, params.rejections);
618
+ },
619
+ "session.setViewed": (_connection, request) => {
620
+ const params = parseParams("session.setViewed", request.params);
621
+
622
+ return this.core.sessionSetViewed(params.id, params.viewedPaths);
623
+ },
624
+ "session.setTitle": (_connection, request) => {
625
+ const params = parseParams("session.setTitle", request.params);
626
+
627
+ return this.core.sessionSetTitle(params.id, params.title);
628
+ },
629
+ "session.projectFiles": (_connection, request) =>
630
+ this.core.projectFiles(parseParams("session.projectFiles", request.params).id),
631
+ "session.fileContents": (_connection, request) => {
632
+ const params = parseParams("session.fileContents", request.params);
633
+
634
+ return this.core.fileContents(params.id, params.path);
635
+ },
636
+ "repo.files": (_connection, request) =>
637
+ this.core.repoFiles(parseParams("repo.files", request.params).cwd),
638
+ "repo.fileContents": (_connection, request) => {
639
+ const params = parseParams("repo.fileContents", request.params);
640
+
641
+ return this.core.repoFileContents(params.cwd, params.path);
642
+ },
643
+ "repo.changes": (_connection, request) =>
644
+ this.core.repoChanges(parseParams("repo.changes", request.params).cwd),
645
+ "repo.diff": (_connection, request) => {
646
+ const params = parseParams("repo.diff", request.params);
647
+
648
+ return this.core.repoDiff(params.cwd, params.vcs);
649
+ },
650
+ "session.workbench": (_connection, request) =>
651
+ this.core.workbenchSession(parseParams("session.workbench", request.params).cwd),
652
+ "session.workbenchReview": (_connection, request) =>
653
+ this.core.workbenchReview(parseParams("session.workbenchReview", request.params).cwd),
654
+ "session.refreshDiff": (_connection, request) => {
655
+ const params = parseParams("session.refreshDiff", request.params);
656
+
657
+ return this.core.sessionRefreshDiff(params.id);
658
+ },
659
+ "session.setShares": (_connection, request) => {
660
+ const params = parseParams("session.setShares", request.params);
661
+
662
+ if (params.shares.some((link) => link.agentEnabled))
663
+ this.threadAgent.assertEnabled(params.id);
664
+
665
+ return this.core.sessionSetShares(params.id, params.shares);
666
+ },
667
+ "session.delete": async (_connection, request) => {
668
+ const { id } = parseParams("session.delete", request.params);
669
+
670
+ await this.threadAgent.remove(id);
671
+ this.core.sessionDelete(id);
672
+
673
+ return {};
674
+ },
675
+ "session.mergeShared": (_connection, request) => {
676
+ const params = parseParams("session.mergeShared", request.params);
677
+
678
+ return this.core.sessionMergeShared(params.id, {
679
+ shareId: params.shareId,
680
+ annotations: params.annotations,
681
+ participants: params.participants,
682
+ removals: params.removals,
683
+ });
684
+ },
685
+ "session.sendMessage": (_connection, request) => {
686
+ const params = parseParams("session.sendMessage", request.params);
687
+
688
+ return this.core.sessionSendMessage(
689
+ params.id,
690
+ params.outcome,
691
+ params.summary,
692
+ params.actionBodies,
693
+ params.operationId,
694
+ );
695
+ },
696
+ "harness.bind": (_connection, request) => {
697
+ const params = parseParams("harness.bind", request.params);
698
+
699
+ return this.core.harnessBind(params);
700
+ },
701
+ "harness.getBinding": (_connection, request) => {
702
+ const params = parseParams("harness.getBinding", request.params);
703
+
704
+ return this.core.harnessGetBinding(params.bindingId);
705
+ },
706
+ "harness.bindingsForSession": (_connection, request) => {
707
+ const params = parseParams("harness.bindingsForSession", request.params);
708
+
709
+ return this.core.harnessBindingsForSession(params.harness, params.harnessSessionId);
710
+ },
711
+ "harness.consumeApprovedRetry": (_connection, request) => {
712
+ const params = parseParams("harness.consumeApprovedRetry", request.params);
713
+
714
+ return this.core.harnessConsumeApprovedRetry(
715
+ params.bindingId,
716
+ params.messageId,
717
+ params.content,
718
+ );
719
+ },
720
+ "delivery.pending": (_connection, request) => {
721
+ const params = parseParams("delivery.pending", request.params);
722
+
723
+ return this.core.deliveryPending(params.bindingId);
724
+ },
725
+ "delivery.acknowledge": (_connection, request) => {
726
+ const params = parseParams("delivery.acknowledge", request.params);
727
+
728
+ return this.core.deliveryAcknowledge(params.deliveryId);
729
+ },
730
+ "session.submitRevision": (_connection, request) => {
731
+ const params = parseParams("session.submitRevision", request.params);
732
+
733
+ return this.core.sessionSubmitRevision(
734
+ params.id,
735
+ params.content,
736
+ params.addressedAnnotationIds,
737
+ params.files,
738
+ params.source,
739
+ );
740
+ },
741
+ "herdr.getThreadSurface": (_connection, request) =>
742
+ this.core.herdrGetThreadSurface(parseParams("herdr.getThreadSurface", request.params).id),
743
+ "herdr.setThreadSurface": (_connection, request) => {
744
+ const params = parseParams("herdr.setThreadSurface", request.params);
745
+
746
+ this.core.herdrSetThreadSurface(params.id, {
747
+ tabId: params.tabId,
748
+ paneId: params.paneId,
749
+ mode: params.mode,
750
+ layout: params.layout,
751
+ });
752
+
753
+ return {};
754
+ },
755
+ "ghostty.getThreadSurface": (_connection, request) =>
756
+ this.core.ghosttyGetThreadSurface(parseParams("ghostty.getThreadSurface", request.params).id),
757
+ "ghostty.setThreadSurface": (_connection, request) => {
758
+ const params = parseParams("ghostty.setThreadSurface", request.params);
759
+
760
+ this.core.ghosttySetThreadSurface(params.id, { terminalId: params.terminalId });
761
+
762
+ return {};
763
+ },
764
+ "ghostty.claimThreadSurface": (_connection, request) =>
765
+ this.core.ghosttyClaimThreadSurface(
766
+ parseParams("ghostty.claimThreadSurface", request.params).id,
767
+ ),
768
+ "ghostty.releaseThreadSurface": (_connection, request) => {
769
+ this.core.ghosttyReleaseThreadSurface(
770
+ parseParams("ghostty.releaseThreadSurface", request.params).id,
771
+ );
772
+
773
+ return {};
774
+ },
775
+ };
776
+
777
+ private async dispatch(connection: Connection, request: Request): Promise<Response["result"]> {
778
+ // The wire is untrusted JSON: validate before DaemonCore sees anything.
779
+ if (!isKnownMethod(request.method)) {
780
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
248
781
  }
782
+ // Capability gate: a capped role (a review-side agent) cannot escalate past
783
+ // read + annotate, whatever primitive it sends.
784
+ if (!roleAllowsMethod(connection.role, request.method)) {
785
+ throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
786
+ }
787
+ if (!Object.hasOwn(this.handlers, request.method)) {
788
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
789
+ }
790
+
791
+ return this.handlers[request.method](connection, request);
249
792
  }
250
793
  }