@frebreco/canvas 0.3.0-next.1 → 0.3.0-next.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frebreco/canvas",
3
- "version": "0.3.0-next.1",
3
+ "version": "0.3.0-next.10",
4
4
  "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "bin",
20
20
  "src",
21
+ "skills",
21
22
  "README.md",
22
23
  "LICENSE"
23
24
  ],
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "canvas",
3
+ "description": "Skills canvas gives the agents it runs.",
4
+ "skills": "./"
5
+ }
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: code-tour
3
+ description: Give a code tour on the canvas board — a guided walk through how part of the codebase works, as one files frame people click through. Use when asked for a tour or walkthrough of code, how a feature works across files, or to onboard someone to an area.
4
+ ---
5
+
6
+ A code tour is one files frame on the board whose list holds, in reading order: a guide (a
7
+ markdown scratch file), then the **stops** — project files at the lines that matter — and
8
+ optionally a diagram at the end. People click through it at their own pace; the thread only
9
+ points at it.
10
+
11
+ ## Steps
12
+
13
+ 1. **Find the stops.** Read the code until you can tell the story from entry point to effect.
14
+ Pick 4–10 stops in the order a newcomer needs them, usually where it starts → the core → the
15
+ edges. A stop is one file at a tight line range (roughly 5–40 lines): the function, the branch,
16
+ the type that carries the idea. Only files git tracks can be shown; a stop in an ignored file
17
+ gets described in the guide instead. Done when each stop's range is read from the file as it
18
+ is now, and the stops tell the story without gaps.
19
+
20
+ 2. **Write the guide**, `tour-<topic>.md`: a one-paragraph overview, then one short section per
21
+ stop, numbered and titled like its list entry — what to look at in those lines, why it
22
+ matters, and how it hands over to the next stop. Name functions and types; quote lines
23
+ sparingly, the frame shows them. End with open questions or pitfalls if you found any.
24
+
25
+ 3. **Draw it**, when the flow branches or spans several components: `tour-<topic>.html`, a
26
+ single self-contained page (inline CSS and SVG or script) with boxes named like the stops.
27
+ Skip it for a straight line of calls.
28
+
29
+ 4. **Open the tour.** `view_board` first; retarget an existing tour frame on the same topic with
30
+ `update_frame` rather than opening a second one. Otherwise one `open_frame` of type `file`
31
+ whose `files` list is the whole tour, the guide first:
32
+
33
+ ```json
34
+ {
35
+ "type": "file",
36
+ "title": "Tour: login",
37
+ "files": [
38
+ { "display": "0 Guide.md", "name": "tour-login.md", "content": "…" },
39
+ { "display": "1 Route/login.ts", "path": "src/routes/login.ts", "start_line": 12, "end_line": 30 },
40
+ { "display": "2 Session/session.ts", "path": "src/auth/session.ts", "start_line": 5, "end_line": 22 },
41
+ { "display": "3 Diagram.html", "name": "tour-login.html", "content": "…" }
42
+ ]
43
+ }
44
+ ```
45
+
46
+ Number the display names like the guide's sections, so people can match them; folders are
47
+ worth it only for chapters of a long tour. Done when every stop in the list has its section
48
+ in the guide and every section its stop.
49
+
50
+ 5. **Reply briefly**: the tour's frame, how many stops, and the story in one or two sentences.
51
+ The guide carries the explanation; the thread stays short.
52
+
53
+ ## Walking people through it
54
+
55
+ When people ask to be walked through the tour ("next", "show stop 3"), `update_frame` the tour
56
+ frame to that stop's `path`, `start_line` and `end_line`, and explain it in your reply. Changing
57
+ the frame makes you its occupant for the turn, so everyone following scrolls with you.
58
+
59
+ ## Revising
60
+
61
+ Follow-up questions often deserve a stop or a section. `write_board_file` with the guide's path
62
+ rewrites it in place. `update_frame` with `files` replaces the whole list, so send every entry,
63
+ the new one in its place. Name the guide and diagram there by their `canvas:scratch/` paths:
64
+ `content` would create new copies.
package/src/cli.ts CHANGED
@@ -54,6 +54,7 @@ const { server, room } = await serve({
54
54
  port: Number(values.port),
55
55
  hostname: tlsHost ? "0.0.0.0" : "127.0.0.1",
56
56
  ...(tlsHost && { tls: { cert: values.cert, key: values.key } }),
57
+ ...(manifest.version && { version: manifest.version }),
57
58
  });
58
59
 
59
60
  const serverUrl = tlsHost ? `wss://${tlsHost}:${server.port}` : `ws://127.0.0.1:${server.port}`;
@@ -46,6 +46,7 @@ import type {
46
46
  SessionSnapshot,
47
47
  } from "../shared/protocol";
48
48
  import { fromAcp, pendingChanges, settingsOf } from "./agent-config";
49
+ import { SKILLS_DIR } from "./skills";
49
50
  import { trimEvent } from "./trim-event";
50
51
 
51
52
  interface AgentDefinition extends AgentInfo {
@@ -78,6 +79,8 @@ export function detectAgents(): ReadonlyArray<AgentDefinition> {
78
79
  claudeCode: {
79
80
  options: {
80
81
  allowedTools: BOARD_TOOL_NAMES.map((name) => `mcp__${BOARD_SERVER_NAME}__${name}`),
82
+ // canvas's own skills; the host's stay available beside them.
83
+ plugins: [{ type: "local", path: SKILLS_DIR, skipMcpDiscovery: true }],
81
84
  },
82
85
  },
83
86
  },
@@ -93,6 +96,8 @@ export function detectAgents(): ReadonlyArray<AgentDefinition> {
93
96
  model: opencodeModel,
94
97
  // Shell commands ask, so a person approves anything the agent runs.
95
98
  permission: { bash: "ask" },
99
+ // canvas's own skills; the host's stay available beside them.
100
+ skills: { paths: [SKILLS_DIR] },
96
101
  }),
97
102
  },
98
103
  },
@@ -12,12 +12,24 @@
12
12
  * paths are checked against the shared set here first (ADR 0002), so the
13
13
  * agent hears why a file can't be shown. It listens on loopback only: agents
14
14
  * run on this machine.
15
+ *
16
+ * An agent's comment (ADR 0006) gets the text of its lines here, from the
17
+ * shared set: the browser keeps it to find the lines again as the file
18
+ * changes.
19
+ *
20
+ * Scratch files (ADR 0005) never reach the browser as content: a call's
21
+ * `content` becomes a file in `.canvas/scratch/` here, and the call goes on
22
+ * with its path. Reading and writing them needs no board, so those tools are
23
+ * answered here.
15
24
  */
16
25
 
17
26
  import { randomBytes } from "node:crypto";
18
27
  import type { McpServer } from "@agentclientprotocol/sdk";
19
28
  import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
29
+ import { linesOf, quoteOf } from "../shared/comments";
20
30
  import type { FileContent } from "../shared/protocol";
31
+ import type { Scratch } from "./scratch";
32
+ import type { Skill } from "./skills";
21
33
 
22
34
  export interface BoardCall {
23
35
  readonly callId: string;
@@ -29,8 +41,11 @@ export interface BoardCall {
29
41
  export interface BoardMcpOptions {
30
42
  /** A file as the shared set lets it out. */
31
43
  readonly read: (path: string) => FileContent;
44
+ readonly scratch: Pick<Scratch, "create" | "write" | "read" | "list" | "remove">;
32
45
  /** Send a call to the host's browser; false if none is connected. */
33
46
  readonly relay: (call: BoardCall) => boolean;
47
+ /** canvas's skills, named in the priming. */
48
+ readonly skills?: ReadonlyArray<Skill>;
34
49
  readonly timeoutMs?: number;
35
50
  }
36
51
 
@@ -126,7 +141,7 @@ export class BoardMcp {
126
141
  : LATEST_PROTOCOL,
127
142
  capabilities: { tools: { listChanged: false } },
128
143
  serverInfo: { name: BOARD_SERVER_NAME, version: "0.0.0" },
129
- instructions: boardInstructions(sessionId),
144
+ instructions: boardInstructions(sessionId, this.options.skills),
130
145
  });
131
146
  case "ping":
132
147
  return reply({});
@@ -151,22 +166,105 @@ export class BoardMcp {
151
166
  private async call(
152
167
  sessionId: string,
153
168
  tool: string,
154
- args: Record<string, unknown>,
169
+ input: Record<string, unknown>,
155
170
  ): Promise<ToolResult> {
156
171
  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`);
172
+ const args = { ...input };
173
+ const notes: string[] = [];
174
+ // Content becomes a scratch file; the board only ever sees its path.
175
+ const created: string[] = [];
176
+ const undo = () => created.forEach((path) => this.options.scratch.remove(path));
177
+ try {
178
+ switch (tool) {
179
+ case "read_board_file":
180
+ return done(this.readScratch(args.path));
181
+ case "write_board_file":
182
+ return done(this.writeScratch(args));
167
183
  }
184
+ if (args.content !== undefined) {
185
+ if (args.path !== undefined) throw new Error("give either path or content, not both");
186
+ args.path = this.createScratch(args.name, args.content, created, notes);
187
+ delete args.content;
188
+ delete args.name;
189
+ } else this.checkPath(args, notes);
190
+ if (tool === "add_comment") this.quote(args);
191
+ if (Array.isArray(args.files))
192
+ args.files = args.files.map((value: unknown) => {
193
+ if (typeof value !== "object" || value === null)
194
+ throw new Error("each entry of files is an object with a display path");
195
+ const entry = { ...(value as Record<string, unknown>) };
196
+ if (entry.content !== undefined) {
197
+ if (entry.path !== undefined)
198
+ throw new Error("give a list entry either path or content, not both");
199
+ // No name: one from the display path's last segment, e.g. "1 Overview.md" → 1-Overview.md.
200
+ const name =
201
+ entry.name ??
202
+ (String(entry.display ?? "")
203
+ .split("/")
204
+ .at(-1)!
205
+ .replace(/[^\w.-]+/g, "-")
206
+ .replace(/^[^\w]+/, "") ||
207
+ "file");
208
+ entry.path = this.createScratch(name, entry.content, created, notes);
209
+ delete entry.content;
210
+ delete entry.name;
211
+ } else this.checkPath(entry, notes);
212
+ return entry;
213
+ });
214
+ } catch (error) {
215
+ undo();
216
+ return failed((error as Error).message);
217
+ }
218
+ if (tool === "view_board") {
219
+ const scratch = this.options.scratch.list();
220
+ notes.push(
221
+ scratch.length ? `Scratch files: ${scratch.join(", ")}.` : "No scratch files yet.",
222
+ );
168
223
  }
169
224
 
225
+ const answer = await this.relay(sessionId, tool, args);
226
+ if (answer.isError) {
227
+ undo();
228
+ return answer;
229
+ }
230
+ return notes.length ? done([answer.content[0]!.text, ...notes].join("\n")) : answer;
231
+ }
232
+
233
+ /** A named file must be in the shared set (or a scratch file), and have the lines asked for. */
234
+ private checkPath(args: Record<string, unknown>, notes: string[]) {
235
+ if (typeof args.path !== "string" || !args.path.trim()) return;
236
+ const path = args.path.trim().replace(/^(\.\/)+/, "");
237
+ const file = this.options.read(path);
238
+ if (file.kind === "denied") throw new Error(file.reason);
239
+ if (file.kind === "missing")
240
+ notes.push(`(${path} doesn't exist yet; the frame shows it as soon as it is written.)`);
241
+ if (file.kind === "text" && typeof args.start_line === "number") {
242
+ const lines = file.text.split("\n").length - (file.text.endsWith("\n") ? 1 : 0);
243
+ if (args.start_line > lines) throw new Error(`${path} has ${lines} lines`);
244
+ }
245
+ }
246
+
247
+ /** An agent's comment carries the text of its lines. */
248
+ private quote(args: Record<string, unknown>) {
249
+ if (typeof args.path !== "string" || !args.path.trim())
250
+ throw new Error("a comment needs the path of the file it is on");
251
+ const path = args.path.trim().replace(/^(\.\/)+/, "");
252
+ const file = this.options.read(path);
253
+ if (file.kind !== "text")
254
+ throw new Error(
255
+ `${path} can't be commented on: ${file.kind === "denied" ? file.reason : file.kind}`,
256
+ );
257
+ const start = Math.floor(Number(args.start_line));
258
+ const end = args.end_line === undefined ? start : Math.floor(Number(args.end_line));
259
+ const quote = quoteOf(file.text, start, end);
260
+ if (quote === null)
261
+ throw new Error(
262
+ `lines ${start}-${end} aren't in ${path}: it has ${linesOf(file.text).length} lines`,
263
+ );
264
+ Object.assign(args, { path, start_line: start, end_line: end, quote });
265
+ }
266
+
267
+ private relay(sessionId: string, tool: string, args: Record<string, unknown>) {
170
268
  const callId = crypto.randomUUID();
171
269
  const result = new Promise<ToolResult>((resolve) => {
172
270
  const timer = setTimeout(
@@ -182,8 +280,41 @@ export class BoardMcp {
182
280
  "the board isn't open right now: it lives in the host's browser, which is not connected",
183
281
  );
184
282
  }
185
- const answer = await result;
186
- return note && !answer.isError ? done(answer.content[0]!.text + note) : answer;
283
+ return result;
284
+ }
285
+
286
+ /** A new scratch file for `content`; its path. Says so in `notes`. */
287
+ private createScratch(name: unknown, content: unknown, created: string[], notes: string[]) {
288
+ if (typeof content !== "string") throw new Error("content must be text");
289
+ if (typeof name !== "string" || !name.trim())
290
+ throw new Error("content needs a name for its scratch file, e.g. overview.md");
291
+ const path = this.options.scratch.create(name.trim(), content);
292
+ created.push(path);
293
+ const taken = !path.endsWith(`/${name.trim()}`);
294
+ notes.push(`Wrote scratch file ${path}${taken ? ` (${name.trim()} was taken)` : ""}.`);
295
+ return path;
296
+ }
297
+
298
+ private readScratch(path: unknown): string {
299
+ if (typeof path !== "string") throw new Error("path must be a canvas:scratch/… path");
300
+ const file = this.options.scratch.read(path.trim());
301
+ if (file.kind === "text") return file.text;
302
+ if (file.kind === "denied") throw new Error(file.reason);
303
+ if (file.kind === "missing")
304
+ throw new Error(`${path} doesn't exist; view_board lists the scratch files`);
305
+ throw new Error(`${path} can't be read`);
306
+ }
307
+
308
+ private writeScratch(args: Record<string, unknown>): string {
309
+ if (typeof args.path === "string" && args.path.trim()) {
310
+ if (args.name !== undefined) throw new Error("give either path (overwrite) or name (create)");
311
+ if (typeof args.content !== "string") throw new Error("content must be text");
312
+ this.options.scratch.write(args.path.trim(), args.content);
313
+ return `Wrote ${args.path.trim()}; frames showing it update.`;
314
+ }
315
+ const notes: string[] = [];
316
+ this.createScratch(args.name, args.content, [], notes);
317
+ return notes[0]!;
187
318
  }
188
319
  }
189
320
 
@@ -8,11 +8,15 @@
8
8
  * One recursive watcher covers the working dir: editors and agents often
9
9
  * replace files rather than write them in place, and a frame may wait for a
10
10
  * file (or its directory) that does not exist yet.
11
+ *
12
+ * Scratch files (`canvas:scratch/…`, ADR 0005) are read and listed alongside;
13
+ * the watcher skips `.canvas/`, so `Scratch` reports its writes itself.
11
14
  */
12
15
 
13
16
  import { readFileSync, statSync, watch, type FSWatcher } from "node:fs";
14
17
  import { sep } from "node:path";
15
18
  import type { FileContent } from "../shared/protocol";
19
+ import { isScratchPath, Scratch } from "./scratch";
16
20
  import { SharedSet } from "./shared-set";
17
21
 
18
22
  export const MAX_FILE_BYTES = 1024 * 1024;
@@ -35,11 +39,13 @@ export class Files {
35
39
  dir: string,
36
40
  private readonly onFile: (path: string, file: FileContent) => void,
37
41
  private readonly onTree: (paths: ReadonlyArray<string>) => void,
42
+ private readonly scratch: Scratch = new Scratch(dir),
38
43
  ) {
39
44
  this.shared = new SharedSet(dir);
40
45
  }
41
46
 
42
47
  read(path: string): FileContent {
48
+ if (isScratchPath(path)) return this.scratch.read(path);
43
49
  let absolute: string;
44
50
  try {
45
51
  absolute = this.shared.resolve(path);
@@ -83,6 +89,12 @@ export class Files {
83
89
  this.ensureWatcher();
84
90
  }
85
91
 
92
+ /** A scratch file was written: re-send it, and the tree if it is new. */
93
+ scratchChanged(path: string): void {
94
+ if (this.subscribers.has(path)) this.send(path);
95
+ if (this.treeWatched) this.sendTree();
96
+ }
97
+
86
98
  stop(): void {
87
99
  this.watcher?.close();
88
100
  if (this.timer) clearTimeout(this.timer);
@@ -97,7 +109,7 @@ export class Files {
97
109
  }
98
110
 
99
111
  private sendTree() {
100
- const paths = this.shared.list();
112
+ const paths = [...this.shared.list(), ...this.scratch.list()];
101
113
  const key = paths.join("\0");
102
114
  if (key === this.lastTree) return;
103
115
  this.lastTree = key;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Scratch files (ADR 0005): content an agent writes for the board only —
3
+ * a write-up, an HTML visualisation — kept in `<dir>/.canvas/scratch/`, out
4
+ * of the project. Board paths name them `canvas:scratch/<name>`; they are
5
+ * read like the shared set's files and mirrored the same way.
6
+ *
7
+ * Only agents write them, through their board tools: nothing a browser
8
+ * sends reaches `create` or `write`. One flat namespace; a name is never
9
+ * taken twice, so creating one never overwrites another agent's file.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { SCRATCH_PREFIX } from "../shared/board-tools";
23
+ import type { FileContent } from "../shared/protocol";
24
+
25
+ export const MAX_SCRATCH_BYTES = 1024 * 1024;
26
+
27
+ export const isScratchPath = (path: string) => path.startsWith(SCRATCH_PREFIX);
28
+
29
+ /** A name as agents may give it: one plain segment, e.g. `auth-overview.md`. */
30
+ const NAME = /^[\w][\w.-]*$/;
31
+
32
+ export class Scratch {
33
+ private readonly dir: string;
34
+
35
+ constructor(
36
+ root: string,
37
+ /** A scratch file was written: `canvas:scratch/<name>`. */
38
+ private readonly onChange: (path: string) => void = () => {},
39
+ ) {
40
+ this.dir = join(root, ".canvas", "scratch");
41
+ }
42
+
43
+ /** A new file, under `name` or, if that is taken, `name-2`, `name-3`…; its path. */
44
+ create(name: string, text: string): string {
45
+ checkName(name);
46
+ checkSize(text);
47
+ const dot = name.lastIndexOf(".");
48
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
49
+ let free = name;
50
+ for (let n = 2; existsSync(join(this.dir, free)); n++) free = `${stem}-${n}${ext}`;
51
+ mkdirSync(this.dir, { recursive: true });
52
+ writeFileSync(join(this.dir, free), text);
53
+ const path = SCRATCH_PREFIX + free;
54
+ this.onChange(path);
55
+ return path;
56
+ }
57
+
58
+ /** Overwrite an existing scratch file. */
59
+ write(path: string, text: string): void {
60
+ const name = this.name(path);
61
+ if (!existsSync(join(this.dir, name)))
62
+ throw new Error(`${path} doesn't exist; give a name to create it`);
63
+ checkSize(text);
64
+ writeFileSync(join(this.dir, name), text);
65
+ this.onChange(path);
66
+ }
67
+
68
+ /** Undo a `create` whose board call failed. */
69
+ remove(path: string): void {
70
+ rmSync(join(this.dir, this.name(path)), { force: true });
71
+ this.onChange(path);
72
+ }
73
+
74
+ read(path: string): FileContent {
75
+ let name: string;
76
+ try {
77
+ name = this.name(path);
78
+ } catch (error) {
79
+ return { kind: "denied", reason: (error as Error).message };
80
+ }
81
+ const file = join(this.dir, name);
82
+ const stat = statSync(file, { throwIfNoEntry: false });
83
+ if (!stat?.isFile()) return { kind: "missing" };
84
+ if (stat.size > MAX_SCRATCH_BYTES) return { kind: "too-large", size: stat.size };
85
+ return { kind: "text", text: readFileSync(file, "utf8") };
86
+ }
87
+
88
+ /** Every scratch file's path, sorted. */
89
+ list(): string[] {
90
+ if (!existsSync(this.dir)) return [];
91
+ return readdirSync(this.dir, { withFileTypes: true })
92
+ .filter((entry) => entry.isFile() && NAME.test(entry.name))
93
+ .map((entry) => SCRATCH_PREFIX + entry.name)
94
+ .sort();
95
+ }
96
+
97
+ private name(path: string): string {
98
+ if (!isScratchPath(path)) throw new Error(`not a scratch file: ${path}`);
99
+ const name = path.slice(SCRATCH_PREFIX.length);
100
+ checkName(name);
101
+ return name;
102
+ }
103
+ }
104
+
105
+ function checkName(name: string) {
106
+ if (!NAME.test(name) || name.length > 120)
107
+ throw new Error(
108
+ `scratch file names are one plain segment of letters, digits, '.', '_' and '-' (got ${JSON.stringify(name)})`,
109
+ );
110
+ }
111
+
112
+ function checkSize(text: string) {
113
+ if (Buffer.byteLength(text) > MAX_SCRATCH_BYTES)
114
+ throw new Error("scratch files are at most 1 MiB");
115
+ }
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * `canvas serve`: the local half of canvas. One WebSocket endpoint that only
3
- * the host's browser may use (it presents the token from the printed link).
3
+ * the host's browser may use (it presents the token from the printed link),
4
+ * in one tab at a time: a newer tab takes over from an older one.
4
5
  * Nothing here knows about guests — the host's browser is the relay.
5
6
  */
6
7
 
7
8
  import type { ServerWebSocket } from "bun";
8
9
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
9
10
  import { join } from "node:path";
10
- import type { ClientToServer, ServerToClient } from "../shared/protocol";
11
+ import { HOST_REPLACED, type ClientToServer, type ServerToClient } from "../shared/protocol";
11
12
  import { AgentManager, detectAgents } from "./agents";
12
13
  import { BoardMcp } from "./board-mcp";
13
14
  import { Files } from "./files";
15
+ import { Scratch } from "./scratch";
16
+ import { canvasSkills } from "./skills";
14
17
  import { Store } from "./store";
15
18
  import { Terminals } from "./terminals";
16
19
 
@@ -19,33 +22,37 @@ export interface ServeOptions {
19
22
  readonly port: number;
20
23
  readonly hostname: string;
21
24
  readonly tls?: { readonly cert: string; readonly key: string };
25
+ /** The published version this runs as; none for a checkout. */
26
+ readonly version?: string;
22
27
  }
23
28
 
24
29
  export async function serve(options: ServeOptions) {
25
30
  const store = new Store(options.dir);
26
31
  excludeFromGit(options.dir);
27
32
  const room = await store.room();
28
- const clients = new Set<ServerWebSocket<unknown>>();
29
- const broadcast = (message: ServerToClient) => {
30
- const text = JSON.stringify(message);
31
- for (const ws of clients) ws.send(text);
32
- };
33
+ // The host tab: the board, and so every board tool call, lives there.
34
+ let host: ServerWebSocket<unknown> | null = null;
35
+ const broadcast = (message: ServerToClient) => host?.send(JSON.stringify(message));
33
36
 
37
+ // Scratch files are written by agents' board tools; `files` mirrors them.
38
+ const scratch = new Scratch(options.dir, (path) => files.scratchChanged(path));
34
39
  const files = new Files(
35
40
  options.dir,
36
41
  (path, file) => broadcast({ t: "file", path, file }),
37
42
  (paths) => broadcast({ t: "tree", paths }),
43
+ scratch,
38
44
  );
39
45
  process.on("exit", () => files.stop());
40
46
  // Board tools: an agent's call goes to one host browser (the board is there).
41
47
  const boardMcp = new BoardMcp({
42
48
  read: (path) => files.read(path),
49
+ scratch,
43
50
  relay: (call) => {
44
- const [ws] = clients;
45
- if (!ws) return false;
46
- ws.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
51
+ if (!host) return false;
52
+ host.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
47
53
  return true;
48
54
  },
55
+ skills: canvasSkills(),
49
56
  });
50
57
  process.on("exit", () => boardMcp.stop());
51
58
 
@@ -143,12 +150,17 @@ export async function serve(options: ServeOptions) {
143
150
  websocket: {
144
151
  maxPayloadLength: 64 * 1024 * 1024,
145
152
  open(ws) {
146
- clients.add(ws);
153
+ if (host) {
154
+ files.drop(host);
155
+ host.close(HOST_REPLACED, "opened in another tab");
156
+ }
157
+ host = ws;
147
158
  const board = store.board();
148
159
  const { token: _, ...secrets } = room;
149
160
  ws.send(
150
161
  JSON.stringify({
151
162
  t: "welcome",
163
+ version: options.version ?? null,
152
164
  room: secrets,
153
165
  cwd: options.dir,
154
166
  agents: agentDefinitions.map(({ kind, label }) => ({ kind, label })),
@@ -158,6 +170,8 @@ export async function serve(options: ServeOptions) {
158
170
  );
159
171
  },
160
172
  message(ws, data) {
173
+ // Still in flight from a tab that was just replaced.
174
+ if (ws !== host) return;
161
175
  try {
162
176
  handle(ws, JSON.parse(String(data)) as ClientToServer);
163
177
  } catch (error) {
@@ -165,7 +179,7 @@ export async function serve(options: ServeOptions) {
165
179
  }
166
180
  },
167
181
  close(ws) {
168
- clients.delete(ws);
182
+ if (ws === host) host = null;
169
183
  files.drop(ws);
170
184
  },
171
185
  },
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The skills canvas gives every agent session, shipped in `skills/` beside
3
+ * `src/` (finding 12). The folder is a Claude Code plugin (its manifest points
4
+ * `skills` at itself) and an opencode skills path at once; `agents.ts` hands
5
+ * it to each agent, which lists the skills beside the host's own.
6
+ *
7
+ * Claude drops skill descriptions from its listing once the host has many
8
+ * skills, so the board priming names them too (`boardInstructions`).
9
+ */
10
+
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+
14
+ export const SKILLS_DIR = resolve(import.meta.dir, "../../skills");
15
+
16
+ export interface Skill {
17
+ readonly name: string;
18
+ readonly description: string;
19
+ }
20
+
21
+ /** Every skill in `dir`, from its `SKILL.md` frontmatter. */
22
+ export function canvasSkills(dir: string = SKILLS_DIR): ReadonlyArray<Skill> {
23
+ return readdirSync(dir, { withFileTypes: true })
24
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
25
+ .map((entry) => {
26
+ const text = readFileSync(join(dir, entry.name, "SKILL.md"), "utf8");
27
+ const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? "";
28
+ const field = (key: string) => new RegExp(`^${key}: (.+)$`, "m").exec(frontmatter)?.[1];
29
+ return { name: field("name") ?? entry.name, description: field("description") ?? "" };
30
+ })
31
+ .sort((a, b) => a.name.localeCompare(b.name));
32
+ }
@@ -6,6 +6,17 @@
6
6
 
7
7
  const SCROLLBACK = 200_000;
8
8
 
9
+ /**
10
+ * The shell must lead a session of its own, with the PTY as its controlling
11
+ * terminal, as in any terminal emulator. `Bun.spawn` leaves it in the session
12
+ * of `canvas serve` (Bun 1.4): Ctrl-C reaches nothing, and whatever opens
13
+ * `/dev/tty` — fzf's Ctrl-R, sudo, ssh prompts — gets the terminal `canvas
14
+ * serve` runs in, where it is stopped as a background job. util-linux
15
+ * `setsid -c` does both; where it is missing (macOS) the shell runs as before.
16
+ */
17
+ const SETSID = Bun.which("setsid");
18
+ const SESSION = SETSID ? [SETSID, "-c"] : [];
19
+
9
20
  interface Term {
10
21
  terminal: Bun.Terminal;
11
22
  scrollback: string;
@@ -19,6 +30,7 @@ export class Terminals {
19
30
  private readonly dir: string,
20
31
  private readonly onData: (id: string, data: string) => void,
21
32
  private readonly onExit: (id: string, code: number | null) => void,
33
+ private readonly shell = process.env.SHELL ?? "bash",
22
34
  ) {}
23
35
 
24
36
  /** Open (or re-attach to) a terminal; returns the scrollback to replay. */
@@ -42,8 +54,7 @@ export class Terminals {
42
54
  }),
43
55
  };
44
56
  this.terms.set(id, term);
45
- const shell = process.env.SHELL ?? "bash";
46
- const proc = Bun.spawn([shell, "-l"], {
57
+ const proc = Bun.spawn([...SESSION, this.shell, "-l"], {
47
58
  cwd: this.dir,
48
59
  terminal: term.terminal,
49
60
  env: { ...process.env, TERM: "xterm-256color" },
@@ -11,9 +11,15 @@
11
11
 
12
12
  export const BOARD_TOOL_NAMES = [
13
13
  "view_board",
14
+ "view_frame",
14
15
  "open_frame",
15
16
  "update_frame",
16
17
  "close_frame",
18
+ "read_board_file",
19
+ "write_board_file",
20
+ "add_comment",
21
+ "edit_comment",
22
+ "delete_comment",
17
23
  ] as const;
18
24
  export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
19
25
 
@@ -39,7 +45,25 @@ interface FileTarget {
39
45
  readonly end_line?: number;
40
46
  }
41
47
 
42
- export interface OpenFrameArgs extends Placement, FileTarget {
48
+ /** A new scratch file to show (ADR 0005); `canvas serve` turns it into a `path`. */
49
+ interface ScratchContent {
50
+ readonly name?: string;
51
+ readonly content?: string;
52
+ }
53
+
54
+ /** One entry of a file frame's list (ADR 0005): a file at a display path. */
55
+ export interface FileListEntry extends ScratchContent {
56
+ readonly display: string;
57
+ readonly path?: string;
58
+ readonly start_line?: number;
59
+ readonly end_line?: number;
60
+ }
61
+
62
+ interface FileList {
63
+ readonly files?: ReadonlyArray<FileListEntry>;
64
+ }
65
+
66
+ export interface OpenFrameArgs extends Placement, FileTarget, ScratchContent, FileList {
43
67
  readonly type: FrameKind;
44
68
  readonly title?: string;
45
69
  readonly url?: string;
@@ -47,17 +71,55 @@ export interface OpenFrameArgs extends Placement, FileTarget {
47
71
  readonly draft?: string;
48
72
  }
49
73
 
50
- export interface UpdateFrameArgs extends Placement, FileTarget {
74
+ export interface UpdateFrameArgs extends Placement, FileTarget, ScratchContent, FileList {
51
75
  readonly frame: string;
52
76
  readonly title?: string;
53
77
  readonly url?: string;
54
78
  readonly view?: "preview" | "source";
79
+ /** A comment's id: show its file at its lines. */
80
+ readonly comment?: string;
81
+ }
82
+
83
+ export interface ViewFrameArgs {
84
+ readonly frame: string;
85
+ }
86
+
87
+ export interface AddCommentArgs {
88
+ readonly frame: string;
89
+ readonly path: string;
90
+ readonly start_line: number;
91
+ readonly end_line?: number;
92
+ readonly body: string;
93
+ /** The lines' text, which `canvas serve` adds (ADR 0006). */
94
+ readonly quote?: string;
95
+ }
96
+
97
+ export interface EditCommentArgs {
98
+ readonly frame: string;
99
+ readonly comment: string;
100
+ readonly body: string;
101
+ }
102
+
103
+ export interface DeleteCommentArgs {
104
+ readonly frame: string;
105
+ readonly comment: string;
55
106
  }
56
107
 
57
108
  export interface CloseFrameArgs {
58
109
  readonly frame: string;
59
110
  }
60
111
 
112
+ export interface ReadBoardFileArgs {
113
+ readonly path: string;
114
+ }
115
+
116
+ export interface WriteBoardFileArgs extends ScratchContent {
117
+ readonly path?: string;
118
+ }
119
+
120
+ /** Where scratch files live, as board paths name them. */
121
+ export const SCRATCH_PREFIX = "canvas:scratch/";
122
+
61
123
  const placement = {
62
124
  next_to: {
63
125
  type: "string",
@@ -74,12 +136,60 @@ const placement = {
74
136
  const fileTarget = {
75
137
  path: {
76
138
  type: "string",
77
- description: "File path relative to the project root, e.g. src/auth/session.ts.",
139
+ description:
140
+ "File path relative to the project root, e.g. src/auth/session.ts, or a scratch file: " +
141
+ "canvas:scratch/<name>.",
78
142
  },
79
143
  start_line: { type: "integer", minimum: 1, description: "First line to show and highlight." },
80
144
  end_line: { type: "integer", minimum: 1, description: "Last highlighted line (inclusive)." },
81
145
  };
82
146
 
147
+ const scratchContent = {
148
+ content: {
149
+ type: "string",
150
+ description:
151
+ "Instead of path: the text of a new scratch file to show — content for this board only, " +
152
+ "like a write-up or an HTML visualisation. canvas keeps it outside the project.",
153
+ },
154
+ name: {
155
+ type: "string",
156
+ description:
157
+ "With content: the scratch file's name, e.g. auth-overview.md; its extension decides how " +
158
+ "it shows. A taken name gets a suffix; the result says which path you got.",
159
+ },
160
+ };
161
+
162
+ const fileList = {
163
+ files: {
164
+ type: "array",
165
+ description:
166
+ "file: a list of files for the frame's tree, for people to click through at their pace — " +
167
+ "e.g. the files of one feature, with a write-up first. Each entry is a project file or a " +
168
+ "scratch file at a display path you choose: make folders, rename, order it (a folder sorts " +
169
+ "where its first entry is; no folders for a flat list). The frame shows path, else the " +
170
+ "first entry. update_frame replaces the list; [] removes it.",
171
+ items: {
172
+ type: "object",
173
+ properties: {
174
+ display: {
175
+ type: "string",
176
+ description:
177
+ 'Where it shows in the tree, e.g. "1 Overview.md" or "Auth/session.ts". Unique.',
178
+ },
179
+ path: { type: "string", description: "A project file or canvas:scratch/<name>." },
180
+ ...scratchContent,
181
+ start_line: {
182
+ type: "integer",
183
+ minimum: 1,
184
+ description: "Lines to open it at, shown as a badge.",
185
+ },
186
+ end_line: { type: "integer", minimum: 1 },
187
+ },
188
+ required: ["display"],
189
+ },
190
+ },
191
+ };
192
+
83
193
  export const BOARD_TOOLS: ReadonlyArray<{
84
194
  readonly name: BoardToolName;
85
195
  readonly description: string;
@@ -101,11 +211,25 @@ export const BOARD_TOOLS: ReadonlyArray<{
101
211
  },
102
212
  },
103
213
  },
214
+ {
215
+ name: "view_frame",
216
+ description:
217
+ "Look at one frame in full. For a file frame: what it shows, its list of files, and its " +
218
+ "comments — people's and agents' notes on lines of its files, with ids, authors and lines. " +
219
+ "view_board says which frames have comments.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: { frame: { type: "string", description: "Id of the frame." } },
223
+ required: ["frame"],
224
+ },
225
+ },
104
226
  {
105
227
  name: "open_frame",
106
228
  description:
107
229
  "Open a new frame on the board, in your own cluster unless placed next to another frame. " +
108
- "file: a project file (read-only, live), optionally at a line range which gets highlighted. " +
230
+ "file: a project file (read-only, live), optionally at a line range which gets highlighted, " +
231
+ "or a scratch file: content you pass, kept by canvas outside the project. A file frame can " +
232
+ "also carry a list of files (files): one frame to click through, instead of many. " +
109
233
  "browser: a URL, loaded by each viewer's own browser. terminal: an idle shell people can " +
110
234
  "type into. agent: another agent session; `draft` pre-fills its prompt, a person sends it.",
111
235
  inputSchema: {
@@ -113,6 +237,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
113
237
  properties: {
114
238
  type: { type: "string", enum: ["file", "browser", "terminal", "agent"] },
115
239
  ...fileTarget,
240
+ ...scratchContent,
241
+ ...fileList,
116
242
  url: { type: "string", description: "browser: an http(s) URL." },
117
243
  agent: { type: "string", description: "agent: which agent runs it (see view_board)." },
118
244
  draft: { type: "string", description: "agent: a prompt draft for people to send." },
@@ -125,7 +251,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
125
251
  {
126
252
  name: "update_frame",
127
253
  description:
128
- "Change a frame: point a file frame at another file or line range, switch a markdown " +
254
+ "Change a frame: point a file frame at another file, line range or new scratch file " +
255
+ "(content), give it a list of files or replace it (files), switch a markdown " +
129
256
  "or HTML file between preview and source, change a browser frame's URL, rename a frame, or move " +
130
257
  "it next to another frame.",
131
258
  inputSchema: {
@@ -133,7 +260,13 @@ export const BOARD_TOOLS: ReadonlyArray<{
133
260
  properties: {
134
261
  frame: { type: "string", description: "Id of the frame to change." },
135
262
  ...fileTarget,
263
+ ...scratchContent,
264
+ ...fileList,
136
265
  view: { type: "string", enum: ["preview", "source"] },
266
+ comment: {
267
+ type: "string",
268
+ description: "file: a comment's id (view_frame) — show its file at its lines.",
269
+ },
137
270
  url: { type: "string" },
138
271
  title: { type: "string" },
139
272
  ...placement,
@@ -151,15 +284,90 @@ export const BOARD_TOOLS: ReadonlyArray<{
151
284
  required: ["frame"],
152
285
  },
153
286
  },
287
+ {
288
+ name: "read_board_file",
289
+ description:
290
+ "Read a scratch file (canvas:scratch/<name>), e.g. one another agent wrote. view_board " +
291
+ "lists them.",
292
+ inputSchema: {
293
+ type: "object",
294
+ properties: { path: { type: "string", description: "canvas:scratch/<name>" } },
295
+ required: ["path"],
296
+ },
297
+ },
298
+ {
299
+ name: "write_board_file",
300
+ description:
301
+ "Write a scratch file without opening a frame. With name: a new one. With path: overwrite " +
302
+ "an existing one (any agent's); every frame showing it updates. Show a new one with " +
303
+ "open_frame (path).",
304
+ inputSchema: {
305
+ type: "object",
306
+ properties: {
307
+ path: { type: "string", description: "canvas:scratch/<name> to overwrite." },
308
+ name: scratchContent.name,
309
+ content: { type: "string", description: "The file's full text." },
310
+ },
311
+ required: ["content"],
312
+ },
313
+ },
314
+ {
315
+ name: "add_comment",
316
+ description:
317
+ "Comment on lines of a file, in a file frame: it shows below the lines for everyone, " +
318
+ "whatever file the frame shows. Markdown. The frame keeps it; people read it there.",
319
+ inputSchema: {
320
+ type: "object",
321
+ properties: {
322
+ frame: { type: "string", description: "Id of the file frame." },
323
+ path: {
324
+ type: "string",
325
+ description: "The file, relative to the project root, or canvas:scratch/<name>.",
326
+ },
327
+ start_line: { type: "integer", minimum: 1 },
328
+ end_line: { type: "integer", minimum: 1, description: "Default: start_line." },
329
+ body: { type: "string", description: "The comment, markdown." },
330
+ },
331
+ required: ["frame", "path", "start_line", "body"],
332
+ },
333
+ },
334
+ {
335
+ name: "edit_comment",
336
+ description: "Rewrite a comment's text. Only agents' comments: people's are theirs.",
337
+ inputSchema: {
338
+ type: "object",
339
+ properties: {
340
+ frame: { type: "string", description: "Id of the file frame." },
341
+ comment: { type: "string", description: "The comment's id (view_frame)." },
342
+ body: { type: "string", description: "The new text, markdown." },
343
+ },
344
+ required: ["frame", "comment", "body"],
345
+ },
346
+ },
347
+ {
348
+ name: "delete_comment",
349
+ description: "Remove a comment. Only agents' comments: people's are theirs.",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ frame: { type: "string", description: "Id of the file frame." },
354
+ comment: { type: "string", description: "The comment's id (view_frame)." },
355
+ },
356
+ required: ["frame", "comment"],
357
+ },
358
+ },
154
359
  ];
155
360
 
156
361
  /** The agent's standing context, sent as MCP server instructions. */
157
- export function boardInstructions(frameId: string): string {
362
+ export function boardInstructions(
363
+ frameId: string,
364
+ skills: ReadonlyArray<{ readonly name: string; readonly description: string }> = [],
365
+ ): string {
158
366
  return `You are running inside canvas: a shared, multiplayer board that people are looking at together, live. Its frames are coding-agent sessions, files of this project, browser previews and terminals. You are the agent in frame ${frameId}; people write prompts into it and read your replies there. Several people may prompt you.
159
367
 
160
368
  Frames that sit close together form a cluster: people keep related work together that way, and your own cluster is your workspace. Within a cluster frames sit in rows; frames in a row share a height.
161
369
 
162
- The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NAMES.join(", ")}. Use them when showing something on the board helps the people you work with — e.g. asked to show the files relevant to a topic, open them as file frames at the relevant lines instead of pasting code. Don't use them when a plain answer is enough.
370
+ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NAMES.join(", ")}. Use them when showing something on the board helps the people you work with — e.g. asked to show the files relevant to a topic, show them on the board (one file, or a list of them) at the relevant lines instead of pasting code. Don't use them when a plain answer is enough.
163
371
 
164
372
  - Look first (view_board). Reuse or retarget a frame (update_frame) rather than open a duplicate.
165
373
  - New frames go into your cluster by default; that is almost always right. Keep it to a handful per request.
@@ -168,5 +376,24 @@ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NA
168
376
  - A terminal frame is an idle shell for people; you cannot type into it. Run commands with your own tools.
169
377
  - An agent frame starts another agent. You can leave a draft prompt in it; only a person can send it.
170
378
  - A browser frame loads its URL in each viewer's own browser, so localhost means their machine, not this one: only the host sees a localhost URL, guests get a notice.
171
- - To show a page you wrote to everyone, open the HTML file as a file frame: it renders, scripts included, but relative links and assets (CSS, images, other scripts) don't load, so inline them.`;
379
+ - Scratch files hold what exists only to be shown on this board: a write-up, a diagram, an HTML visualisation. Pass the text as \`content\` (with a \`name\`) to open_frame or update_frame, or use write_board_file; canvas keeps them outside the project, as canvas:scratch/<name>. Don't write such files into the project for the board. Anything else — a temp file for your own work, a script, test data — goes wherever it would without canvas.
380
+ - Any agent may read (read_board_file) and overwrite (write_board_file) any scratch file; view_board lists them.
381
+ - To show several files for one topic, prefer one file frame with a list (files) over a frame per file: people click through it at their own pace. You decide the tree: display paths, folders, order, line ranges. A write-up (a scratch file) at the top and a visualisation at the bottom fit in the same list.
382
+ - People (and agents) comment on lines of files in a file frame. view_board says which frames have comments; view_frame lists them, with ids. You aren't told when someone comments: look when asked to, e.g. to address feedback. add_comment leaves one of yours; edit_comment and delete_comment change agents' comments, never people's. update_frame with a comment's id shows its file at its lines. A comment is "outdated" when its lines changed since; it shows what they were.
383
+ - Markdown and HTML files render. HTML runs its scripts, but relative assets (CSS, images, other scripts) don't load, so inline them. Links do work (see below).
384
+
385
+ Your replies, comments, markdown and HTML can link to places on the board with ordinary markdown links (or <a href> in HTML). A click takes that person there, opening a file frame if no frame shows the file:
386
+ - A file, from the project root: [session.ts](src/auth/session.ts); lines of it: [session.ts:42-60](src/auth/session.ts#L42-L60); a markdown heading: [Setup](docs/guide.md#setup); a scratch file: [overview](canvas:scratch/overview.md). \`path:line\` in inline code becomes a link too, if the file exists.
387
+ - A frame, by its id from view_board: [the review](#frame=<id>); a file or lines in it: #frame=<id>&path=src/a.ts&lines=10-20; a comment: #frame=<id>&comment=<comment id>.
388
+ - In a markdown or HTML file, relative paths are from that file. HTML pages can link each other: a link to another HTML file opens in the same frame, so several scratch pages make a small site.
389
+ Link to what you talk about when it's on the board or in the project; don't link to everything.${skillsSection(skills)}`;
390
+ }
391
+
392
+ /** canvas's own skills: the agent lists them, but may have dropped their descriptions. */
393
+ function skillsSection(
394
+ skills: ReadonlyArray<{ readonly name: string; readonly description: string }>,
395
+ ): string {
396
+ if (skills.length === 0) return "";
397
+ const list = skills.map((skill) => `- ${skill.name}: ${skill.description}`).join("\n");
398
+ return `\n\ncanvas also gives you skills for work on the board. When one fits the request, load it with your skill tool before you start:\n${list}`;
172
399
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Comments on the lines of a file (ADR 0006). A comment keeps the lines it
3
+ * was written on as text, its quote: files change under it — agents edit them
4
+ * live — and the quote is how it finds its lines again, or knows it can't.
5
+ * Pure, so `canvas serve` (which quotes agents' comments) and the browser
6
+ * agree on what a line is.
7
+ */
8
+
9
+ /** A file's lines as the frames number them: a final newline ends the last line. */
10
+ export function linesOf(text: string): string[] {
11
+ const lines = text.split("\n");
12
+ if (lines.length > 1 && lines.at(-1) === "") lines.pop();
13
+ return lines;
14
+ }
15
+
16
+ /** Lines `start`–`end` (1-based, inclusive) of `text`; null if the file hasn't got them. */
17
+ export function quoteOf(text: string, start: number, end: number): string | null {
18
+ const lines = linesOf(text);
19
+ if (start < 1 || end < start || end > lines.length) return null;
20
+ return lines.slice(start - 1, end).join("\n");
21
+ }
22
+
23
+ /**
24
+ * Where a quote is now: its old place if the lines there still read the same,
25
+ * else the nearest place they do. null if they're gone — the comment is
26
+ * outdated. A quote of blank lines only stays where it was: blank lines are
27
+ * everywhere, finding "them" elsewhere would be a guess.
28
+ */
29
+ export function relocate(
30
+ text: string,
31
+ quote: string,
32
+ start: number,
33
+ ): { start: number; end: number } | null {
34
+ const lines = linesOf(text);
35
+ const wanted = quote.split("\n");
36
+ const at = (s: number) => wanted.every((line, i) => lines[s - 1 + i] === line);
37
+ const found = (s: number) => ({ start: s, end: s + wanted.length - 1 });
38
+ const last = lines.length - wanted.length + 1;
39
+ if (start >= 1 && start <= last && at(start)) return found(start);
40
+ if (!quote.trim()) return null;
41
+ for (let distance = 1; distance < Math.max(start, last); distance++) {
42
+ if (start - distance >= 1 && start - distance <= last && at(start - distance))
43
+ return found(start - distance);
44
+ if (start + distance >= 1 && start + distance <= last && at(start + distance))
45
+ return found(start + distance);
46
+ }
47
+ return null;
48
+ }
@@ -157,6 +157,13 @@ export interface RoomSecrets {
157
157
  readonly hostPrivateKey: JsonWebKey;
158
158
  }
159
159
 
160
+ /**
161
+ * Close code of a host browser's WebSocket when another one connected: one
162
+ * host tab at a time, and the replaced one must not reconnect by itself, or
163
+ * two tabs would take the connection from each other forever.
164
+ */
165
+ export const HOST_REPLACED = 4001;
166
+
160
167
  export type ClientToServer =
161
168
  | { readonly t: "board-save"; readonly state: string }
162
169
  | { readonly t: "agent-create"; readonly id: string; readonly agent: AgentKind }
@@ -204,6 +211,8 @@ export type ClientToServer =
204
211
  export type ServerToClient =
205
212
  | {
206
213
  readonly t: "welcome";
214
+ /** The version `canvas serve` runs as; null for a checkout. */
215
+ readonly version: string | null;
207
216
  readonly room: RoomSecrets;
208
217
  readonly cwd: string;
209
218
  readonly agents: ReadonlyArray<AgentInfo>;
@@ -251,6 +260,8 @@ export interface RoomState {
251
260
  readonly access: GuestAccess;
252
261
  readonly cwd: string;
253
262
  readonly agents: ReadonlyArray<AgentInfo>;
263
+ /** `canvas serve`'s version, null for a checkout; absent from hosts older than it. */
264
+ readonly version?: string | null;
254
265
  }
255
266
 
256
267
  /** Requests a guest sends the host; the host answers `{ok}` or `{ok:false, error}`. */