@frebreco/canvas 0.0.1 → 0.2.0-next.1

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.
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Agent sessions on the host machine. Any agent that speaks ACP works — only
3
+ * its launch command differs (finding 01).
4
+ *
5
+ * Each session keeps one ACP connection to its agent process for as long as
6
+ * it is in use (finding 04): settings (model, reasoning effort, …) are ACP
7
+ * session config options, which only exist on a live session, and people
8
+ * change them between prompts. The connection is opened on demand, closed
9
+ * after a while idle, and on reopen the session is loaded and its last
10
+ * settings re-applied. ACP updates are turned into AG-UI chunks by
11
+ * `@tanstack/ai-acp`'s translator, so the thread format is unchanged.
12
+ *
13
+ * A session is an append-only event log (`AgentEvent`). The manager appends to
14
+ * it and fans every event out through `emit`; persistence and the WebSocket
15
+ * are the caller's concern.
16
+ */
17
+
18
+ import {
19
+ ClientSideConnection,
20
+ type McpServer,
21
+ ndJsonStream,
22
+ PROTOCOL_VERSION,
23
+ type RequestPermissionRequest,
24
+ type RequestPermissionResponse,
25
+ type SessionConfigOption,
26
+ type SessionUpdate,
27
+ } from "@agentclientprotocol/sdk";
28
+ import {
29
+ AsyncQueue,
30
+ translateAcpStream,
31
+ type AcpSessionUpdate,
32
+ type AcpStreamEvent,
33
+ } from "@tanstack/ai-acp";
34
+ import type { Subprocess } from "bun";
35
+ import { BOARD_SERVER_NAME, BOARD_TOOL_NAMES } from "../shared/board-tools";
36
+ import type {
37
+ AgentConfigOption,
38
+ AgentConfigValue,
39
+ AgentEvent,
40
+ AgentInfo,
41
+ AgentKind,
42
+ AgentSetting,
43
+ Author,
44
+ PermissionOption,
45
+ SessionMeta,
46
+ SessionSnapshot,
47
+ } from "../shared/protocol";
48
+ import { fromAcp, pendingChanges, settingsOf } from "./agent-config";
49
+ import { trimEvent } from "./trim-event";
50
+
51
+ interface AgentDefinition extends AgentInfo {
52
+ readonly command: ReadonlyArray<string>;
53
+ readonly env?: Record<string, string>;
54
+ /** Agent-specific `_meta` for `session/new` and `session/load`. */
55
+ readonly sessionMeta?: Record<string, unknown>;
56
+ }
57
+
58
+ const claudeAcpBin = Bun.resolveSync(
59
+ "@agentclientprotocol/claude-agent-acp/dist/index.js",
60
+ import.meta.dir,
61
+ );
62
+
63
+ /** How long an agent process may sit unused before it is stopped. */
64
+ const IDLE_MS = 15 * 60_000;
65
+
66
+ /** The ACP agents canvas knows how to launch, filtered to what is installed. */
67
+ export function detectAgents(): ReadonlyArray<AgentDefinition> {
68
+ const opencodeModel = process.env.CANVAS_OPENCODE_MODEL ?? "opencode-go/big-pickle";
69
+ const candidates: Array<AgentDefinition & { needs: string }> = [
70
+ {
71
+ kind: "claude",
72
+ label: "Claude Code",
73
+ needs: "claude",
74
+ command: ["bun", claudeAcpBin],
75
+ // The board tools run without asking: they only change the board,
76
+ // which the host's browser already lets the agent do (finding 06).
77
+ sessionMeta: {
78
+ claudeCode: {
79
+ options: {
80
+ allowedTools: BOARD_TOOL_NAMES.map((name) => `mcp__${BOARD_SERVER_NAME}__${name}`),
81
+ },
82
+ },
83
+ },
84
+ },
85
+ {
86
+ kind: "opencode",
87
+ label: "opencode",
88
+ needs: "opencode",
89
+ command: ["opencode", "acp"],
90
+ env: {
91
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
92
+ // The starting model; people pick another in the frame.
93
+ model: opencodeModel,
94
+ // Shell commands ask, so a person approves anything the agent runs.
95
+ permission: { bash: "ask" },
96
+ }),
97
+ },
98
+ },
99
+ ];
100
+ return candidates.filter((agent) => Bun.which(agent.needs) !== null);
101
+ }
102
+
103
+ /** A running agent process with the session open. */
104
+ interface Live {
105
+ readonly acp: ClientSideConnection;
106
+ readonly sessionId: string;
107
+ readonly process: Subprocess<"pipe", "pipe", "pipe">;
108
+ }
109
+
110
+ interface Turn {
111
+ readonly id: string;
112
+ readonly queue: AsyncQueue<AcpStreamEvent>;
113
+ }
114
+
115
+ interface Session {
116
+ meta: SessionMeta;
117
+ events: AgentEvent[];
118
+ options?: AgentConfigOption[];
119
+ live?: Promise<Live>;
120
+ turn?: Turn;
121
+ idleTimer?: ReturnType<typeof setTimeout>;
122
+ pending: Map<string, (optionId: string | null) => void>;
123
+ }
124
+
125
+ export interface AgentManagerOptions {
126
+ readonly dir: string;
127
+ readonly agents: ReadonlyArray<AgentDefinition>;
128
+ readonly restored: ReadonlyArray<SessionSnapshot>;
129
+ /** MCP servers to give a session's agent (the board tools). */
130
+ readonly mcpServers?: (sessionId: string) => McpServer[];
131
+ readonly onMeta: (meta: SessionMeta) => void;
132
+ readonly onEvent: (sessionId: string, event: AgentEvent) => void;
133
+ readonly onOptions: (sessionId: string, options: ReadonlyArray<AgentConfigOption>) => void;
134
+ /** Something went wrong that no request is waiting to hear about. */
135
+ readonly onError: (message: string) => void;
136
+ }
137
+
138
+ export class AgentManager {
139
+ private readonly sessions = new Map<string, Session>();
140
+
141
+ constructor(private readonly options: AgentManagerOptions) {
142
+ for (const snapshot of options.restored) {
143
+ // A turn that was in flight when the server stopped is over. Logs
144
+ // written before events were trimmed are trimmed here.
145
+ this.sessions.set(snapshot.meta.id, {
146
+ meta: { ...snapshot.meta, status: "idle" },
147
+ events: snapshot.events.map(trimEvent),
148
+ pending: new Map(),
149
+ });
150
+ }
151
+ }
152
+
153
+ snapshots(): SessionSnapshot[] {
154
+ return [...this.sessions.values()].map((s) => ({
155
+ meta: s.meta,
156
+ events: s.events,
157
+ ...(s.options && { options: s.options }),
158
+ }));
159
+ }
160
+
161
+ /**
162
+ * Make sure a session exists and its agent has listed its settings. Safe to
163
+ * repeat: the host's browser sends this for every agent frame it sees.
164
+ */
165
+ create(id: string, agent: AgentKind): void {
166
+ let session = this.sessions.get(id);
167
+ if (!session) {
168
+ if (!this.options.agents.some((a) => a.kind === agent))
169
+ throw new Error(`unknown agent ${agent}`);
170
+ session = { meta: { id, agent, status: "idle" }, events: [], pending: new Map() };
171
+ this.sessions.set(id, session);
172
+ this.options.onMeta(session.meta);
173
+ }
174
+ if (!session.options)
175
+ this.connect(session).catch((cause: unknown) =>
176
+ this.options.onError(`${agent} did not start: ${errorMessage(cause)}`),
177
+ );
178
+ }
179
+
180
+ /** Change one setting; the agent answers with the full, updated set. */
181
+ async configure(sessionId: string, configId: string, value: AgentConfigValue): Promise<void> {
182
+ const session = this.sessions.get(sessionId);
183
+ if (!session) throw new Error(`no session ${sessionId}`);
184
+ const live = await this.connect(session);
185
+ const response = await live.acp.setSessionConfigOption({
186
+ sessionId: live.sessionId,
187
+ configId,
188
+ ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }),
189
+ });
190
+ this.setOptions(session, response.configOptions);
191
+ this.touch(session);
192
+ }
193
+
194
+ cancel(sessionId: string): void {
195
+ const session = this.sessions.get(sessionId);
196
+ if (!session?.turn) return;
197
+ void session.live?.then((live) => live.acp.cancel({ sessionId: live.sessionId }));
198
+ for (const resolve of session.pending.values()) resolve(null);
199
+ }
200
+
201
+ resolvePermission(
202
+ sessionId: string,
203
+ requestId: string,
204
+ optionId: string | null,
205
+ by: string,
206
+ ): void {
207
+ const session = this.sessions.get(sessionId);
208
+ const resolve = session?.pending.get(requestId);
209
+ if (!session || !resolve) return;
210
+ session.pending.delete(requestId);
211
+ this.append(session, { kind: "permission-resolved", requestId, optionId, by });
212
+ this.setStatus(session, session.pending.size > 0 ? "waiting" : "running");
213
+ resolve(optionId);
214
+ }
215
+
216
+ prompt(sessionId: string, text: string, author: Author): void {
217
+ const session = this.sessions.get(sessionId);
218
+ if (!session) throw new Error(`no session ${sessionId}`);
219
+ if (session.meta.status !== "idle")
220
+ throw new Error("the agent is still working on the last prompt");
221
+ void this.runTurn(session, text, author);
222
+ }
223
+
224
+ /** Stop every agent process (server shutdown). */
225
+ close(): void {
226
+ for (const session of this.sessions.values()) this.disconnect(session);
227
+ }
228
+
229
+ private async runTurn(session: Session, text: string, author: Author): Promise<void> {
230
+ const turnId = crypto.randomUUID();
231
+ this.append(session, { kind: "turn", turnId, text, author, at: Date.now() });
232
+ this.setStatus(session, "running");
233
+ clearTimeout(session.idleTimer);
234
+
235
+ let error: string | undefined;
236
+ let cancelled = false as boolean;
237
+ try {
238
+ const live = await this.connect(session);
239
+ const turn: Turn = { id: turnId, queue: new AsyncQueue() };
240
+ session.turn = turn;
241
+ live.acp
242
+ .prompt({ sessionId: live.sessionId, prompt: [{ type: "text", text }] })
243
+ .then((response) => {
244
+ cancelled = response.stopReason === "cancelled";
245
+ turn.queue.push({
246
+ kind: "done",
247
+ stopReason: response.stopReason,
248
+ ...(response.usage && { usage: response.usage }),
249
+ });
250
+ turn.queue.end();
251
+ })
252
+ .catch((cause: unknown) => turn.queue.fail(cause));
253
+
254
+ const chunks = translateAcpStream(turn.queue, {
255
+ model: session.meta.agent,
256
+ runId: turnId,
257
+ threadId: session.meta.id,
258
+ genId: () => crypto.randomUUID(),
259
+ labels: {
260
+ sessionIdEvent: `${session.meta.agent}.session-id`,
261
+ contentEvent: `${session.meta.agent}.message-content`,
262
+ },
263
+ });
264
+ for await (const chunk of chunks) {
265
+ if (chunk.type === "RUN_ERROR") error = chunk.message ?? "agent error";
266
+ this.append(session, { kind: "chunk", turnId, chunk });
267
+ }
268
+ } catch (cause) {
269
+ error = errorMessage(cause);
270
+ }
271
+
272
+ for (const resolve of session.pending.values()) resolve(null);
273
+ session.pending.clear();
274
+ session.turn = undefined;
275
+ this.append(session, {
276
+ kind: "turn-end",
277
+ turnId,
278
+ ...(error !== undefined && !cancelled && { error }),
279
+ ...(cancelled && { cancelled: true }),
280
+ });
281
+ this.setStatus(session, "idle");
282
+ this.touch(session);
283
+ }
284
+
285
+ private connect(session: Session): Promise<Live> {
286
+ if (!session.live) {
287
+ const forget = () => {
288
+ if (session.live === live) session.live = undefined;
289
+ };
290
+ const live = this.open(session, forget);
291
+ session.live = live;
292
+ live.catch(forget);
293
+ }
294
+ this.touch(session);
295
+ return session.live;
296
+ }
297
+
298
+ private async open(session: Session, onExit: () => void): Promise<Live> {
299
+ const definition = this.options.agents.find((a) => a.kind === session.meta.agent);
300
+ if (!definition) throw new Error(`${session.meta.agent} is not installed on the host`);
301
+
302
+ const child = Bun.spawn([...definition.command], {
303
+ cwd: this.options.dir,
304
+ env: { ...process.env, ...definition.env },
305
+ stdin: "pipe",
306
+ stdout: "pipe",
307
+ stderr: "pipe",
308
+ });
309
+ let stderr = "";
310
+ void (async () => {
311
+ for await (const chunk of child.stderr.pipeThrough(new TextDecoderStream()))
312
+ stderr = (stderr + chunk).slice(-4096);
313
+ })().catch(() => undefined);
314
+ const exited = child.exited.then((code) => {
315
+ throw new Error(
316
+ `${definition.label} exited (code ${code})${stderr.trim() ? `: ${stderr.trim().split("\n").at(-1)}` : ""}`,
317
+ );
318
+ });
319
+ exited.catch((cause: unknown) => {
320
+ onExit();
321
+ session.turn?.queue.fail(cause);
322
+ });
323
+
324
+ const stream = ndJsonStream(
325
+ new WritableStream<Uint8Array>({
326
+ write: (chunk) => {
327
+ child.stdin.write(chunk);
328
+ void child.stdin.flush();
329
+ },
330
+ close: () => void child.stdin.end(),
331
+ }),
332
+ child.stdout,
333
+ );
334
+ const acp = new ClientSideConnection(
335
+ () => ({
336
+ requestPermission: (params) => this.askPermission(session, params),
337
+ sessionUpdate: async ({ update }) => this.onUpdate(session, update),
338
+ }),
339
+ stream,
340
+ );
341
+ const race = <T>(work: Promise<T>) => Promise.race([work, exited]);
342
+
343
+ try {
344
+ const init = await race(
345
+ acp.initialize({
346
+ protocolVersion: PROTOCOL_VERSION,
347
+ clientInfo: { name: "canvas", version: "0.0.0" },
348
+ // The agent works on the host directly; it never needs our fs.
349
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
350
+ }),
351
+ );
352
+ const request = {
353
+ cwd: this.options.dir,
354
+ mcpServers: this.options.mcpServers?.(session.meta.id) ?? [],
355
+ ...(definition.sessionMeta && { _meta: definition.sessionMeta }),
356
+ };
357
+ let sessionId: string | undefined;
358
+ let configOptions: SessionConfigOption[] | null | undefined;
359
+ const resume = session.meta.acpSessionId;
360
+ if (resume && init.agentCapabilities?.loadSession) {
361
+ // History replays as updates while no turn is open; they are dropped.
362
+ const loaded = await race(acp.loadSession({ ...request, sessionId: resume })).catch(
363
+ () => null,
364
+ );
365
+ if (loaded) {
366
+ sessionId = resume;
367
+ configOptions = loaded.configOptions;
368
+ }
369
+ }
370
+ if (!sessionId) {
371
+ const created = await race(acp.newSession(request));
372
+ sessionId = created.sessionId;
373
+ configOptions = created.configOptions;
374
+ session.meta = { ...session.meta, acpSessionId: sessionId };
375
+ this.options.onMeta(session.meta);
376
+ }
377
+ const live: Live = { acp, sessionId, process: child };
378
+ if (configOptions) {
379
+ const wanted = session.meta.settings ?? [];
380
+ this.setOptions(session, configOptions);
381
+ await this.restoreSettings(session, live, wanted);
382
+ }
383
+ return live;
384
+ } catch (cause) {
385
+ child.kill();
386
+ throw cause;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * A reopened session starts from the agent's defaults: put back what the
392
+ * session had, one change at a time, as each can change the others.
393
+ */
394
+ private async restoreSettings(
395
+ session: Session,
396
+ live: Live,
397
+ wanted: ReadonlyArray<AgentSetting>,
398
+ ): Promise<void> {
399
+ const tried = new Set<string>();
400
+ for (;;) {
401
+ const next = pendingChanges(session.options ?? [], wanted).find((c) => !tried.has(c.id));
402
+ if (!next) return;
403
+ tried.add(next.id);
404
+ try {
405
+ const response = await live.acp.setSessionConfigOption({
406
+ sessionId: live.sessionId,
407
+ configId: next.id,
408
+ ...(typeof next.value === "boolean"
409
+ ? { type: "boolean" as const, value: next.value }
410
+ : { value: next.value }),
411
+ });
412
+ this.setOptions(session, response.configOptions);
413
+ } catch {
414
+ // The agent refused it; keep what it reports.
415
+ }
416
+ }
417
+ }
418
+
419
+ private onUpdate(session: Session, update: SessionUpdate): void {
420
+ if (update.sessionUpdate === "config_option_update")
421
+ return this.setOptions(session, update.configOptions);
422
+ session.turn?.queue.push({ kind: "update", update: update as AcpSessionUpdate });
423
+ }
424
+
425
+ /** Park the agent's ask until the host answers it in the browser. */
426
+ private async askPermission(
427
+ session: Session,
428
+ request: RequestPermissionRequest,
429
+ ): Promise<RequestPermissionResponse> {
430
+ const turn = session.turn;
431
+ if (!turn) return { outcome: { outcome: "cancelled" } };
432
+ const requestId = crypto.randomUUID();
433
+ const options: PermissionOption[] = request.options.map((o) => ({
434
+ optionId: o.optionId,
435
+ name: o.name,
436
+ kind: o.kind,
437
+ }));
438
+ const optionId = await new Promise<string | null>((resolve) => {
439
+ session.pending.set(requestId, resolve);
440
+ this.append(session, {
441
+ kind: "permission",
442
+ turnId: turn.id,
443
+ requestId,
444
+ toolCallId: request.toolCall.toolCallId,
445
+ title: request.toolCall.title ?? "Tool call",
446
+ options,
447
+ });
448
+ this.setStatus(session, "waiting");
449
+ });
450
+ return {
451
+ outcome: optionId === null ? { outcome: "cancelled" } : { outcome: "selected", optionId },
452
+ };
453
+ }
454
+
455
+ private setOptions(session: Session, raw: ReadonlyArray<SessionConfigOption>): void {
456
+ const options = fromAcp(raw);
457
+ if (JSON.stringify(options) !== JSON.stringify(session.options)) {
458
+ session.options = options;
459
+ this.options.onOptions(session.meta.id, options);
460
+ }
461
+ const settings = settingsOf(options);
462
+ if (JSON.stringify(settings) !== JSON.stringify(session.meta.settings)) {
463
+ session.meta = { ...session.meta, settings };
464
+ this.options.onMeta(session.meta);
465
+ }
466
+ }
467
+
468
+ /** (Re)start the idle clock; a running turn keeps the agent alive. */
469
+ private touch(session: Session): void {
470
+ clearTimeout(session.idleTimer);
471
+ session.idleTimer = setTimeout(() => {
472
+ if (!session.turn) this.disconnect(session);
473
+ }, IDLE_MS);
474
+ }
475
+
476
+ private disconnect(session: Session): void {
477
+ clearTimeout(session.idleTimer);
478
+ const live = session.live;
479
+ session.live = undefined;
480
+ void live?.then((l) => l.process.kill()).catch(() => undefined);
481
+ }
482
+
483
+ private append(session: Session, raw: AgentEvent): void {
484
+ const event = trimEvent(raw);
485
+ session.events.push(event);
486
+ this.options.onEvent(session.meta.id, event);
487
+ }
488
+
489
+ private setStatus(session: Session, status: SessionMeta["status"]): void {
490
+ if (session.meta.status === status) return;
491
+ session.meta = { ...session.meta, status };
492
+ this.options.onMeta(session.meta);
493
+ }
494
+ }
495
+
496
+ const errorMessage = (cause: unknown) =>
497
+ cause instanceof Error
498
+ ? cause.message
499
+ : typeof cause === "object" && cause !== null && "message" in cause
500
+ ? String(cause.message)
501
+ : String(cause);
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The board tools as an MCP server, one per agent session (finding 06).
3
+ *
4
+ * ACP lets a client hand its agent MCP servers in `session/new`; canvas hands
5
+ * each session this one, at `/mcp/<sessionId>` with a secret of its own, so a
6
+ * call says which agent frame it comes from. It speaks the stateless subset of
7
+ * MCP's streamable HTTP transport: one JSON-RPC message (or batch) per POST,
8
+ * answered with JSON; no server-sent stream.
9
+ *
10
+ * The board lives in the host's browser, so a tool call is relayed there
11
+ * (`board-call`) and its answer (`board-result`) returned to the agent. File
12
+ * paths are checked against the shared set here first (ADR 0002), so the
13
+ * agent hears why a file can't be shown. It listens on loopback only: agents
14
+ * run on this machine.
15
+ */
16
+
17
+ import { randomBytes } from "node:crypto";
18
+ import type { McpServer } from "@agentclientprotocol/sdk";
19
+ import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
20
+ import type { FileContent } from "../shared/protocol";
21
+
22
+ export interface BoardCall {
23
+ readonly callId: string;
24
+ readonly sessionId: string;
25
+ readonly tool: string;
26
+ readonly args: unknown;
27
+ }
28
+
29
+ export interface BoardMcpOptions {
30
+ /** A file as the shared set lets it out. */
31
+ readonly read: (path: string) => FileContent;
32
+ /** Send a call to the host's browser; false if none is connected. */
33
+ readonly relay: (call: BoardCall) => boolean;
34
+ readonly timeoutMs?: number;
35
+ }
36
+
37
+ interface Rpc {
38
+ readonly jsonrpc: "2.0";
39
+ readonly id?: string | number;
40
+ readonly method: string;
41
+ readonly params?: Record<string, unknown>;
42
+ }
43
+
44
+ type ToolResult = { content: Array<{ type: "text"; text: string }>; isError?: true };
45
+
46
+ const LATEST_PROTOCOL = "2025-06-18";
47
+
48
+ export class BoardMcp {
49
+ private readonly secrets = new Map<string, string>();
50
+ private readonly pending = new Map<
51
+ string,
52
+ { resolve: (result: ToolResult) => void; timer: ReturnType<typeof setTimeout> }
53
+ >();
54
+ private readonly server: ReturnType<typeof Bun.serve>;
55
+
56
+ constructor(private readonly options: BoardMcpOptions) {
57
+ this.server = Bun.serve({
58
+ hostname: "127.0.0.1",
59
+ port: 0,
60
+ fetch: (req) => this.fetch(req),
61
+ });
62
+ }
63
+
64
+ /** The MCP server entry for a session's `session/new` / `session/load`. */
65
+ serverFor(sessionId: string): Extract<McpServer, { type: "http" }> {
66
+ let secret = this.secrets.get(sessionId);
67
+ if (!secret) this.secrets.set(sessionId, (secret = randomBytes(24).toString("base64url")));
68
+ return {
69
+ type: "http",
70
+ name: BOARD_SERVER_NAME,
71
+ url: `http://127.0.0.1:${this.server.port}/mcp/${encodeURIComponent(sessionId)}`,
72
+ headers: [{ name: "Authorization", value: `Bearer ${secret}` }],
73
+ };
74
+ }
75
+
76
+ /** The host's browser answered a call. */
77
+ result(callId: string, ok: boolean, text: string): void {
78
+ const waiting = this.pending.get(callId);
79
+ if (!waiting) return;
80
+ this.pending.delete(callId);
81
+ clearTimeout(waiting.timer);
82
+ waiting.resolve(ok ? done(text) : failed(text));
83
+ }
84
+
85
+ stop(): void {
86
+ void this.server.stop(true);
87
+ for (const [callId] of this.pending) this.result(callId, false, "canvas serve stopped");
88
+ }
89
+
90
+ private async fetch(req: Request): Promise<Response> {
91
+ const match = /^\/mcp\/([^/]+)$/.exec(new URL(req.url).pathname);
92
+ const sessionId = match ? decodeURIComponent(match[1]!) : null;
93
+ const secret = sessionId ? this.secrets.get(sessionId) : undefined;
94
+ if (!sessionId || !secret) return new Response("not found", { status: 404 });
95
+ if (req.headers.get("authorization") !== `Bearer ${secret}`)
96
+ return new Response("bad secret", { status: 401 });
97
+ if (req.method !== "POST") return new Response(null, { status: 405 });
98
+
99
+ let body: Rpc | Rpc[];
100
+ try {
101
+ body = (await req.json()) as Rpc | Rpc[];
102
+ } catch {
103
+ return Response.json(
104
+ { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } },
105
+ { status: 400 },
106
+ );
107
+ }
108
+ const messages = Array.isArray(body) ? body : [body];
109
+ const replies = (
110
+ await Promise.all(messages.map((message) => this.answer(sessionId, message)))
111
+ ).filter((reply) => reply !== null);
112
+ if (!replies.length) return new Response(null, { status: 202 });
113
+ return Response.json(Array.isArray(body) ? replies : replies[0]);
114
+ }
115
+
116
+ private async answer(sessionId: string, message: Rpc): Promise<object | null> {
117
+ // Notifications (no id) need no answer.
118
+ if (message.id === undefined) return null;
119
+ const reply = (result: unknown) => ({ jsonrpc: "2.0", id: message.id, result });
120
+ switch (message.method) {
121
+ case "initialize":
122
+ return reply({
123
+ protocolVersion:
124
+ typeof message.params?.protocolVersion === "string"
125
+ ? message.params.protocolVersion
126
+ : LATEST_PROTOCOL,
127
+ capabilities: { tools: { listChanged: false } },
128
+ serverInfo: { name: BOARD_SERVER_NAME, version: "0.0.0" },
129
+ instructions: boardInstructions(sessionId),
130
+ });
131
+ case "ping":
132
+ return reply({});
133
+ case "tools/list":
134
+ return reply({ tools: BOARD_TOOLS });
135
+ case "tools/call": {
136
+ const { name, arguments: args = {} } = (message.params ?? {}) as {
137
+ name?: string;
138
+ arguments?: Record<string, unknown>;
139
+ };
140
+ return reply(await this.call(sessionId, String(name), args));
141
+ }
142
+ default:
143
+ return {
144
+ jsonrpc: "2.0",
145
+ id: message.id,
146
+ error: { code: -32601, message: `method not found: ${message.method}` },
147
+ };
148
+ }
149
+ }
150
+
151
+ private async call(
152
+ sessionId: string,
153
+ tool: string,
154
+ args: Record<string, unknown>,
155
+ ): Promise<ToolResult> {
156
+ if (!BOARD_TOOLS.some((t) => t.name === tool)) return failed(`no tool ${tool}`);
157
+ let note = "";
158
+ if (typeof args.path === "string" && args.path.trim()) {
159
+ const path = args.path.trim().replace(/^(\.\/)+/, "");
160
+ const file = this.options.read(path);
161
+ if (file.kind === "denied") return failed(file.reason);
162
+ if (file.kind === "missing")
163
+ note = `\n(${path} doesn't exist yet; the frame shows it as soon as it is written.)`;
164
+ if (file.kind === "text" && typeof args.start_line === "number") {
165
+ const lines = file.text.split("\n").length - (file.text.endsWith("\n") ? 1 : 0);
166
+ if (args.start_line > lines) return failed(`${path} has ${lines} lines`);
167
+ }
168
+ }
169
+
170
+ const callId = crypto.randomUUID();
171
+ const result = new Promise<ToolResult>((resolve) => {
172
+ const timer = setTimeout(
173
+ () => this.result(callId, false, "the board did not answer in time"),
174
+ this.options.timeoutMs ?? 15_000,
175
+ );
176
+ this.pending.set(callId, { resolve, timer });
177
+ });
178
+ if (!this.options.relay({ callId, sessionId, tool, args })) {
179
+ this.result(
180
+ callId,
181
+ false,
182
+ "the board isn't open right now: it lives in the host's browser, which is not connected",
183
+ );
184
+ }
185
+ const answer = await result;
186
+ return note && !answer.isError ? done(answer.content[0]!.text + note) : answer;
187
+ }
188
+ }
189
+
190
+ const done = (text: string): ToolResult => ({ content: [{ type: "text", text }] });
191
+ const failed = (text: string): ToolResult => ({ content: [{ type: "text", text }], isError: true });