@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,18 +27,28 @@ afterEach(() => {
27
27
 
28
28
  describe("socket round-trip", () => {
29
29
  test("ping and full session flow over the wire", async () => {
30
+ // Assert
30
31
  expect((await client.ping()).pid).toBe(process.pid);
31
- const s = await client.sessionCreate(WS, PLAN);
32
- expect(s.status).toBe("pending");
33
- await client.sessionAnnotate(s.id, {
32
+
33
+ // Act
34
+ const session = await client.sessionCreate(WS, PLAN);
35
+
36
+ // Assert
37
+ expect(session.status).toBe("pending");
38
+
39
+ // Act
40
+ await client.sessionAnnotate(session.id, {
34
41
  id: "a1",
35
42
  kind: "comment",
36
43
  anchor: { quote: "Body text", prefix: "", suffix: "." },
37
44
  body: "More detail please.",
38
45
  });
39
- const wait = client.sessionWait(s.id, 5_000);
40
- await client.sessionResolve(s.id, "request_changes", "Expand it.");
46
+ const wait = client.sessionWait(session.id, 5_000);
47
+
48
+ await client.sessionResolve(session.id, "request_changes", "Expand it.");
41
49
  const resolved = (await wait)!;
50
+
51
+ // Assert
42
52
  expect(resolved.verdict!.kind).toBe("request_changes");
43
53
  expect(resolved.verdict!.feedback).toContain("More detail please.");
44
54
  });
@@ -47,18 +57,25 @@ describe("socket round-trip", () => {
47
57
  expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
48
58
  try {
49
59
  await client.sessionGet("nope");
50
- } catch (e) {
51
- expect((e as DaemonClientError).code).toBe("not_found");
60
+ } catch (error) {
61
+ expect((error as DaemonClientError).code).toBe("not_found");
52
62
  }
53
63
  });
54
64
 
55
65
  test("events push to subscribed connections only", async () => {
66
+ // Arrange
56
67
  const observer = await DaemonClient.connect({ home });
57
68
  const seen: string[] = [];
58
- observer.onEvent((e) => seen.push(e.event));
69
+
70
+ observer.onEvent((event) => seen.push(event.event));
59
71
  await observer.subscribe();
60
- const s = await client.sessionCreate(WS, PLAN);
61
- await client.sessionResolve(s.id, "approve", "");
72
+
73
+ // Act
74
+ const session = await client.sessionCreate(WS, PLAN);
75
+
76
+ await client.sessionResolve(session.id, "approve", "");
77
+
78
+ // Assert
62
79
  // events are pushed async over the socket; give the loop a beat
63
80
  await Bun.sleep(50);
64
81
  expect(seen).toContain("session.created");
@@ -71,33 +88,165 @@ describe("socket round-trip", () => {
71
88
  try {
72
89
  await client.request("session.create", { artifact: { type: "plan", content: "x" } });
73
90
  throw new Error("should have thrown");
74
- } catch (e) {
75
- expect((e as DaemonClientError).code).toBe("invalid_params");
91
+ } catch (error) {
92
+ expect((error as DaemonClientError).code).toBe("invalid_params");
76
93
  }
77
94
  // wrong types
78
95
  try {
79
96
  await client.request("session.wait", { id: 42 });
80
97
  throw new Error("should have thrown");
81
- } catch (e) {
82
- expect((e as DaemonClientError).code).toBe("invalid_params");
98
+ } catch (error) {
99
+ expect((error as DaemonClientError).code).toBe("invalid_params");
83
100
  }
84
101
  // unknown method
85
102
  try {
86
103
  await client.request("session.nuke", {});
87
104
  throw new Error("should have thrown");
88
- } catch (e) {
89
- expect((e as DaemonClientError).code).toBe("unknown_method");
105
+ } catch (error) {
106
+ expect((error as DaemonClientError).code).toBe("unknown_method");
107
+ }
108
+ const prototypeMethods = await Promise.allSettled([
109
+ client.request("__proto__", {}),
110
+ client.request("toString", {}),
111
+ client.request("constructor", {}),
112
+ ]);
113
+
114
+ for (const result of prototypeMethods) {
115
+ expect(result.status).toBe("rejected");
116
+ expect(((result as PromiseRejectedResult).reason as DaemonClientError).code).toBe(
117
+ "unknown_method",
118
+ );
90
119
  }
91
120
  // the daemon is still fully alive afterwards
92
- const s = await client.sessionCreate(WS, PLAN);
93
- expect((await client.sessionGet(s.id)).id).toBe(s.id);
121
+ const session = await client.sessionCreate(WS, PLAN);
122
+
123
+ expect((await client.sessionGet(session.id)).id).toBe(session.id);
124
+ });
125
+
126
+ test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
127
+ // Act
128
+ const session = await client.sessionCreate(WS, {
129
+ type: "plan",
130
+ content: "# P",
131
+ meta: { agent: "claude-code", herdrPane: "%7" },
132
+ });
133
+ const got = await client.sessionGet(session.id);
134
+
135
+ // Assert
136
+ expect(got.artifact.meta.herdrPane).toBe("%7");
137
+ expect(got.artifact.meta.agent).toBe("claude-code");
138
+ });
139
+
140
+ test("share-sync verbs round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
141
+ // Given a shared session with one local note
142
+ const session = await client.sessionCreate(WS, PLAN);
143
+ const anchor = { quote: "Body text", prefix: "", suffix: "." };
144
+
145
+ await client.sessionSetShareId(session.id, "p_abc123xy");
146
+ await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
147
+ expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
148
+
149
+ // When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
150
+ const merged = await client.sessionMergeShared(session.id, {
151
+ annotations: [
152
+ {
153
+ id: "a1",
154
+ kind: "comment",
155
+ anchor,
156
+ body: "should not overwrite",
157
+ createdAt: "2026-01-01T00:00:00.000Z",
158
+ },
159
+ {
160
+ id: "a2",
161
+ kind: "comment",
162
+ anchor,
163
+ body: "theirs",
164
+ author: "SHA256:mate",
165
+ createdAt: "2026-01-01T00:00:00.000Z",
166
+ },
167
+ ],
168
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
169
+ });
170
+
171
+ // Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
172
+ expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
173
+ expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
174
+ expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
175
+ "SHA256:mate",
176
+ );
177
+ expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
178
+ });
179
+
180
+ test("mergeShared validates each annotation at the socket boundary", async () => {
181
+ // Given a session
182
+ const session = await client.sessionCreate(WS, PLAN);
183
+
184
+ try {
185
+ // When a malformed annotation is merged
186
+ // @ts-expect-error intentionally malformed annotation (empty id, missing fields)
187
+ await client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
188
+ throw new Error("should have thrown");
189
+ } catch (error) {
190
+ // Then it is rejected at the wire boundary
191
+ expect((error as DaemonClientError).code).toBe("invalid_params");
192
+ }
193
+ });
194
+
195
+ test("session.delete removes a session over the wire", async () => {
196
+ // Given a created session
197
+ const session = await client.sessionCreate(WS, PLAN);
198
+
199
+ // When it is deleted
200
+ await client.sessionDelete(session.id);
201
+
202
+ // Then it is gone, and deleting an unknown id is a not_found error
203
+ expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
204
+ try {
205
+ await client.sessionDelete("ses_missing");
206
+ throw new Error("should have thrown");
207
+ } catch (error) {
208
+ expect((error as DaemonClientError).code).toBe("not_found");
209
+ }
94
210
  });
95
211
 
96
212
  test("two clients see the same state (thin-renderer model)", async () => {
213
+ // Arrange
97
214
  const second = await DaemonClient.connect({ home });
98
- const s = await client.sessionCreate(WS, PLAN);
99
- const fromSecond = await second.sessionGet(s.id);
215
+
216
+ // Act
217
+ const session = await client.sessionCreate(WS, PLAN);
218
+ const fromSecond = await second.sessionGet(session.id);
219
+
220
+ // Assert
100
221
  expect(fromSecond.artifact.content).toBe(PLAN.content);
101
222
  second.close();
102
223
  });
103
224
  });
225
+
226
+ describe("large payloads survive socket backpressure", () => {
227
+ test("a session bigger than the kernel socket buffer round-trips", async () => {
228
+ // Regression: responses larger than one socket write used to truncate
229
+ // mid-line because the write return value was ignored, so the client
230
+ // never saw the frame's newline and every later request wedged too.
231
+ // Arrange
232
+ const bigContent =
233
+ "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
234
+
235
+ // Act
236
+ const created = await client.sessionCreate(
237
+ { repoRoot: "/repo", branch: "main" },
238
+ { type: "plan", content: bigContent, meta: { title: "big" } },
239
+ );
240
+ const fetched = await client.sessionGet(created.id);
241
+
242
+ // Assert
243
+ expect(fetched.artifact.content).toBe(bigContent);
244
+
245
+ // Act
246
+ // the connection must still be usable for the next frame
247
+ const listed = await client.sessionList();
248
+
249
+ // Assert
250
+ expect(listed.some((session) => session.id === created.id)).toBe(true);
251
+ }, 15_000);
252
+ });
package/src/server.ts CHANGED
@@ -1,21 +1,35 @@
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
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
8
17
  import { DaemonCore, type DaemonEvent } from "./api";
9
18
  import { DaemonError } from "./errors";
10
- import { isKnownMethod, parseParams } from "./validate";
11
- import { LineBuffer, type Request } from "./protocol";
19
+ import { roleAllowsMethod, type DaemonRole } from "./capabilities";
20
+ import { isKnownMethod, parseParams, type MethodName } from "./validate";
21
+ import { BackpressureWriter, LineBuffer, type Request } from "./protocol";
12
22
  import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
13
23
 
14
- interface Conn {
24
+ interface Connection {
15
25
  write(data: string): void;
16
26
  subscribed: boolean;
27
+ /** Capability role for this connection; the owner until a daemon.hello caps it. */
28
+ role: DaemonRole;
17
29
  }
18
30
 
31
+ type MethodHandler = (connection: Connection, request: Request) => unknown;
32
+
19
33
  export interface DaemonOptions {
20
34
  home?: string;
21
35
  /** Idle-exit delay; 0 disables (tests, foreground runs). */
@@ -33,20 +47,20 @@ const HELD_HOMES = new Set<string>();
33
47
  export class DaemonServer {
34
48
  readonly core: DaemonCore;
35
49
  readonly home: string;
36
- private conns = new Set<Conn>();
50
+ private connections = new Set<Connection>();
37
51
  private server: ReturnType<typeof Bun.listen> | null = null;
38
52
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
39
53
  private lockFd: number | null = null;
40
54
  private readonly idleExitMs: number;
41
55
  private readonly onIdleExit: () => void;
42
56
 
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));
57
+ constructor(options: DaemonOptions = {}) {
58
+ this.home = options.home ?? cueloopHome();
59
+ this.idleExitMs = options.idleExitMs ?? 15 * 60 * 1000;
60
+ this.onIdleExit = options.onIdleExit ?? (() => process.exit(0));
47
61
  mkdirSync(this.home, { recursive: true, mode: 0o700 });
48
62
  this.core = new DaemonCore(this.home);
49
- this.core.onEvent((e) => this.broadcast(e));
63
+ this.core.onEvent((event) => this.broadcast(event));
50
64
  }
51
65
 
52
66
  /**
@@ -59,16 +73,20 @@ export class DaemonServer {
59
73
  private acquireLock(): boolean {
60
74
  if (HELD_HOMES.has(this.home)) return false; // another instance here owns it
61
75
  const path = lockPath(this.home);
76
+
62
77
  for (let attempt = 0; attempt < 3; attempt++) {
63
78
  try {
64
79
  // "wx" fails when the file exists - the atomic part of the handshake
65
80
  const fd = openSync(path, "wx");
81
+
66
82
  writeFileSync(fd, String(process.pid));
67
83
  this.lockFd = fd;
68
84
  HELD_HOMES.add(this.home);
85
+
69
86
  return true;
70
87
  } catch {
71
88
  let ownerPid = 0;
89
+
72
90
  try {
73
91
  ownerPid = Number(readFileSync(path, "utf8").trim());
74
92
  } catch {
@@ -78,6 +96,7 @@ export class DaemonServer {
78
96
  if (ownerPid && ownerPid !== process.pid) {
79
97
  try {
80
98
  process.kill(ownerPid, 0); // throws when the pid is gone
99
+
81
100
  return false; // a live daemon owns this home
82
101
  } catch {
83
102
  // stale lock from a crashed daemon
@@ -92,6 +111,7 @@ export class DaemonServer {
92
111
  }
93
112
  }
94
113
  }
114
+
95
115
  return false;
96
116
  }
97
117
 
@@ -115,27 +135,42 @@ export class DaemonServer {
115
135
  start(): string | null {
116
136
  if (!this.acquireLock()) return null;
117
137
  const path = socketPath(this.home);
138
+
118
139
  // safe now: holding the lock means no live daemon owns this home, so any
119
140
  // socket file left behind is stale
120
141
  if (existsSync(path)) rmSync(path, { force: true });
121
- const self = this;
122
- this.server = Bun.listen<{ buffer: LineBuffer; conn: Conn }>({
142
+ this.server = Bun.listen<{
143
+ buffer: LineBuffer;
144
+ connection: Connection;
145
+ writer: BackpressureWriter;
146
+ }>({
123
147
  unix: path,
124
148
  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();
149
+ open: (socket) => {
150
+ const writer = new BackpressureWriter(socket);
151
+ const connection: Connection = {
152
+ write: (data) => writer.write(data),
153
+ subscribed: false,
154
+ role: "owner",
155
+ };
156
+
157
+ socket.data = { buffer: new LineBuffer(), connection, writer };
158
+ this.connections.add(connection);
159
+ this.scheduleIdleCheck();
130
160
  },
131
- data(socket, data) {
161
+ data: (socket, data) => {
132
162
  socket.data.buffer.push(data.toString(), (line) => {
133
- void self.handleLine(socket.data.conn, line);
163
+ void this.respondToRequestLine(socket.data.connection, line);
134
164
  });
135
165
  },
136
- close(socket) {
137
- self.conns.delete(socket.data.conn);
138
- self.scheduleIdleCheck();
166
+ drain(socket) {
167
+ // drain can fire before open has attached data on some platforms;
168
+ // a throw here would take the whole daemon down with the socket
169
+ socket.data?.writer.drain();
170
+ },
171
+ close: (socket) => {
172
+ this.connections.delete(socket.data.connection);
173
+ this.scheduleIdleCheck();
139
174
  },
140
175
  error() {},
141
176
  },
@@ -143,10 +178,12 @@ export class DaemonServer {
143
178
  chmodSync(path, 0o600);
144
179
  writeFileSync(pidPath(this.home), String(process.pid));
145
180
  this.scheduleIdleCheck();
181
+
146
182
  return path;
147
183
  }
148
184
 
149
185
  stop(): void {
186
+ this.core.dispose();
150
187
  this.server?.stop(true);
151
188
  this.server = null;
152
189
  rmSync(socketPath(this.home), { force: true });
@@ -155,9 +192,10 @@ export class DaemonServer {
155
192
  this.releaseLock();
156
193
  }
157
194
 
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);
195
+ private broadcast(event: DaemonEvent): void {
196
+ const frame = JSON.stringify(event) + "\n";
197
+
198
+ for (const connection of this.connections) if (connection.subscribed) connection.write(frame);
161
199
  this.scheduleIdleCheck();
162
200
  }
163
201
 
@@ -166,7 +204,7 @@ export class DaemonServer {
166
204
  if (this.idleExitMs <= 0) return;
167
205
  if (this.idleTimer) clearTimeout(this.idleTimer);
168
206
  this.idleTimer = setTimeout(() => {
169
- if (this.conns.size === 0 && !this.core.hasPendingSessions()) {
207
+ if (this.connections.size === 0 && !this.core.hasPendingSessions()) {
170
208
  this.stop();
171
209
  this.onIdleExit();
172
210
  } else {
@@ -175,76 +213,147 @@ export class DaemonServer {
175
213
  }, this.idleExitMs);
176
214
  }
177
215
 
178
- private async handleLine(conn: Conn, line: string): Promise<void> {
179
- let req: Request;
216
+ private async respondToRequestLine(connection: Connection, line: string): Promise<void> {
217
+ let request: Request;
218
+
180
219
  try {
181
- req = JSON.parse(line) as Request;
220
+ request = JSON.parse(line) as Request;
182
221
  } catch {
183
- conn.write(JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) + "\n");
222
+ connection.write(
223
+ JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
224
+ "\n",
225
+ );
226
+
184
227
  return;
185
228
  }
186
229
  try {
187
- const result = await this.dispatch(conn, req);
188
- conn.write(JSON.stringify({ id: req.id, result }) + "\n");
230
+ const result = await this.dispatch(connection, request);
231
+
232
+ connection.write(JSON.stringify({ id: request.id, result }) + "\n");
189
233
  } catch (err) {
190
234
  const code = err instanceof DaemonError ? err.code : "internal";
191
235
  const message = err instanceof Error ? err.message : String(err);
192
- conn.write(JSON.stringify({ id: req.id, error: { code, message } }) + "\n");
236
+
237
+ connection.write(JSON.stringify({ id: request.id, error: { code, message } }) + "\n");
193
238
  }
194
239
  }
195
240
 
196
- private async dispatch(conn: Conn, req: Request): Promise<unknown> {
241
+ private readonly handlers: Record<MethodName, MethodHandler> = {
242
+ "daemon.ping": () => ({ pid: process.pid }),
243
+ "daemon.hello": (connection, request) => {
244
+ connection.role = parseParams("daemon.hello", request.params).role;
245
+
246
+ return {};
247
+ },
248
+ "daemon.shutdown": () => {
249
+ setTimeout(() => {
250
+ this.stop();
251
+ this.onIdleExit();
252
+ }, 10);
253
+
254
+ return {};
255
+ },
256
+ "events.subscribe": (connection) => {
257
+ connection.subscribed = true;
258
+
259
+ return {};
260
+ },
261
+ "session.create": (_connection, request) => {
262
+ const params = parseParams("session.create", request.params);
263
+
264
+ return this.core.sessionCreate({ workspace: params.workspace, artifact: params.artifact });
265
+ },
266
+ "session.get": (_connection, request) =>
267
+ this.core.sessionGet(parseParams("session.get", request.params).id),
268
+ "session.list": (_connection, request) =>
269
+ this.core.sessionList(parseParams("session.list", request.params).filter),
270
+ "session.wait": (_connection, request) => {
271
+ const params = parseParams("session.wait", request.params);
272
+
273
+ return this.core.sessionWait(params.id, params.timeoutMs);
274
+ },
275
+ "session.annotate": (_connection, request) => {
276
+ const params = parseParams("session.annotate", request.params);
277
+
278
+ return this.core.sessionAnnotate(params.id, params.annotation, params.authorName);
279
+ },
280
+ "session.removeAnnotation": (_connection, request) => {
281
+ const params = parseParams("session.removeAnnotation", request.params);
282
+
283
+ return this.core.sessionRemoveAnnotation(params.id, params.annotationId);
284
+ },
285
+ "session.setWorkingCopy": (_connection, request) => {
286
+ const params = parseParams("session.setWorkingCopy", request.params);
287
+
288
+ return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
289
+ },
290
+ "session.setViewed": (_connection, request) => {
291
+ const params = parseParams("session.setViewed", request.params);
292
+
293
+ return this.core.sessionSetViewed(params.id, params.viewedPaths);
294
+ },
295
+ "session.refreshDiff": (_connection, request) => {
296
+ const params = parseParams("session.refreshDiff", request.params);
297
+
298
+ return this.core.sessionRefreshDiff(params.id);
299
+ },
300
+ "session.setShareId": (_connection, request) => {
301
+ const params = parseParams("session.setShareId", request.params);
302
+
303
+ return this.core.sessionSetShareId(params.id, params.shareId);
304
+ },
305
+ "session.delete": (_connection, request) => {
306
+ this.core.sessionDelete(parseParams("session.delete", request.params).id);
307
+
308
+ return {};
309
+ },
310
+ "session.mergeShared": (_connection, request) => {
311
+ const params = parseParams("session.mergeShared", request.params);
312
+
313
+ return this.core.sessionMergeShared(params.id, {
314
+ annotations: params.annotations,
315
+ participants: params.participants,
316
+ });
317
+ },
318
+ "session.resolve": (_connection, request) => {
319
+ const params = parseParams("session.resolve", request.params);
320
+
321
+ return this.core.sessionResolve(params.id, params.verdictKind, params.summary);
322
+ },
323
+ "session.submitRevision": (_connection, request) => {
324
+ const params = parseParams("session.submitRevision", request.params);
325
+
326
+ return this.core.sessionSubmitRevision(
327
+ params.id,
328
+ params.content,
329
+ params.addressedAnnotationIds,
330
+ );
331
+ },
332
+ "herdr.getTab": (_connection, request) =>
333
+ this.core.herdrGetTab(parseParams("herdr.getTab", request.params).id),
334
+ "herdr.setTab": (_connection, request) => {
335
+ const params = parseParams("herdr.setTab", request.params);
336
+
337
+ this.core.herdrSetTab(params.id, { tabId: params.tabId, paneId: params.paneId });
338
+
339
+ return {};
340
+ },
341
+ };
342
+
343
+ private async dispatch(connection: Connection, request: Request): Promise<unknown> {
197
344
  // 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)}`);
345
+ if (typeof request.method !== "string" || !isKnownMethod(request.method)) {
346
+ throw new DaemonError("unknown_method", `unknown method ${String(request.method)}`);
200
347
  }
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);
233
- }
234
- case "session.setWorkingCopy": {
235
- const p = parseParams("session.setWorkingCopy", req.params);
236
- return core.sessionSetWorkingCopy(p.id, p.workingCopy);
237
- }
238
- case "session.resolve": {
239
- const p = parseParams("session.resolve", req.params);
240
- return core.sessionResolve(p.id, p.verdictKind, p.summary);
241
- }
242
- case "session.submitRevision": {
243
- const p = parseParams("session.submitRevision", req.params);
244
- return core.sessionSubmitRevision(p.id, p.content);
245
- }
246
- default:
247
- throw new DaemonError("unknown_method", `unknown method ${req.method}`);
348
+ // Capability gate: a capped role (a review-side agent) cannot escalate past
349
+ // read + annotate, whatever verb it sends.
350
+ if (!roleAllowsMethod(connection.role, request.method)) {
351
+ throw new DaemonError("forbidden", `role ${connection.role} cannot call ${request.method}`);
248
352
  }
353
+ if (!Object.hasOwn(this.handlers, request.method)) {
354
+ throw new DaemonError("unknown_method", `unknown method ${request.method}`);
355
+ }
356
+
357
+ return this.handlers[request.method](connection, request);
249
358
  }
250
359
  }