@frebreco/canvas 0.4.0 → 0.5.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # canvas
2
2
 
3
- **A multiplayer canvas whose frames are coding-agent sessions, files, browser previews and
4
- terminals.** The agents run on one person's machine; everyone else joins peer to peer from the
3
+ **A multiplayer canvas whose frames are coding-agent sessions, files, browser previews,
4
+ terminals and drawings.** The agents run on one person's machine; everyone else joins peer to peer from the
5
5
  browser.
6
6
 
7
7
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frebreco/canvas",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-next.2",
4
4
  "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -94,8 +94,8 @@ export function detectAgents(): ReadonlyArray<AgentDefinition> {
94
94
  OPENCODE_CONFIG_CONTENT: JSON.stringify({
95
95
  // The starting model; people pick another in the frame.
96
96
  model: opencodeModel,
97
- // Shell commands ask, so a person approves anything the agent runs.
98
- permission: { bash: "ask" },
97
+ // No permission policy: opencode's defaults (ask outside the
98
+ // project) and the host's own opencode config decide.
99
99
  // canvas's own skills; the host's stay available beside them.
100
100
  skills: { paths: [SKILLS_DIR] },
101
101
  }),
@@ -17,6 +17,9 @@
17
17
  * shared set: the browser keeps it to find the lines again as the file
18
18
  * changes.
19
19
  *
20
+ * A drawing (ADR 0009) comes back as an image too, which goes to the agent
21
+ * as MCP image content.
22
+ *
20
23
  * Scratch files (ADR 0005) never reach the browser as content: a call's
21
24
  * `content` becomes a file in `.canvas/scratch/` here, and the call goes on
22
25
  * with its path. Reading and writing them needs no board, so those tools are
@@ -27,7 +30,7 @@ import { randomBytes } from "node:crypto";
27
30
  import type { McpServer } from "@agentclientprotocol/sdk";
28
31
  import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
29
32
  import { linesOf, quoteOf } from "../shared/comments";
30
- import type { FileContent } from "../shared/protocol";
33
+ import type { FileContent, ToolImage } from "../shared/protocol";
31
34
  import type { Scratch } from "./scratch";
32
35
  import type { Skill } from "./skills";
33
36
 
@@ -56,7 +59,8 @@ interface Rpc {
56
59
  readonly params?: Record<string, unknown>;
57
60
  }
58
61
 
59
- type ToolResult = { content: Array<{ type: "text"; text: string }>; isError?: true };
62
+ type Content = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
63
+ type ToolResult = { content: Content[]; isError?: true };
60
64
 
61
65
  const LATEST_PROTOCOL = "2025-06-18";
62
66
 
@@ -89,12 +93,12 @@ export class BoardMcp {
89
93
  }
90
94
 
91
95
  /** The host's browser answered a call. */
92
- result(callId: string, ok: boolean, text: string): void {
96
+ result(callId: string, ok: boolean, text: string, images: ReadonlyArray<ToolImage> = []): void {
93
97
  const waiting = this.pending.get(callId);
94
98
  if (!waiting) return;
95
99
  this.pending.delete(callId);
96
100
  clearTimeout(waiting.timer);
97
- waiting.resolve(ok ? done(text) : failed(text));
101
+ waiting.resolve(ok ? done(text, images) : failed(text));
98
102
  }
99
103
 
100
104
  stop(): void {
@@ -227,7 +231,10 @@ export class BoardMcp {
227
231
  undo();
228
232
  return answer;
229
233
  }
230
- return notes.length ? done([answer.content[0]!.text, ...notes].join("\n")) : answer;
234
+ if (!notes.length) return answer;
235
+ const [first, ...rest] = answer.content;
236
+ const text = first?.type === "text" ? first.text : "";
237
+ return { content: [{ type: "text", text: [text, ...notes].join("\n") }, ...rest] };
231
238
  }
232
239
 
233
240
  /** A named file must be in the shared set (or a scratch file), and have the lines asked for. */
@@ -318,5 +325,10 @@ export class BoardMcp {
318
325
  }
319
326
  }
320
327
 
321
- const done = (text: string): ToolResult => ({ content: [{ type: "text", text }] });
328
+ const done = (text: string, images: ReadonlyArray<ToolImage> = []): ToolResult => ({
329
+ content: [
330
+ { type: "text", text },
331
+ ...images.map((image) => ({ type: "image" as const, ...image })),
332
+ ],
333
+ });
322
334
  const failed = (text: string): ToolResult => ({ content: [{ type: "text", text }], isError: true });
@@ -154,7 +154,7 @@ export async function serve(options: ServeOptions) {
154
154
  case "term-resize":
155
155
  return terminals.resize(message.id, message.cols, message.rows);
156
156
  case "board-result":
157
- return boardMcp.result(message.callId, message.ok, message.text);
157
+ return boardMcp.result(message.callId, message.ok, message.text, message.images);
158
158
  }
159
159
  };
160
160
 
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * - a text chunk repeats the whole message so far in `content`; the thread
7
7
  * is folded from `delta` alone, so the repeat goes;
8
- * - images a tool returns (a screenshot the agent read) stay as a
9
- * placeholder with their size;
8
+ * - images a tool returns (a screenshot the agent read, a drawing) stay as
9
+ * a placeholder with their size, as image content or as a data URL
10
+ * (opencode's attachments);
10
11
  * - tool output and arguments are cut to their start and end.
11
12
  */
12
13
 
@@ -48,7 +49,7 @@ function cut(text: string): string {
48
49
 
49
50
  /** Tool output is a string, JSON when it has more than text: drop image data from it. */
50
51
  function withoutImages(content: string): string {
51
- if (!content.includes('"image"')) return content;
52
+ if (!content.includes('"image"') && !content.includes("data:image/")) return content;
52
53
  let parsed: unknown;
53
54
  try {
54
55
  parsed = JSON.parse(content);
@@ -64,6 +65,10 @@ function withoutImages(content: string): string {
64
65
  const size = JSON.stringify(value).length;
65
66
  return { type: "image", omitted: `${Math.round(size / 1024)} KB` };
66
67
  }
68
+ if (typeof value.url === "string" && value.url.startsWith("data:image/")) {
69
+ found = true;
70
+ return { ...value, url: `data:… (${Math.round(value.url.length / 1024)} KB omitted)` };
71
+ }
67
72
  return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walk(v)]));
68
73
  };
69
74
  const next = walk(parsed);
@@ -20,13 +20,14 @@ export const BOARD_TOOL_NAMES = [
20
20
  "add_comment",
21
21
  "edit_comment",
22
22
  "delete_comment",
23
+ "draw",
23
24
  ] as const;
24
25
  export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
25
26
 
26
27
  /** The MCP server's name, as agents prefix its tools (`mcp__canvas__…`, `canvas_…`). */
27
28
  export const BOARD_SERVER_NAME = "canvas";
28
29
 
29
- export type FrameKind = "file" | "browser" | "terminal" | "agent";
30
+ export type FrameKind = "file" | "browser" | "terminal" | "agent" | "drawing";
30
31
  export type PlaceSide = "left" | "right" | "above" | "below";
31
32
 
32
33
  export interface ViewBoardArgs {
@@ -63,7 +64,15 @@ interface FileList {
63
64
  readonly files?: ReadonlyArray<FileListEntry>;
64
65
  }
65
66
 
66
- export interface OpenFrameArgs extends Placement, FileTarget, ScratchContent, FileList {
67
+ /** What to draw (ADR 0009): the host's browser turns it into Excalidraw elements. */
68
+ interface DrawContent {
69
+ /** Excalidraw element skeletons (`web/lib/drawing.ts` checks them). */
70
+ readonly elements?: ReadonlyArray<Record<string, unknown>>;
71
+ readonly mermaid?: string;
72
+ }
73
+
74
+ export interface OpenFrameArgs
75
+ extends Placement, FileTarget, ScratchContent, FileList, DrawContent {
67
76
  readonly type: FrameKind;
68
77
  readonly title?: string;
69
78
  readonly url?: string;
@@ -105,6 +114,13 @@ export interface DeleteCommentArgs {
105
114
  readonly comment: string;
106
115
  }
107
116
 
117
+ export interface DrawArgs extends DrawContent {
118
+ readonly frame: string;
119
+ /** Ids of elements to remove. */
120
+ readonly delete?: ReadonlyArray<string>;
121
+ readonly clear?: boolean;
122
+ }
123
+
108
124
  export interface CloseFrameArgs {
109
125
  readonly frame: string;
110
126
  }
@@ -190,6 +206,68 @@ const fileList = {
190
206
  },
191
207
  };
192
208
 
209
+ const idRef = { type: "object", properties: { id: { type: "string" } }, required: ["id"] };
210
+
211
+ const drawContent = {
212
+ elements: {
213
+ type: "array",
214
+ description:
215
+ "Excalidraw elements to add, in the drawing's coordinates (view_frame gives the extent of " +
216
+ "what is there; y grows downwards). Shapes: rectangle, ellipse, diamond with x, y, width, " +
217
+ "height and a label. Text: x, y, text. Arrows and lines: x, y and points relative to them; " +
218
+ "an arrow between two shapes needs only start and end — the ids of shapes of this call or " +
219
+ "already drawn — and is routed between them. Give an id of your own to connect to a shape " +
220
+ "of the same call; an existing element's id replaces that element.",
221
+ items: {
222
+ type: "object",
223
+ properties: {
224
+ type: {
225
+ type: "string",
226
+ enum: ["rectangle", "ellipse", "diamond", "text", "arrow", "line"],
227
+ },
228
+ id: { type: "string" },
229
+ x: { type: "number" },
230
+ y: { type: "number" },
231
+ width: { type: "number" },
232
+ height: { type: "number" },
233
+ label: {
234
+ type: "object",
235
+ properties: { text: { type: "string" } },
236
+ required: ["text"],
237
+ description: "A shape's or an arrow's text.",
238
+ },
239
+ text: { type: "string", description: "text: its text." },
240
+ fontSize: { type: "number", description: "text: default 20." },
241
+ points: {
242
+ type: "array",
243
+ items: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 },
244
+ description: "arrow, line: [[0,0],[dx,dy],…], relative to x, y.",
245
+ },
246
+ start: { ...idRef, description: "arrow: the shape it starts at." },
247
+ end: { ...idRef, description: "arrow: the shape it points to." },
248
+ strokeColor: { type: "string", description: "Hex, e.g. #e03131." },
249
+ backgroundColor: { type: "string", description: "Hex; default transparent." },
250
+ fillStyle: { type: "string", enum: ["solid", "hachure", "cross-hatch"] },
251
+ strokeWidth: { type: "number", description: "1, 2 (default) or 4." },
252
+ strokeStyle: { type: "string", enum: ["solid", "dashed", "dotted"] },
253
+ roundness: {
254
+ type: "object",
255
+ properties: { type: { type: "number" } },
256
+ description: "{type: 3}: rounded corners.",
257
+ },
258
+ link: { type: "string", description: "A URL, or a board link." },
259
+ },
260
+ required: ["type"],
261
+ },
262
+ },
263
+ mermaid: {
264
+ type: "string",
265
+ description:
266
+ "A mermaid flowchart, sequence or class diagram, turned into shapes and placed below what " +
267
+ "is drawn. The easiest way to draw anything with more than a few boxes.",
268
+ },
269
+ };
270
+
193
271
  export const BOARD_TOOLS: ReadonlyArray<{
194
272
  readonly name: BoardToolName;
195
273
  readonly description: string;
@@ -216,7 +294,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
216
294
  description:
217
295
  "Look at one frame in full. For a file frame: what it shows, its list of files, and its " +
218
296
  "comments — people's and agents' notes on lines of its files, with ids, authors and lines. " +
219
- "view_board says which frames have comments.",
297
+ "view_board says which frames have comments. For a drawing: an image of it, and its " +
298
+ "elements with ids, labels, places and what arrows connect.",
220
299
  inputSchema: {
221
300
  type: "object",
222
301
  properties: { frame: { type: "string", description: "Id of the frame." } },
@@ -231,17 +310,20 @@ export const BOARD_TOOLS: ReadonlyArray<{
231
310
  "or a scratch file: content you pass, kept by canvas outside the project. A file frame can " +
232
311
  "also carry a list of files (files): one frame to click through, instead of many. " +
233
312
  "browser: a URL, loaded by each viewer's own browser. terminal: an idle shell people can " +
234
- "type into. agent: another agent session; `draft` pre-fills its prompt, a person sends it.",
313
+ "type into. agent: another agent session; `draft` pre-fills its prompt, a person sends it. " +
314
+ "drawing: a whiteboard people sketch on together, optionally with elements or mermaid " +
315
+ "to start it (see draw).",
235
316
  inputSchema: {
236
317
  type: "object",
237
318
  properties: {
238
- type: { type: "string", enum: ["file", "browser", "terminal", "agent"] },
319
+ type: { type: "string", enum: ["file", "browser", "terminal", "agent", "drawing"] },
239
320
  ...fileTarget,
240
321
  ...scratchContent,
241
322
  ...fileList,
242
323
  url: { type: "string", description: "browser: an http(s) URL." },
243
324
  agent: { type: "string", description: "agent: which agent runs it (see view_board)." },
244
325
  draft: { type: "string", description: "agent: a prompt draft for people to send." },
326
+ ...drawContent,
245
327
  title: { type: "string", description: "Frame title. Default: from what it shows." },
246
328
  ...placement,
247
329
  },
@@ -356,6 +438,27 @@ export const BOARD_TOOLS: ReadonlyArray<{
356
438
  required: ["frame", "comment"],
357
439
  },
358
440
  },
441
+ {
442
+ name: "draw",
443
+ description:
444
+ "Change a drawing frame: add elements, a mermaid diagram, replace elements (an element with " +
445
+ "an existing id), remove elements (delete) or everything (clear). Look at it first " +
446
+ "(view_frame): people may have drawn there. Everyone sees the change live.",
447
+ inputSchema: {
448
+ type: "object",
449
+ properties: {
450
+ frame: { type: "string", description: "Id of the drawing frame." },
451
+ ...drawContent,
452
+ delete: {
453
+ type: "array",
454
+ items: { type: "string" },
455
+ description: "Ids of elements to remove (view_frame lists them); labels go with shapes.",
456
+ },
457
+ clear: { type: "boolean", description: "Remove everything first." },
458
+ },
459
+ required: ["frame"],
460
+ },
461
+ },
359
462
  ];
360
463
 
361
464
  /** The agent's standing context, sent as MCP server instructions. */
@@ -363,7 +466,7 @@ export function boardInstructions(
363
466
  frameId: string,
364
467
  skills: ReadonlyArray<{ readonly name: string; readonly description: string }> = [],
365
468
  ): string {
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.
469
+ 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, terminals and drawings. You are the agent in frame ${frameId}; people write prompts into it and read your replies there. Several people may prompt you.
367
470
 
368
471
  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.
369
472
 
@@ -380,6 +483,7 @@ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NA
380
483
  - Any agent may read (read_board_file) and overwrite (write_board_file) any scratch file; view_board lists them.
381
484
  - 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
485
  - 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.
486
+ - A drawing frame is an Excalidraw whiteboard people sketch on together. view_frame shows it to you as an image, with its elements as text; draw adds shapes, text, arrows or a mermaid diagram, changes or removes elements. Use one to sketch an architecture or a flow with people, or when asked to draw; a mermaid flowchart is quickest for more than a few boxes. People's sketches in it are theirs: add next to them, don't redraw them unless asked.
383
487
  - 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
488
 
385
489
  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:
@@ -213,14 +213,22 @@ export type ClientToServer =
213
213
  readonly cols: number;
214
214
  readonly rows: number;
215
215
  }
216
- /** The answer to a `board-call`: the tool's text, for the agent. */
216
+ /** The answer to a `board-call`: the tool's text, for the agent, and any images. */
217
217
  | {
218
218
  readonly t: "board-result";
219
219
  readonly callId: string;
220
220
  readonly ok: boolean;
221
221
  readonly text: string;
222
+ readonly images?: ReadonlyArray<ToolImage>;
222
223
  };
223
224
 
225
+ /** An image a board tool shows the agent, e.g. a drawing (ADR 0009). */
226
+ export interface ToolImage {
227
+ /** base64, no data: prefix. */
228
+ readonly data: string;
229
+ readonly mimeType: "image/png";
230
+ }
231
+
224
232
  export type ServerToClient =
225
233
  | {
226
234
  readonly t: "welcome";