agentic-canvas 0.9.0 → 0.11.0

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
@@ -7,7 +7,7 @@
7
7
 
8
8
  A shared whiteboard for diagrams, drawn by people and agents.
9
9
 
10
- > Status: 0.9.0. The board model, the agent operations, the exports, the CLI, the
10
+ > Status: 0.11.0. The board model, the agent operations, the exports, the CLI, the
11
11
  > MCP server, a small Worker API, and live co-editing work. The CLI, the MCP server,
12
12
  > and the SDK can join a live room. A remote client calls the Worker API. A small web UI, served by the Worker, is new in 0.4.0.
13
13
  > The package exports `agentic-canvas/ui`, a board view you can mount in your own app, new in 0.5.0.
@@ -38,7 +38,7 @@ npx agentic-canvas export demo --out demo.svg
38
38
  npx agentic-canvas export demo --format png --out demo.png
39
39
  ```
40
40
 
41
- Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`, `delete` (a whole board). Run `npx agentic-canvas --help`.
41
+ Other commands: `list`, `add`, `connect`, `update`, `move`, `rm`, `delete` (a whole board), and `access`, `grant`, `revoke`, `invite` (sharing, server only). Run `npx agentic-canvas --help`.
42
42
  `repair` is for the owner only. It needs `CANVAS_ACTOR=human:<id>`.
43
43
  Boards live in `./.agentic-canvas` (or `--dir`, or `CANVAS_DIR`). Every write
44
44
  needs an actor in `CANVAS_ACTOR`, as `<kind>:<id>`. The operator sets it. A write
@@ -52,8 +52,7 @@ CANVAS_ACTOR=agent:helper npx agentic-canvas --dir ./boards mcp
52
52
  ```
53
53
 
54
54
  The server speaks MCP on stdio. It has one tool for each CLI command: `create_board`,
55
- `delete_board`, `list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, and
56
- `export_png`. `repair` is not a tool. Only a human operator runs it, from the CLI. The server will not start unless `CANVAS_ACTOR` is set to a valid
55
+ `delete_board`, `list_boards`, `read_board`, `add_shape`, `connect`, `update_element`, `move`, `remove`, `draw_flow`, `export_svg`, `export_png`, and the sharing tools `list_access`, `grant_access`, `revoke_access`, and `create_invite`. `repair` is not a tool. Only a human operator runs it, from the CLI. The server will not start unless `CANVAS_ACTOR` is set to a valid
57
56
  `<kind>:<id>`. That actor stamps every write. No tool takes an actor, author, or kind, so a
58
57
  caller cannot choose who it writes as. An error comes back as a tool error with a code.
59
58
 
@@ -184,6 +183,33 @@ HTTP routes, under `/api/v1/canvas`. Each needs a bearer token, except the link
184
183
  | `DELETE /boards/:id/links/:link` | Owner. Revoke a link of this board. |
185
184
  | `GET /shared/:id` and `GET /shared/:id/svg` | No bearer token needed. Send the link token in `x-link-token`. Read only. |
186
185
 
186
+ ### Share from the client, the CLI, and MCP
187
+
188
+ Sharing lives on the server, so these need a Worker and a token with `canvas:admin` that owns the board.
189
+ A local store has no owners: the CLI commands exit 2 there, and the MCP tools fail with `UNAVAILABLE`.
190
+
191
+ ```ts
192
+ const canvas = createClient({ baseUrl, token });
193
+ await canvas.grant("demo", { subjectKind: "agent", subjectId: "helper", role: "editor" });
194
+ await canvas.invite("demo", { role: "viewer", email: "ada@example.com", expiresInMs: 86_400_000 });
195
+ const { grants, invites } = await canvas.access("demo"); // an invite shows no email
196
+ await canvas.revoke("demo", { subjectKind: "agent", subjectId: "helper" });
197
+ ```
198
+
199
+ ```sh
200
+ export CANVAS_SERVER=https://canvas.example.com CANVAS_TOKEN=...
201
+ npx agentic-canvas access demo
202
+ npx agentic-canvas grant demo agent:helper --role editor
203
+ npx agentic-canvas invite demo --role viewer --handle helper --expires-in-ms 86400000
204
+ npx agentic-canvas revoke demo agent:helper
205
+ ```
206
+
207
+ With `CANVAS_SERVER` set, `npx agentic-canvas mcp` serves `list_access`, `grant_access`, `revoke_access`, and `create_invite` as well.
208
+ Each prints or returns the server's JSON. A refusal has the server's code, such as `NOT_FOUND`, `FORBIDDEN`, or `INVALID`.
209
+ Share links and invite acceptance stay on the HTTP API.
210
+
211
+ A live MCP server with an admin token can share boards: an agent that holds it can grant `owner` and revoke grants. Give an agent a token with `canvas:write` only, unless it must share.
212
+
187
213
  The static dev fallback holds no board roles. Its tokens reach every board and cannot share.
188
214
 
189
215
  ## UI
@@ -257,6 +283,39 @@ server still checks every write against the token. The host page owns the theme.
257
283
  For a React app, `BoardEditor` is the same component without a root. Wrap it in the providers of
258
284
  `agentic-design/react`.
259
285
 
286
+ ### Use your own Yjs document
287
+
288
+ An app that keeps the board in its own Yjs room gives it as `host`. The UI then reads and writes that document as a
289
+ board (its `elements` and `meta` maps, the shape this package stores) and redraws as soon as the document changes.
290
+ It makes no HTTP call, does not poll, and opens no live room. `client` is not needed. The default is still client
291
+ mode: without `host`, the UI works as described above.
292
+
293
+ ```ts
294
+ const board = mount(element, {
295
+ boardId: "b1",
296
+ principal: { kind: "human", name: "Ada", id: "ada", role: "editor" }, // id is stamped on what Ada writes; the default is name
297
+ host: {
298
+ doc, // a Y.Doc
299
+ presence: sync, // optional: a SyncClient of agentic-realtime, or { peers, onPeers }
300
+ status: () => status, // optional: "connecting" | "connected" | "disconnected"
301
+ onStatus: (listener) => stop, // optional: tell the UI when the status changes
302
+ },
303
+ });
304
+ ```
305
+
306
+ - Use the same `yjs` copy as the UI. A document from another copy is refused with a `TypeError`.
307
+ - Each element the UI creates or edits is stamped with the principal's `id` and `kind`. A delete has no stamp. The `id` follows the identity rule of the
308
+ SDK (letters, digits, `.`, `_`, `-`). An agent kind marks its elements as machine-made. A refused write shows a message
309
+ and leaves the document alone. The server does not check these writes: your app and its room decide who may write.
310
+ - In host mode `boardId` only keys the view. The UI shows the one board that the document holds (its `elements` and `meta` maps), so keep one board in each document.
311
+ - A document pushed past the element cap (10,000) can no longer be read. If that happens after the first draw, the UI keeps the last board it drew. At mount it shows the error page with Retry. Your app should stop a peer from writing that far.
312
+ - `client` or `room` together with `host` throws a `TypeError`, so a board never half-writes to a server. (`BoardEditor` shows an alert for these, as it does for a bad principal.) Give presence in `host.presence`.
313
+ - The principal's `id` (default: `name`) must pass the identity rule, so a display name with a space needs an explicit `id`. A bad one throws a `TypeError` at mount.
314
+ - The UI redraws only for changes in the document's `elements` and `meta` maps. Other data in the same document is left alone.
315
+ - `status` and `onStatus` are read when the UI opens `doc`. A new function on `update()` does not open it again. `onStatus` alone starts as not live, until it reports "connected". With neither, the board shows as live. Give `onStatus` at mount to follow later changes. It is not picked up by a later `update()` for the same `doc`.
316
+ - With `presence`, the UI shows the peers. It never sends a cursor, and never closes your room or your document.
317
+ - On `unmount` the UI removes its observers from your document and leaves it as it is.
318
+
260
319
  Develop and test the UI:
261
320
 
262
321
  ```sh
@@ -265,7 +324,7 @@ npx playwright-core install chromium
265
324
  npm run test:e2e # Playwright against `wrangler dev`, with axe (WCAG 2.2 AA)
266
325
  ```
267
326
 
268
- The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
327
+ `E2E_PORT` sets the port of the E2E server (default 18900). The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
269
328
  with axe in the light and the dark theme. It saves screenshots to `test-results/screens/`. The committed set in `docs/ui/` shows the UI at this release. Run `UPDATE_SCREENSHOTS=1 npm run test:e2e` to refresh it.
270
329
 
271
330
  A browser cannot set a header on a WebSocket, so the UI sends its token to the live room as a WebSocket sub-protocol (`Sec-WebSocket-Protocol`). A proxy that logs request headers can record it. The API itself takes the token only in an `Authorization` header, and the sub-protocol only on a WebSocket upgrade.
@@ -337,7 +396,7 @@ Every name that each entry point exports is listed here. A name marked (type) ex
337
396
  | `PRINCIPAL_KINDS`, `PrincipalKind` (type), `Principal` (type), `principalSchema`, `isMachineMade` | The principal kinds, the schema of a principal, and the rule that marks machine-made kinds. `principalSchema` uses the identity rule below. |
338
397
  | `operatorActor(env?)`, `ActorConfigError` | Read the actor from `CANVAS_ACTOR` as `<kind>:<id>`. It throws `ActorConfigError` when the value is missing or bad. |
339
398
  | `CanvasService` | The board operations that the CLI, the MCP server and the Worker share. It takes a `Store`. |
340
- | `createMcpServer(service, actor)`, `runMcp(service, actor)` | Make the MCP server, or run it on stdio. |
399
+ | `createMcpServer(service, actor, sharing?)`, `runMcp(service, actor, sharing?)` | Make the MCP server, or run it on stdio. `sharing` is a client from `agentic-canvas/client`: it turns on the sharing tools. |
341
400
  | `createFileStore`, `openLocalStore`, `FileStoreOptions` (type) | Stores on one local file. See `agentic-canvas/file-store`. |
342
401
 
343
402
  ### Actor ids
@@ -353,6 +412,8 @@ old author stays on its elements. Only new writes follow the strict rule.
353
412
  | Name | What it is |
354
413
  |---|---|
355
414
  | `createClient(options)`, `CanvasApiError` | The remote client. See Remote client above. |
415
+ | `parseSubject(text)` | Reads `human:<id>` or `agent:<id>` as `{ subjectKind, subjectId }`. It throws a `TypeError` for any other text. |
416
+ | `BoardSharing` (type), `BoardRole` (type), `SubjectKind` (type), `Subject` (type), `BoardGrant` (type), `BoardInvite` (type), `InviteInput` (type) | Types for sharing. |
356
417
  | `ClientOptions` (type), `CallOptions` (type), `CanvasClient` (type), `ShapeInput` (type), `ConnectorInput` (type), `ShapePatch` (type), `ConnectorPatch` (type), `ImageInfo` (type), `ApiErrorCode` (type) | Types for the client. `ApiErrorCode` adds the client's own codes, such as `NETWORK`, to `ErrorCode`. |
357
418
 
358
419
  ### `agentic-canvas/file-store`
@@ -380,6 +441,7 @@ old author stays on its elements. Only new writes follow the strict rule.
380
441
  | `mount(element, options)` | Render the board UI into an element. It returns `update(options)` and `unmount()`. See Mount the board in your app. |
381
442
  | `BoardEditor(options)` | The same board UI as a React component, without a root. |
382
443
  | `BoardEditorOptions` (type), `BoardPrincipal` (type), `MountedBoard` (type) | Types for the options, the principal, and the object that `mount` returns. |
444
+ | `HostBoard` (type) | The `host` option: `doc`, and optionally `presence`, `status`, and `onStatus`. |
383
445
  | `CanvasClient` (type), `RoomOptions` (type) | The client type, as in `agentic-canvas/client`, and the live room's base URL and token for the `room` option. |
384
446
 
385
447
  The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-design/styles.css` too, for the tokens.
@@ -3,10 +3,10 @@ import {
3
3
  MAX_LIVE_BYTES,
4
4
  isBoardId,
5
5
  isStoreId
6
- } from "./chunk-M74ENUWR.js";
6
+ } from "./chunk-Z6KLGAFP.js";
7
7
  import {
8
8
  CanvasError
9
- } from "./chunk-ZSPZMGHG.js";
9
+ } from "./chunk-MVWUIBGV.js";
10
10
 
11
11
  // src/worker.ts
12
12
  import { createStoreAuth, hasScope as hasScope2, parsePrincipal, parseRegion } from "agentic-identity";
@@ -0,0 +1,290 @@
1
+ import {
2
+ COLOR_PATTERN,
3
+ CanvasError,
4
+ IMAGE_PATTERN,
5
+ MAX_ELEMENTS,
6
+ SHAPE_TYPES
7
+ } from "./chunk-MVWUIBGV.js";
8
+
9
+ // src/core/flow.ts
10
+ import dagre from "@dagrejs/dagre";
11
+ import * as Y from "yjs";
12
+ var MAX_FLOW_NODES = 500;
13
+ var MAX_FLOW_EDGES = 2e3;
14
+ var MAX_FLOW_CHARS = 2e5;
15
+ var NODE = String.raw`([A-Za-z0-9_-]{1,48})(?:\[([^\]]*)\]|\(([^)]*)\)|\{([^}]*)\})?`;
16
+ var EDGE = new RegExp(String.raw`^${NODE}\s*->\s*${NODE}\s*(?::(.*))?$`);
17
+ var ALONE = new RegExp(String.raw`^${NODE}$`);
18
+ var SHAPES = ["rect", "ellipse", "diamond"];
19
+ function parseFlow(source) {
20
+ if (source.length > MAX_FLOW_CHARS) throw new CanvasError("LIMIT", `flow text is over ${MAX_FLOW_CHARS} characters`);
21
+ const graph = { direction: "TB", nodes: [], edges: [] };
22
+ const byId = /* @__PURE__ */ new Map();
23
+ const fail = (line, message) => {
24
+ throw new CanvasError("INVALID", `line ${line}: ${message}`);
25
+ };
26
+ const node = (line, m, at) => {
27
+ const id = m[at];
28
+ if (/^edge_\d+$/.test(id)) fail(line, `"${id}" is reserved for connectors. Pick another node id.`);
29
+ const brackets = m.slice(at + 1, at + 4);
30
+ const open = brackets.findIndex((b) => b !== void 0);
31
+ const shape = SHAPES[Math.max(open, 0)];
32
+ const text = (open < 0 ? "" : brackets[open].trim()) || id;
33
+ const known = byId.get(id);
34
+ if (!known) {
35
+ const created = { id, text, shape, explicit: open >= 0 };
36
+ byId.set(id, created);
37
+ graph.nodes.push(created);
38
+ } else if (open >= 0) {
39
+ if (known.explicit && (known.text !== text || known.shape !== shape)) fail(line, `"${id}" is defined twice with different text`);
40
+ Object.assign(known, { text, shape, explicit: true });
41
+ }
42
+ return id;
43
+ };
44
+ source.replace(/^/, "").split(/\r?\n/).forEach((raw, index) => {
45
+ const line = index + 1;
46
+ const text = raw.trim();
47
+ if (text === "" || text.startsWith("#") || text.startsWith("//")) return;
48
+ if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(text)) fail(line, "control characters are not allowed");
49
+ const direction = /^direction\s+(\S+)$/.exec(text);
50
+ if (direction) {
51
+ if (direction[1] !== "TB" && direction[1] !== "LR") fail(line, `direction must be TB or LR, got "${direction[1]}"`);
52
+ graph.direction = direction[1];
53
+ return;
54
+ }
55
+ const edge = EDGE.exec(text);
56
+ if (edge) {
57
+ const from = node(line, edge, 1);
58
+ const to = node(line, edge, 5);
59
+ graph.edges.push({ from, to, text: (edge[9] ?? "").trim() });
60
+ } else {
61
+ const alone = ALONE.exec(text);
62
+ if (!alone) fail(line, `cannot read "${text.slice(0, 40)}". Use "A -> B: label".`);
63
+ else node(line, alone, 1);
64
+ }
65
+ if (graph.nodes.length > MAX_FLOW_NODES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_NODES} nodes`);
66
+ if (graph.edges.length > MAX_FLOW_EDGES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_EDGES} edges`);
67
+ });
68
+ if (graph.nodes.length === 0) throw new CanvasError("INVALID", "the flow has no nodes");
69
+ return { ...graph, nodes: graph.nodes.map(({ id, text, shape }) => ({ id, text, shape })) };
70
+ }
71
+ function sizeOf(n) {
72
+ const w = Math.min(360, Math.max(120, 24 + 8 * n.text.length));
73
+ return n.shape === "diamond" ? { w: Math.min(360, Math.round(w * 1.5)), h: 90 } : { w, h: 60 };
74
+ }
75
+ function layoutFlow(graph) {
76
+ const g = new dagre.graphlib.Graph({ multigraph: true });
77
+ g.setGraph({ rankdir: graph.direction, nodesep: 40, ranksep: 70, marginx: 0, marginy: 0 });
78
+ g.setDefaultEdgeLabel(() => ({}));
79
+ const index = new Map(graph.nodes.map((n, i) => [n.id, `n${i}`]));
80
+ const sizes = graph.nodes.map(sizeOf);
81
+ sizes.forEach(({ w, h }, i) => g.setNode(`n${i}`, { width: w, height: h }));
82
+ graph.edges.forEach((e, i) => g.setEdge(index.get(e.from), index.get(e.to), {}, String(i)));
83
+ dagre.layout(g);
84
+ const boxes = graph.nodes.map((n, i) => {
85
+ const { w, h } = sizes[i];
86
+ const c = g.node(`n${i}`);
87
+ return { id: n.id, x: Math.round(c.x - w / 2), y: Math.round(c.y - h / 2), w, h };
88
+ });
89
+ const minX = Math.min(...boxes.map((b) => b.x));
90
+ const minY = Math.min(...boxes.map((b) => b.y));
91
+ return boxes.map((b) => ({ ...b, x: b.x - minX, y: b.y - minY }));
92
+ }
93
+ function drawFlow(board, source, actor, options = {}) {
94
+ const prefix = options.idPrefix ?? `${crypto.randomUUID().slice(0, 8)}-`;
95
+ if (!/^[A-Za-z0-9_-]{0,16}$/.test(prefix)) throw new CanvasError("INVALID", "idPrefix must be up to 16 of A-Z, a-z, 0-9, _ and -");
96
+ const graph = parseFlow(source);
97
+ const placed = layoutFlow(graph);
98
+ const shapes = board.list().flatMap((e) => e.type === "connector" || e.type === "group" ? [] : [e]);
99
+ const origin = options.origin ?? {
100
+ x: shapes.length ? Math.min(...shapes.map((s) => s.x)) : 0,
101
+ y: shapes.length ? Math.max(...shapes.map((s) => s.y + s.h)) + 80 : 0
102
+ };
103
+ const work = board.clone();
104
+ const text = new Map(graph.nodes.map((n) => [n.id, n]));
105
+ for (const p of placed) {
106
+ const n = text.get(p.id);
107
+ work.addShape({ id: prefix + p.id, type: n.shape, text: n.text, x: p.x + origin.x, y: p.y + origin.y, w: p.w, h: p.h }, actor);
108
+ }
109
+ const edges = graph.edges.map((e, i) => {
110
+ const id = `${prefix}edge_${i + 1}`;
111
+ work.addConnector({ id, from: { id: prefix + e.from }, to: { id: prefix + e.to }, text: e.text }, actor);
112
+ return id;
113
+ });
114
+ Y.applyUpdate(board.doc, Y.encodeStateAsUpdate(work.doc, Y.encodeStateVector(board.doc)));
115
+ return { nodes: placed.map((p) => prefix + p.id), edges };
116
+ }
117
+
118
+ // src/core/svg.ts
119
+ var LINE = 17;
120
+ var num = (n) => String(Math.round(n * 100) / 100);
121
+ var esc = (value) => (typeof value === "string" ? value : "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
122
+ var paint = (value, fallback) => typeof value === "string" && COLOR_PATTERN.test(value) ? value : fallback;
123
+ var str = (value) => typeof value === "string" ? value : "";
124
+ var within = (n, low, high) => typeof n === "number" && Number.isFinite(n) && n >= low && n <= high;
125
+ var LIMIT_COORD = 1e7;
126
+ var TYPES = /* @__PURE__ */ new Set([...SHAPE_TYPES, "connector", "group"]);
127
+ var sane = (s) => within(s.x, -LIMIT_COORD, LIMIT_COORD) && within(s.y, -LIMIT_COORD, LIMIT_COORD) && within(s.w, 1e-6, LIMIT_COORD) && within(s.h, 1e-6, LIMIT_COORD);
128
+ var isShape = (v) => "w" in v;
129
+ var center = (s) => ({ x: s.x + s.w / 2, y: s.y + s.h / 2 });
130
+ function edgePoint(s, toward) {
131
+ const c = center(s);
132
+ const dx = toward.x - c.x;
133
+ const dy = toward.y - c.y;
134
+ if (dx === 0 && dy === 0) return c;
135
+ const a = s.w / 2;
136
+ const b = s.h / 2;
137
+ const k = s.type === "ellipse" ? 1 / Math.hypot(dx / a, dy / b) : s.type === "diamond" ? 1 / (Math.abs(dx) / a + Math.abs(dy) / b) : Math.min(a / Math.abs(dx), b / Math.abs(dy));
138
+ return { x: c.x + dx * k, y: c.y + dy * k };
139
+ }
140
+ function wrap(text, width) {
141
+ const max = Math.max(4, Math.floor((width - 16) / 7.5));
142
+ const out = [];
143
+ for (const paragraph of text.split(/\r?\n/)) {
144
+ let line = "";
145
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
146
+ const chars = [...word];
147
+ while (chars.length > max) {
148
+ if (line) out.push(line);
149
+ line = "";
150
+ out.push(chars.splice(0, max).join(""));
151
+ }
152
+ const rest = chars.join("");
153
+ if (line && [...line].length + 1 + chars.length > max) {
154
+ out.push(line);
155
+ line = rest;
156
+ } else line = line ? `${line} ${rest}` : rest;
157
+ }
158
+ out.push(line);
159
+ }
160
+ return out.slice(0, 40);
161
+ }
162
+ function label(text, cx, cy, width, extra = "") {
163
+ if (text === "") return "";
164
+ const lines = wrap(text, width);
165
+ const top = cy - (lines.length - 1) * LINE / 2;
166
+ const spans = lines.map((l, i) => `<tspan x="${num(cx)}" y="${num(top + i * LINE)}">${esc(l)}</tspan>`);
167
+ return `<text text-anchor="middle" dominant-baseline="central"${extra}>${spans.join("")}</text>`;
168
+ }
169
+ function shapeBody(s) {
170
+ const { x, y, w, h } = s;
171
+ const stroke = paint(s.stroke, "#1f2937");
172
+ const look = (fill, more = "") => `fill="${paint(s.fill, fill)}" stroke="${stroke}" stroke-width="2"${more}`;
173
+ const text = label(str(s.text), x + w / 2, y + h / 2, w);
174
+ switch (s.type) {
175
+ case "ellipse":
176
+ return `<ellipse cx="${num(x + w / 2)}" cy="${num(y + h / 2)}" rx="${num(w / 2)}" ry="${num(h / 2)}" ${look("#ffffff")}/>${text}`;
177
+ case "diamond": {
178
+ const pts = [[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]];
179
+ return `<polygon points="${pts.map(([px, py]) => `${num(px)},${num(py)}`).join(" ")}" ${look("#ffffff")}/>${text}`;
180
+ }
181
+ case "text":
182
+ return text;
183
+ case "frame":
184
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("none", ' stroke-dasharray="8 6"')}/>${label(str(s.text), x + w / 2, y - 12, w)}`;
185
+ case "image":
186
+ if (typeof s.src === "string" && s.src.startsWith("data:") && IMAGE_PATTERN.test(s.src)) {
187
+ return `<image href="${s.src}" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" preserveAspectRatio="xMidYMid meet"/>`;
188
+ }
189
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("#f3f4f6", ' stroke-dasharray="4 4"')}/>${label("image", x + w / 2, y + h / 2, w)}`;
190
+ default:
191
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${s.type === "sticky" ? 2 : 6}" ${look(s.type === "sticky" ? "#fde68a" : "#ffffff")}/>${text}`;
192
+ }
193
+ }
194
+ var endKey = (e) => "id" in e ? e.id : `${e.x},${e.y}`;
195
+ var wellFormed = (e) => typeof e === "object" && e !== null && (typeof e.id === "string" || within(e.x, -LIMIT_COORD, LIMIT_COORD) && within(e.y, -LIMIT_COORD, LIMIT_COORD));
196
+ function connectorBody(c, shapes, lane) {
197
+ const resolve = (e) => "id" in e ? shapes.get(e.id) : e;
198
+ const a = resolve(c.from);
199
+ const b = resolve(c.to);
200
+ if (!a || !b) return "";
201
+ let d;
202
+ let at;
203
+ if (isShape(a) && a === b) {
204
+ const x0 = a.x + a.w * 0.35;
205
+ const x1 = a.x + a.w * 0.65;
206
+ const lift = 45 + 32 * lane.index;
207
+ d = `M${num(x0)} ${num(a.y)}C${num(x0)} ${num(a.y - lift)} ${num(x1)} ${num(a.y - lift)} ${num(x1)} ${num(a.y)}`;
208
+ at = { x: a.x + a.w / 2, y: a.y - lift * 0.75 - 6 };
209
+ } else {
210
+ const ca = isShape(a) ? center(a) : a;
211
+ const cb = isShape(b) ? center(b) : b;
212
+ const flip = endKey(c.from) > endKey(c.to);
213
+ const dx = (flip ? ca.x - cb.x : cb.x - ca.x) || 0;
214
+ const dy = (flip ? ca.y - cb.y : cb.y - ca.y) || 0;
215
+ const len = Math.hypot(dx, dy) || 1;
216
+ const offset = (lane.index - (lane.count - 1) / 2) * 44;
217
+ const mid = { x: (ca.x + cb.x) / 2, y: (ca.y + cb.y) / 2 };
218
+ const ctrl = { x: mid.x - dy / len * 2 * offset, y: mid.y + dx / len * 2 * offset };
219
+ const p = isShape(a) ? edgePoint(a, ctrl) : a;
220
+ const q = isShape(b) ? edgePoint(b, ctrl) : b;
221
+ d = offset === 0 ? `M${num(p.x)} ${num(p.y)}L${num(q.x)} ${num(q.y)}` : `M${num(p.x)} ${num(p.y)}Q${num(ctrl.x)} ${num(ctrl.y)} ${num(q.x)} ${num(q.y)}`;
222
+ at = offset === 0 ? { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } : { x: (p.x + q.x) / 4 + ctrl.x / 2, y: (p.y + q.y) / 4 + ctrl.y / 2 };
223
+ }
224
+ const marks = `${c.startArrow ? ' marker-start="url(#arrow)"' : ""}${c.endArrow ? ' marker-end="url(#arrow)"' : ""}`;
225
+ const halo = ' stroke="#ffffff" stroke-width="4" paint-order="stroke"';
226
+ return `<path d="${d}" fill="none" stroke="${paint(c.stroke, "#1f2937")}" stroke-width="2"${marks}/>${label(str(c.text), at.x, at.y, 160, halo)}`;
227
+ }
228
+ var drawable = (el) => TYPES.has(el.type) && (el.type === "connector" ? wellFormed(el.from) && wellFormed(el.to) : el.type === "group" || sane(el));
229
+ function toSvg(board) {
230
+ const listed = board.list();
231
+ if (listed.length > MAX_ELEMENTS) throw new CanvasError("LIMIT", `a board holds at most ${MAX_ELEMENTS} elements`);
232
+ const all = listed.filter((el) => drawable(el));
233
+ const shapes = /* @__PURE__ */ new Map();
234
+ const box = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
235
+ const grow = (x, y) => {
236
+ box.minX = Math.min(box.minX, x);
237
+ box.minY = Math.min(box.minY, y);
238
+ box.maxX = Math.max(box.maxX, x);
239
+ box.maxY = Math.max(box.maxY, y);
240
+ };
241
+ for (const el of all) {
242
+ if (el.type === "connector") {
243
+ for (const e of [el.from, el.to]) if (!("id" in e)) grow(e.x, e.y);
244
+ } else if (el.type !== "group") {
245
+ shapes.set(el.id, el);
246
+ grow(el.x, el.y);
247
+ grow(el.x + el.w, el.y + el.h);
248
+ }
249
+ }
250
+ const pad = 80;
251
+ const any = box.minX !== Infinity;
252
+ const minX = any ? box.minX - pad : 0;
253
+ const minY = any ? box.minY - pad : 0;
254
+ const width = any ? Math.ceil(box.maxX + pad - minX) : 240;
255
+ const height = any ? Math.ceil(box.maxY + pad - minY) : 120;
256
+ const lanes = /* @__PURE__ */ new Map();
257
+ const groups = /* @__PURE__ */ new Map();
258
+ for (const el of all) {
259
+ if (el.type !== "connector") continue;
260
+ const key = [endKey(el.from), endKey(el.to)].sort().join("|");
261
+ groups.set(key, [...groups.get(key) ?? [], el.id]);
262
+ }
263
+ for (const ids of groups.values()) ids.forEach((id, index) => lanes.set(id, { index, count: ids.length }));
264
+ const body = all.map((el) => {
265
+ if (el.type === "group") return "";
266
+ const inner = el.type === "connector" ? connectorBody(el, shapes, lanes.get(el.id)) : shapeBody(el);
267
+ if (inner === "") return "";
268
+ const kind = el.author.kind;
269
+ const mark = `data-id="${esc(el.id)}" data-type="${el.type}" data-author-kind="${kind}" data-machine-made="${kind !== "human"}"`;
270
+ return `<g ${mark}>${inner}</g>`;
271
+ });
272
+ const title = esc(board.title) || "Whiteboard";
273
+ return [
274
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${num(minX)} ${num(minY)} ${width} ${height}" font-family="sans-serif" font-size="14" fill="#111827" role="img" aria-label="${title}">`,
275
+ `<title>${title}</title>`,
276
+ '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#1f2937"/></marker></defs>',
277
+ `<rect x="${num(minX)}" y="${num(minY)}" width="${width}" height="${height}" fill="#ffffff"/>`,
278
+ ...body.filter(Boolean),
279
+ "</svg>"
280
+ ].join("\n");
281
+ }
282
+
283
+ export {
284
+ MAX_FLOW_NODES,
285
+ MAX_FLOW_EDGES,
286
+ parseFlow,
287
+ layoutFlow,
288
+ drawFlow,
289
+ toSvg
290
+ };
@@ -1,14 +1,12 @@
1
1
  import {
2
2
  CanvasError,
3
+ PRINCIPAL_KINDS,
3
4
  SHAPE_TYPES
4
- } from "./chunk-ZSPZMGHG.js";
5
- import {
6
- PRINCIPAL_KINDS
7
- } from "./chunk-JOBTIAFJ.js";
5
+ } from "./chunk-MVWUIBGV.js";
8
6
 
9
7
  // src/version.ts
10
8
  import { createRequire } from "module";
11
- var VERSION = true ? "0.9.0" : readPackageVersion();
9
+ var VERSION = true ? "0.11.0" : readPackageVersion();
12
10
 
13
11
  // src/brand.ts
14
12
  var BRAND = {
@@ -70,9 +68,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
70
68
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
71
69
  import { z } from "zod";
72
70
  var json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
71
+ var isApiError = (error) => error instanceof Error && error.name === "CanvasApiError" && typeof error.code === "string";
73
72
  function failure(error) {
74
- if (!(error instanceof CanvasError)) console.error(error instanceof Error ? error.message : String(error));
75
- const body = error instanceof CanvasError ? { code: error.code, message: error.message } : { code: "INTERNAL", message: "the board operation failed" };
73
+ const safe = error instanceof CanvasError || isApiError(error);
74
+ if (!safe) console.error(error instanceof Error ? error.message : String(error));
75
+ const body = safe ? { code: error.code, message: error.message } : { code: "INTERNAL", message: "the board operation failed" };
76
76
  return { isError: true, content: [{ type: "text", text: JSON.stringify(body) }] };
77
77
  }
78
78
  function tool(run) {
@@ -86,7 +86,7 @@ function tool(run) {
86
86
  }
87
87
  var board = z.string().describe("Board id.");
88
88
  var num = z.number();
89
- function createMcpServer(service, actor) {
89
+ function createMcpServer(service, actor, sharing) {
90
90
  const server = new McpServer({ name: BRAND.command, version: BRAND.version });
91
91
  const read = { readOnlyHint: true };
92
92
  server.registerTool(
@@ -170,10 +170,38 @@ function createMcpServer(service, actor) {
170
170
  { description: "Export the board as a PNG image. Needs the optional playwright-core and a Chromium.", inputSchema: { board }, annotations: read },
171
171
  tool(async (a) => ({ content: [{ type: "image", data: Buffer.from(await svgToPng(await service.exportSvg(a.board))).toString("base64"), mimeType: "image/png" }] }))
172
172
  );
173
+ const shared = () => {
174
+ if (!sharing) throw new CanvasError("UNAVAILABLE", "sharing needs a server: set CANVAS_SERVER and CANVAS_TOKEN");
175
+ return sharing;
176
+ };
177
+ const kind = z.enum(["human", "agent"]).describe("human or agent. A person and an agent can share one id, and hold separate roles.");
178
+ server.registerTool(
179
+ "list_access",
180
+ { description: "List the grants and the open invites of a board. Needs the owner role.", inputSchema: { board }, annotations: read },
181
+ tool(async (a) => json(await shared().access(a.board)))
182
+ );
183
+ server.registerTool(
184
+ "grant_access",
185
+ { description: "Give a person or an agent a role on a board. A lower role replaces a higher one. Needs the owner role.", inputSchema: { board, subjectKind: kind, subjectId: z.string(), role: z.enum(["viewer", "commenter", "editor", "owner"]) }, annotations: { destructiveHint: true } },
186
+ tool(async ({ board: b, ...input }) => json(await shared().grant(b, input)))
187
+ );
188
+ server.registerTool(
189
+ "revoke_access",
190
+ { description: "Take a role back from a person or an agent. The last owner cannot be removed. Needs the owner role.", inputSchema: { board, subjectKind: kind, subjectId: z.string() }, annotations: { destructiveHint: true } },
191
+ tool(async ({ board: b, ...input }) => json(await shared().revoke(b, input)))
192
+ );
193
+ server.registerTool(
194
+ "create_invite",
195
+ {
196
+ description: "Invite a person by email or an agent by handle to a board. Give email or handle, not both. The role is not owner. Needs the owner role.",
197
+ inputSchema: { board, role: z.enum(["viewer", "commenter", "editor"]), email: z.string().optional(), handle: z.string().optional(), expiresInMs: z.number().optional() }
198
+ },
199
+ tool(async ({ board: b, ...input }) => json(await shared().invite(b, input)))
200
+ );
173
201
  return server;
174
202
  }
175
- async function runMcp(service, actor) {
176
- await createMcpServer(service, actor).connect(new StdioServerTransport());
203
+ async function runMcp(service, actor, sharing) {
204
+ await createMcpServer(service, actor, sharing).connect(new StdioServerTransport());
177
205
  }
178
206
 
179
207
  export {