agentic-canvas 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pooria Arab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,142 @@
1
- # Temporary Holding Version
1
+ # agentic-canvas
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/agentic-canvas)](https://www.npmjs.com/package/agentic-canvas)
4
+ [![MIT license](https://img.shields.io/badge/license-MIT-blue)](https://opensource.org/license/mit)
5
+
6
+ [Package](https://www.npmjs.com/package/agentic-canvas) · [Source](https://github.com/pooriaarab/agentic-canvas) · [Support](https://github.com/pooriaarab/agentic-canvas/issues)
7
+
8
+ A shared whiteboard for diagrams, drawn by people and agents.
9
+
10
+ > Status: 0.1.0, the first release. The board model, the agent operations, the
11
+ > exports, the CLI, and the MCP server work. The HTTP API and the web UI are not
12
+ > built yet.
13
+
14
+ ## Install
15
+
16
+ Node 22 or newer.
17
+
18
+ ```sh
19
+ npm install agentic-canvas
20
+ ```
21
+
22
+ To build from source instead, run `npm ci` and `npm run build`.
23
+
24
+ PNG export is optional. It needs the `playwright-core` package (Apache-2.0) and a
25
+ Chromium: `npm i playwright-core && npx playwright-core install chromium`.
26
+ The Chromium download is a one-time call to the Playwright CDN at install time. PNG export
27
+ itself makes no network call.
28
+
29
+ ## Quick start
30
+
31
+ ```sh
32
+ export CANVAS_ACTOR=agent:helper # who writes: <kind>:<id>
33
+ npx agentic-canvas new --id demo --title "Release flow"
34
+ npx agentic-canvas flow demo --text $'direction LR\nCode(Write code) -> Test{Tests pass?}: push\nTest -> Ship[Ship it]: yes'
35
+ npx agentic-canvas read demo # nodes, edges, text as JSON
36
+ npx agentic-canvas export demo --out demo.svg
37
+ npx agentic-canvas export demo --format png --out demo.png
38
+ ```
39
+
40
+ Other commands: `add`, `connect`, `move`, `rm`. Run `npx agentic-canvas --help`.
41
+ `repair` is for the owner only. It needs `CANVAS_ACTOR=human:<id>`.
42
+ Boards live in `./.agentic-canvas` (or `--dir`, or `CANVAS_DIR`). Every write
43
+ needs an actor in `CANVAS_ACTOR`, as `<kind>:<id>`. The operator sets it. A write
44
+ fails when it is unset, empty, or has a bad kind. The CLI has no `--actor` flag and no
45
+ default actor, so a caller cannot pick its own kind. Only the operator can set `human`.
46
+
47
+ ## MCP server
48
+
49
+ ```sh
50
+ CANVAS_ACTOR=agent:helper npx agentic-canvas --dir ./boards mcp
51
+ ```
52
+
53
+ The server speaks MCP on stdio. It has one tool for each CLI command: `create_board`,
54
+ `read_board`, `add_shape`, `connect`, `move`, `remove`, `draw_flow`, `export_svg`, and
55
+ `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
56
+ `<kind>:<id>`. That actor stamps every write. No tool takes an actor, author, or kind, so a
57
+ caller cannot choose who it writes as. An error comes back as a tool error with a code.
58
+
59
+ ## SDK
60
+
61
+ ```ts
62
+ import { Board } from "agentic-canvas";
63
+
64
+ const me = { kind: "human", id: "ada" } as const;
65
+ const bot = { kind: "agent_of_human", id: "helper" } as const;
66
+
67
+ const board = new Board();
68
+ board.addShape({ id: "a", type: "rect", x: 0, y: 0, text: "Start" }, me);
69
+ board.addShape({ id: "b", type: "diamond", x: 240, y: 0, text: "Ok?" }, bot);
70
+ board.addConnector({ from: { id: "a" }, to: { id: "b" }, text: "next" }, bot);
71
+ board.list(); // every element, back to front
72
+ ```
73
+
74
+ Read the board as data, and draw a flowchart from text:
75
+
76
+ ```ts
77
+ import { drawFlow, readBoard } from "agentic-canvas";
78
+
79
+ drawFlow(board, "direction LR\nS(Start) -> D{Ok?}: go\nD -> E[Done]: yes", bot);
80
+ readBoard(board); // { title, nodes, edges, groups, texts }
81
+ ```
82
+
83
+ The flow text takes one statement per line: `A -> B: label`. Use `[text]` for a
84
+ box, `(text)` for an ellipse, and `{text}` for a diamond. `direction LR` sets
85
+ the direction. Lines that start with `#` or `//` are comments.
86
+
87
+ ## Features
88
+
89
+ - Shapes: rectangle, ellipse, diamond, text, sticky note, frame, image.
90
+ - Connectors that bind to shapes and follow them when they move.
91
+ - Z-order, and groups that nest.
92
+ - Every element keeps the kind of its author and of its last editor. An agent
93
+ edit stays visible as machine-made.
94
+ - `readBoard`: nodes, edges, groups, and text as plain data, with a machine-made
95
+ flag on each.
96
+ - `drawFlow`: a flowchart from text with a layered layout. It adds nothing when
97
+ anything fails.
98
+ - `toSvg`: a deterministic SVG. It fetches nothing and marks machine-made work
99
+ with `data-machine-made`.
100
+ - `svgToPng`: PNG through a headless Chromium with JavaScript off, service workers blocked,
101
+ and every network request blocked. A board over 16 million pixels exports as SVG only.
102
+ - A CLI over a local file store. Several processes can write at once.
103
+ - An MCP server (stdio) over the same service as the CLI.
104
+ - Yjs underneath: two copies of a board merge without conflicts.
105
+ - No UI code. The model is plain TypeScript on Yjs, with no outbound calls.
106
+
107
+ ## Limits
108
+
109
+ - A board holds at most 10,000 elements. A flow holds at most 500 nodes and 2,000
110
+ edges.
111
+ - Flow text has no chains (`A -> B -> C`). Write one edge per line.
112
+ - `readBoard` leaves out embedded image bytes.
113
+ - Images hold a `data:` PNG, JPEG, GIF, or WebP, or a `blob:` key. Remote URLs and
114
+ SVG are refused.
115
+ - Two peers that make the same explicit id at the same time keep only one element, with no
116
+ error. Explicit ids are safe with one writer. Leave `id` empty and the board picks a random one.
117
+ - A synced update is untrusted. An element that fails the write rules is hidden, and `repair()`
118
+ deletes it. A board past the element cap refuses to load or read. A human operator runs
119
+ `repair --drop <element>` to delete elements until it fits.
120
+ - `update` cannot clear a required field. Only `fill` and `stroke` accept `undefined`.
121
+ - Z values stop at ±1e9. New elements that pass the limit land at the limit and tie with it.
122
+ While an element sits at +1e9, a front move fails with `LIMIT`. While one sits at -1e9, a back
123
+ move fails. Remove every element at the limit to clear it.
124
+ - Delete against connect can leave a connector with a missing end. Readers hide it. `repair()`
125
+ removes it, and `Board.decode` runs `repair()` for you.
126
+ - `move` writes absolute positions. Two peers that move one shape at the same time do not add up:
127
+ the last writer wins.
128
+ - Frames are plain shapes. They do not own the shapes inside them yet.
129
+ - The store is one file, `canvas.json`, in the store folder. A write takes `canvas.json.lock`.
130
+ A lock whose owner is gone, or that is older than 30 seconds, is taken over. A lock that
131
+ cannot be read counts as held: the call then waits 10 seconds and fails with `UNAVAILABLE`.
132
+ Remove the lock by hand if no process runs.
133
+ - Node 22 or newer is needed. `agentic-canvas/file-store` exports `createFileStore(path)` and
134
+ `openLocalStore(dir)`. A writer that is paused for more than 30 seconds can lose its lock
135
+ and then still write.
136
+ - Delete leaves no audit record yet. The caller logs it.
137
+ - PNG needs the optional `playwright-core` and a Chromium. Without them it fails with
138
+ `UNAVAILABLE`. SVG always works.
139
+
140
+ ## License
141
+
142
+ MIT.
package/SKILL.md ADDED
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: agentic-canvas
3
+ description: A shared whiteboard for diagrams, drawn by people and agents. Keywords: whiteboard,canvas,diagrams,drawing,collaboration,agents,mcp,cli.
4
+ ---
5
+
6
+ # agentic-canvas
7
+
8
+ A shared whiteboard for diagrams, drawn by people and agents.
9
+
10
+ ## Use the SDK
11
+
12
+ ```js
13
+ import { Board } from "agentic-canvas";
14
+
15
+ const bot = { kind: "agent_of_human", id: "helper" };
16
+ const board = new Board();
17
+ board.addShape({ id: "a", type: "rect", x: 0, y: 0, text: "Start" }, bot);
18
+ ```
19
+
20
+ Draw a flowchart from text, then read the board:
21
+
22
+ ```js
23
+ import { drawFlow, readBoard } from "agentic-canvas";
24
+
25
+ drawFlow(board, "A[Start] -> B{Ok?}: go\nB -> C: yes", bot);
26
+ readBoard(board);
27
+ ```
28
+
29
+ ## Use the CLI
30
+
31
+ ```sh
32
+ export CANVAS_ACTOR=agent:helper
33
+ agentic-canvas new --id demo
34
+ agentic-canvas flow demo --text "A -> B: go"
35
+ agentic-canvas read demo
36
+ agentic-canvas export demo --out demo.svg
37
+ ```
38
+
39
+ ## Rules
40
+
41
+ - Name the actor on every write.
42
+ - Report a `CanvasError` as a failure.
@@ -0,0 +1,237 @@
1
+ // src/store-file.ts
2
+ import { randomUUID } from "crypto";
3
+ import { mkdirSync } from "fs";
4
+ import { link, open, readFile, rename, unlink } from "fs/promises";
5
+ import { join } from "path";
6
+
7
+ // src/store-errors.ts
8
+ var FileStoreError = class extends Error {
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = "FileStoreError";
13
+ }
14
+ code;
15
+ };
16
+
17
+ // src/store-file.ts
18
+ import { assertCollection, assertId, createMemoryStore, parseRegion, StoreConflictError } from "agentic-identity";
19
+ var MAX_TRANSACTION_RUNS = 50;
20
+ var clone = (value) => JSON.parse(JSON.stringify(value));
21
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
+ var codeOf = (error) => error?.code;
23
+ function processIsAlive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ } catch (error) {
28
+ return codeOf(error) === "EPERM";
29
+ }
30
+ }
31
+ function lockIsStale(text, staleAfterMs) {
32
+ let held;
33
+ try {
34
+ held = JSON.parse(text);
35
+ } catch {
36
+ return false;
37
+ }
38
+ if (typeof held !== "object" || held === null) return false;
39
+ const { pid, at } = held;
40
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid < 1 || typeof at !== "number" || !Number.isFinite(at)) return false;
41
+ return !processIsAlive(pid) || Date.now() - at > staleAfterMs;
42
+ }
43
+ function createFileStore(path, options = {}) {
44
+ const region = parseRegion(options.region ?? "unknown");
45
+ const lockPath = `${path}.lock`;
46
+ const timeoutMs = options.lockTimeoutMs ?? 1e4;
47
+ const staleAfterMs = options.staleAfterMs ?? 3e4;
48
+ for (const ms of [timeoutMs, staleAfterMs]) if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) throw new TypeError("lockTimeoutMs and staleAfterMs must be finite numbers of 0 or more");
49
+ let queue = Promise.resolve();
50
+ async function load() {
51
+ let text;
52
+ try {
53
+ text = await readFile(path, "utf8");
54
+ } catch (error) {
55
+ if (codeOf(error) === "ENOENT") return /* @__PURE__ */ new Map();
56
+ throw error;
57
+ }
58
+ try {
59
+ const data = JSON.parse(text);
60
+ if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error();
61
+ return new Map(
62
+ Object.entries(data).map(([name, docs]) => {
63
+ if (typeof docs !== "object" || docs === null || Array.isArray(docs)) throw new Error();
64
+ for (const doc of Object.values(docs)) if (typeof doc !== "object" || doc === null || Array.isArray(doc)) throw new Error();
65
+ return [name, new Map(Object.entries(docs))];
66
+ })
67
+ );
68
+ } catch {
69
+ throw new FileStoreError("DAMAGED", `the database file is not valid JSON. Repair or remove it`);
70
+ }
71
+ }
72
+ async function save(snapshot) {
73
+ const text = JSON.stringify(Object.fromEntries([...snapshot].map(([name, docs]) => [name, Object.fromEntries(docs)])));
74
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
75
+ try {
76
+ const handle = await open(temp, "w");
77
+ try {
78
+ await handle.writeFile(text);
79
+ await handle.sync();
80
+ } finally {
81
+ await handle.close();
82
+ }
83
+ await rename(temp, path);
84
+ } catch (error) {
85
+ await unlink(temp).catch(() => void 0);
86
+ throw error;
87
+ }
88
+ }
89
+ async function takeOverIfStale() {
90
+ let seen;
91
+ try {
92
+ seen = await readFile(lockPath, "utf8");
93
+ } catch {
94
+ return;
95
+ }
96
+ if (!lockIsStale(seen, staleAfterMs)) return;
97
+ const aside = `${lockPath}.stale.${randomUUID()}`;
98
+ try {
99
+ await rename(lockPath, aside);
100
+ } catch {
101
+ return;
102
+ }
103
+ if (await readFile(aside, "utf8").catch(() => "") !== seen) await link(aside, lockPath).catch(() => void 0);
104
+ await unlink(aside).catch(() => void 0);
105
+ }
106
+ async function acquire() {
107
+ const token = randomUUID();
108
+ const deadline = Date.now() + timeoutMs;
109
+ for (; ; ) {
110
+ try {
111
+ const handle = await open(lockPath, "wx");
112
+ try {
113
+ await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), token }));
114
+ } finally {
115
+ await handle.close();
116
+ }
117
+ return token;
118
+ } catch (error) {
119
+ if (codeOf(error) !== "EEXIST") throw error;
120
+ }
121
+ await takeOverIfStale();
122
+ if (Date.now() > deadline) throw new FileStoreError("LOCKED", `the database file is locked. If no agentic-canvas process runs, remove the .lock file next to it`);
123
+ await sleep(5 + Math.random() * 15);
124
+ }
125
+ }
126
+ async function release(token) {
127
+ const text = await readFile(lockPath, "utf8").catch(() => "");
128
+ if (text.includes(`"token":"${token}"`)) await unlink(lockPath).catch(() => void 0);
129
+ }
130
+ async function memoryOf(snapshot, only) {
131
+ const memory = createMemoryStore({ region });
132
+ for (const [name, docs] of snapshot) if (only === void 0 || only === name) for (const [id, doc] of docs) await memory.put(name, id, doc);
133
+ return memory;
134
+ }
135
+ const recording = (inner, log) => ({
136
+ get: (collection, id) => inner.get(collection, id),
137
+ query: (collection, query) => inner.query(collection, query),
138
+ put: async (collection, id, doc) => {
139
+ await inner.put(collection, id, doc);
140
+ log.push([collection, id, clone(doc)]);
141
+ },
142
+ delete: async (collection, id) => {
143
+ await inner.delete(collection, id);
144
+ log.push([collection, id, null]);
145
+ }
146
+ });
147
+ const locked = (body) => {
148
+ const run = queue.then(async () => {
149
+ const token = await acquire();
150
+ try {
151
+ return await body();
152
+ } finally {
153
+ await release(token);
154
+ }
155
+ });
156
+ queue = run.catch(() => void 0);
157
+ return run;
158
+ };
159
+ async function commit(snapshot, log) {
160
+ for (const [name, id, doc] of log) {
161
+ if (doc === null) snapshot.get(name)?.delete(id);
162
+ else snapshot.set(name, (snapshot.get(name) ?? /* @__PURE__ */ new Map()).set(id, doc));
163
+ }
164
+ if (log.length > 0) await save(snapshot);
165
+ }
166
+ const writeOne = (write) => locked(async () => {
167
+ const log = [];
168
+ await write(recording(createMemoryStore({ region }), log));
169
+ await commit(await load(), log);
170
+ });
171
+ const watching = (inner, seen) => ({
172
+ ...inner,
173
+ async get(collection, id) {
174
+ const doc = await inner.get(collection, id);
175
+ if (!seen.has(`${collection}/${id}`)) seen.set(`${collection}/${id}`, JSON.stringify(doc));
176
+ return doc;
177
+ },
178
+ async query(collection, query) {
179
+ const page = await inner.query(collection, query);
180
+ for (const item of page.items) if (!seen.has(`${collection}/${item.id}`)) seen.set(`${collection}/${item.id}`, JSON.stringify(item.data));
181
+ return page;
182
+ }
183
+ });
184
+ const unchanged = (snapshot, seen) => [...seen].every(([key, text]) => {
185
+ const slash = key.indexOf("/");
186
+ return JSON.stringify(snapshot.get(key.slice(0, slash))?.get(key.slice(slash + 1)) ?? null) === text;
187
+ });
188
+ return {
189
+ region,
190
+ async get(collection, id) {
191
+ assertCollection(collection);
192
+ assertId(id);
193
+ const doc = (await load()).get(collection)?.get(id);
194
+ return doc ? clone(doc) : null;
195
+ },
196
+ async query(collection, query) {
197
+ assertCollection(collection);
198
+ return (await memoryOf(await load(), collection)).query(collection, query);
199
+ },
200
+ put: (collection, id, doc) => writeOne((ops) => ops.put(collection, id, doc)),
201
+ delete: (collection, id) => writeOne((ops) => ops.delete(collection, id)),
202
+ // Optimistic, as the port requires: run on a copy of the file with no lock, then take the lock and commit
203
+ // only if every doc the run read is unchanged. Else run again. The memory adapter keeps the rule that
204
+ // every read comes before the first write.
205
+ async transaction(fn) {
206
+ for (let run = 0; run < MAX_TRANSACTION_RUNS; run++) {
207
+ const log = [];
208
+ const seen = /* @__PURE__ */ new Map();
209
+ const result = await (await memoryOf(await load())).transaction(async (tx) => {
210
+ log.length = 0;
211
+ seen.clear();
212
+ return fn(watching(recording(tx, log), seen));
213
+ });
214
+ const done = log.length === 0 ? unchanged(await load(), seen) : await locked(async () => {
215
+ const current = await load();
216
+ if (!unchanged(current, seen)) return false;
217
+ await commit(current, log);
218
+ return true;
219
+ });
220
+ if (done) return result;
221
+ }
222
+ throw new StoreConflictError();
223
+ }
224
+ };
225
+ }
226
+ function openLocalStore(dir) {
227
+ mkdirSync(dir, { recursive: true });
228
+ return createFileStore(join(dir, STORE_FILE));
229
+ }
230
+ var STORE_FILE = "canvas.json";
231
+
232
+ export {
233
+ FileStoreError,
234
+ createFileStore,
235
+ openLocalStore,
236
+ STORE_FILE
237
+ };