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