@frebreco/canvas 0.3.0-next.7 → 0.3.0-next.9

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.7",
3
+ "version": "0.3.0-next.9",
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.
@@ -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
  },
@@ -13,6 +13,10 @@
13
13
  * agent hears why a file can't be shown. It listens on loopback only: agents
14
14
  * run on this machine.
15
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
+ *
16
20
  * Scratch files (ADR 0005) never reach the browser as content: a call's
17
21
  * `content` becomes a file in `.canvas/scratch/` here, and the call goes on
18
22
  * with its path. Reading and writing them needs no board, so those tools are
@@ -22,8 +26,10 @@
22
26
  import { randomBytes } from "node:crypto";
23
27
  import type { McpServer } from "@agentclientprotocol/sdk";
24
28
  import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
29
+ import { linesOf, quoteOf } from "../shared/comments";
25
30
  import type { FileContent } from "../shared/protocol";
26
31
  import type { Scratch } from "./scratch";
32
+ import type { Skill } from "./skills";
27
33
 
28
34
  export interface BoardCall {
29
35
  readonly callId: string;
@@ -38,6 +44,8 @@ export interface BoardMcpOptions {
38
44
  readonly scratch: Pick<Scratch, "create" | "write" | "read" | "list" | "remove">;
39
45
  /** Send a call to the host's browser; false if none is connected. */
40
46
  readonly relay: (call: BoardCall) => boolean;
47
+ /** canvas's skills, named in the priming. */
48
+ readonly skills?: ReadonlyArray<Skill>;
41
49
  readonly timeoutMs?: number;
42
50
  }
43
51
 
@@ -133,7 +141,7 @@ export class BoardMcp {
133
141
  : LATEST_PROTOCOL,
134
142
  capabilities: { tools: { listChanged: false } },
135
143
  serverInfo: { name: BOARD_SERVER_NAME, version: "0.0.0" },
136
- instructions: boardInstructions(sessionId),
144
+ instructions: boardInstructions(sessionId, this.options.skills),
137
145
  });
138
146
  case "ping":
139
147
  return reply({});
@@ -179,6 +187,7 @@ export class BoardMcp {
179
187
  delete args.content;
180
188
  delete args.name;
181
189
  } else this.checkPath(args, notes);
190
+ if (tool === "add_comment") this.quote(args);
182
191
  if (Array.isArray(args.files))
183
192
  args.files = args.files.map((value: unknown) => {
184
193
  if (typeof value !== "object" || value === null)
@@ -235,6 +244,26 @@ export class BoardMcp {
235
244
  }
236
245
  }
237
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
+
238
267
  private relay(sessionId: string, tool: string, args: Record<string, unknown>) {
239
268
  const callId = crypto.randomUUID();
240
269
  const result = new Promise<ToolResult>((resolve) => {
@@ -13,6 +13,7 @@ import { AgentManager, detectAgents } from "./agents";
13
13
  import { BoardMcp } from "./board-mcp";
14
14
  import { Files } from "./files";
15
15
  import { Scratch } from "./scratch";
16
+ import { canvasSkills } from "./skills";
16
17
  import { Store } from "./store";
17
18
  import { Terminals } from "./terminals";
18
19
 
@@ -51,6 +52,7 @@ export async function serve(options: ServeOptions) {
51
52
  host.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
52
53
  return true;
53
54
  },
55
+ skills: canvasSkills(),
54
56
  });
55
57
  process.on("exit", () => boardMcp.stop());
56
58
 
@@ -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
+ }
@@ -11,11 +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",
17
18
  "read_board_file",
18
19
  "write_board_file",
20
+ "add_comment",
21
+ "edit_comment",
22
+ "delete_comment",
19
23
  ] as const;
20
24
  export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
21
25
 
@@ -72,6 +76,33 @@ export interface UpdateFrameArgs extends Placement, FileTarget, ScratchContent,
72
76
  readonly title?: string;
73
77
  readonly url?: string;
74
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;
75
106
  }
76
107
 
77
108
  export interface CloseFrameArgs {
@@ -180,6 +211,18 @@ export const BOARD_TOOLS: ReadonlyArray<{
180
211
  },
181
212
  },
182
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
+ },
183
226
  {
184
227
  name: "open_frame",
185
228
  description:
@@ -220,6 +263,10 @@ export const BOARD_TOOLS: ReadonlyArray<{
220
263
  ...scratchContent,
221
264
  ...fileList,
222
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
+ },
223
270
  url: { type: "string" },
224
271
  title: { type: "string" },
225
272
  ...placement,
@@ -264,10 +311,58 @@ export const BOARD_TOOLS: ReadonlyArray<{
264
311
  required: ["content"],
265
312
  },
266
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
+ },
267
359
  ];
268
360
 
269
361
  /** The agent's standing context, sent as MCP server instructions. */
270
- export function boardInstructions(frameId: string): string {
362
+ export function boardInstructions(
363
+ frameId: string,
364
+ skills: ReadonlyArray<{ readonly name: string; readonly description: string }> = [],
365
+ ): string {
271
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.
272
367
 
273
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.
@@ -284,5 +379,15 @@ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NA
284
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.
285
380
  - Any agent may read (read_board_file) and overwrite (write_board_file) any scratch file; view_board lists them.
286
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.
287
- - Markdown and HTML files render. HTML runs its scripts, but relative links and assets (CSS, images, other scripts) don't load, so inline them.`;
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 links and assets (CSS, images, other scripts) don't load, so inline them.${skillsSection(skills)}`;
384
+ }
385
+
386
+ /** canvas's own skills: the agent lists them, but may have dropped their descriptions. */
387
+ function skillsSection(
388
+ skills: ReadonlyArray<{ readonly name: string; readonly description: string }>,
389
+ ): string {
390
+ if (skills.length === 0) return "";
391
+ const list = skills.map((skill) => `- ${skill.name}: ${skill.description}`).join("\n");
392
+ 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}`;
288
393
  }
@@ -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
+ }