beehaven 0.8.35 → 0.8.37

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/dist/relay.js CHANGED
@@ -10,6 +10,7 @@ import { join, dirname, resolve, sep } from 'path';
10
10
  import { execSync, execFileSync } from 'child_process';
11
11
  import WebSocket from 'ws';
12
12
  import { logger } from './logger.js';
13
+ import { ReconnectBackoff } from './reconnect-backoff.js';
13
14
  import { TerminalBlockParser } from './terminal/block-parser.js';
14
15
  import { writeIntegration, cleanupIntegration } from './terminal/shell-integration.js';
15
16
  import { transformPath } from './daemon-path-transform.js';
@@ -73,11 +74,16 @@ export class Relay {
73
74
  // ── WebSocket ─────────────────────────────────────────────────────────
74
75
  ws = null;
75
76
  wsConnecting = false;
76
- reconnectAttempts = 0;
77
+ /** ⚠ PPO-532: resets only after a connection stays open 60 s, not when one opens — see
78
+ * reconnect-backoff.ts. `reconnectAttempts` (read by the daemon's status.json) reads it. */
79
+ backoff = new ReconnectBackoff();
80
+ get reconnectAttempts() {
81
+ return this.backoff.attempts;
82
+ }
77
83
  reconnectTimer = null;
78
84
  /** Watchdog timer + the moment we first noticed we were disconnected. If the
79
85
  * relay stays down past the threshold, the watchdog forces a HARD reconnect
80
- * (terminate any zombie socket + reset backoff + redial). This is the
86
+ * (terminate any zombie socket + redial now; PPO-532 keeps the backoff). This is the
81
87
  * self-heal a HEADLESS / orphaned daemon needs — it has no Mac-app tray
82
88
  * parent to restart it, so a wedged WS (e.g. a half-open socket after system
83
89
  * sleep) would otherwise leave it "stuck reconnecting" forever. */
@@ -388,6 +394,8 @@ export class Relay {
388
394
  return;
389
395
  // Re-enable auto-reconnect in case a previous stop() set it.
390
396
  this.stopped = false;
397
+ // An explicit start (daemon boot, sign-in) is not a retry: no inherited backoff.
398
+ this.backoff.reset();
391
399
  // Validate endpoint against whitelist
392
400
  try {
393
401
  const endpointHost = new URL(this.config.endpoint.replace('wss://', 'https://').replace('ws://', 'http://')).hostname;
@@ -534,6 +542,8 @@ export class Relay {
534
542
  params.set('deviceId', String(this.deviceId).slice(0, 64));
535
543
  const url = `${this.wsBase()}/my/workspace/ws?${params.toString()}`;
536
544
  const ws = new WebSocket(url);
545
+ // This socket's healthy clock (PPO-532); null until it opens.
546
+ let openedAt = null;
537
547
  // Wait for open or first error/close before resolving
538
548
  await new Promise((resolve) => {
539
549
  const done = () => {
@@ -543,7 +553,10 @@ export class Relay {
543
553
  this.ws = ws;
544
554
  this.wsConnecting = false;
545
555
  this.connected = true;
546
- this.reconnectAttempts = 0;
556
+ // ⚠ PPO-532: opening is not recovering. A workspace that runs out of memory while
557
+ // hydrating accepts the socket and drops it seconds later; resetting here made every
558
+ // retry the first (~1 s), a reconnect loop. The backoff resets after 60 s open.
559
+ openedAt = this.backoff.opened();
547
560
  console.log(`[relay] WebSocket connected to ${this.config.endpoint}`);
548
561
  // Notify subscribers that the WebSocket is open so they
549
562
  // can pull fresh state. Wrapped because the callback
@@ -764,12 +777,17 @@ export class Relay {
764
777
  this.heartbeatInterval = null;
765
778
  this.wsPingInterval = null;
766
779
  this.wsWakeDetector = null;
767
- // Reject pending requests
768
- for (const [, pending] of this.pendingRequests) {
780
+ // Reject pending requests.
781
+ // ⚠ CAN2-175 — SAY THE OUTCOME IS UNKNOWN, as the timeout does. A dropped socket loses the REPLY,
782
+ // not the request: a canvas batch carries on and lands on the workspace. A bare "WebSocket closed"
783
+ // read as "nothing happened", the caller resent at once, and could not tell which state was real.
784
+ for (const [requestId, pending] of this.pendingRequests) {
769
785
  clearTimeout(pending.timer);
770
- pending.reject(new Error('WebSocket closed'));
786
+ pending.reject(new Error(`WebSocket closed (code ${code ?? '?'}) before the reply (action=${pending.action ?? '?'}, requestId=${requestId}); outcome unknown: `
787
+ + 'the request may still be running on the workspace or may already have landed. Read its state before resending.'));
771
788
  }
772
789
  this.pendingRequests.clear();
790
+ this.backoff.closed(openedAt);
773
791
  // Always schedule reconnect while we're still configured and not
774
792
  // stopped. Previous guard `if (wasConnected)` broke the chain: if
775
793
  // a reconnect attempt failed to open, the new ws's close handler
@@ -1084,12 +1102,12 @@ export class Relay {
1084
1102
  return reject(new Error('WebSocket not connected'));
1085
1103
  }
1086
1104
  const requestId = Math.random().toString(36).slice(2) + Date.now().toString(36);
1105
+ const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
1087
1106
  const timer = setTimeout(() => {
1088
1107
  this.pendingRequests.delete(requestId);
1089
- const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
1090
1108
  reject(new Error(`Request timeout after ${timeout}ms (action=${action}, requestId=${requestId}); outcome unknown`));
1091
1109
  }, timeout);
1092
- this.pendingRequests.set(requestId, { resolve, reject, timer });
1110
+ this.pendingRequests.set(requestId, { resolve, reject, timer, action });
1093
1111
  // Send both rid (legacy) and requestId (workspace actions) for compatibility
1094
1112
  if (!this.wsSend({ ...msg, rid: requestId, requestId })) {
1095
1113
  clearTimeout(timer);
@@ -1100,28 +1118,21 @@ export class Relay {
1100
1118
  }
1101
1119
  // ── Reconnection ──────────────────────────────────────────────────────
1102
1120
  /**
1103
- * Reconnect with exponential backoff + jitter.
1121
+ * Reconnect with exponential backoff + FULL jitter (PPO-532, reconnect-backoff.ts).
1104
1122
  *
1105
- * Backoff: 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped). Jitter adds
1106
- * 0-25% of the base delay so a fleet of clients reconnecting after a
1107
- * server outage doesn't synchronize into a thundering herd. The cap
1108
- * keeps idle daemons polling at most once a minute.
1123
+ * Delay: uniform over [0, min(30s, 1s·2^attempt)). The ±25% jitter this replaced kept the
1124
+ * daemon socket and its agent sockets — which drop together — reconnecting together. The
1125
+ * attempt count resets only once a connection has stayed open 60 s, so a socket that opens
1126
+ * and dies (an out-of-memory workspace) keeps backing off instead of retrying every second.
1109
1127
  *
1110
- * Single-flight: a reconnect already scheduled is a no-op. The timer
1111
- * is cleared on successful connect (reconnectAttempts → 0) and on
1112
- * stop().
1128
+ * Single-flight: a reconnect already scheduled is a no-op. The timer is cleared by stop()
1129
+ * and forceReconnect().
1113
1130
  */
1114
1131
  scheduleReconnect() {
1115
1132
  if (this.reconnectTimer)
1116
1133
  return;
1117
- const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60_000);
1118
- // Add 0-25% random jitter — `delay * (1 + 0..0.25)` keeps the
1119
- // floor at the exponential value so we never reconnect FASTER
1120
- // than the backoff intends, only slower.
1121
- const jitter = Math.random() * 0.25 * baseDelay;
1122
- const delay = Math.round(baseDelay + jitter);
1123
- this.reconnectAttempts++;
1124
- console.log(`[relay] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.reconnectAttempts})`);
1134
+ const delay = this.backoff.next();
1135
+ console.log(`[relay] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.backoff.attempts})`);
1125
1136
  this.reconnectTimer = setTimeout(() => {
1126
1137
  this.reconnectTimer = null;
1127
1138
  if (this.useWebSocket) {
@@ -1151,6 +1162,10 @@ export class Relay {
1151
1162
  this.disconnectedSince = Date.now();
1152
1163
  return;
1153
1164
  }
1165
+ // ⚠ PPO-532: a scheduled retry means the backoff chain is alive (≤30 s away). Forcing
1166
+ // past it turned a workspace that keeps dropping new sockets back into rapid redials.
1167
+ if (this.reconnectTimer)
1168
+ return;
1154
1169
  if (Date.now() - this.disconnectedSince > maxDownMs) {
1155
1170
  console.warn(`[relay] Watchdog: relay down ${Math.round((Date.now() - this.disconnectedSince) / 1000)}s — forcing hard reconnect`);
1156
1171
  this.disconnectedSince = Date.now();
@@ -1159,7 +1174,8 @@ export class Relay {
1159
1174
  }, 30_000);
1160
1175
  }
1161
1176
  /** Hard reconnect: drop any (possibly zombie) socket, clear pending reconnect
1162
- * state, reset the backoff so the next dial is immediate, then redial. */
1177
+ * state, then redial at once. The attempt count is kept (PPO-532): if this dial
1178
+ * fails too, the next retry continues the backoff instead of restarting at 1 s. */
1163
1179
  forceReconnect() {
1164
1180
  try {
1165
1181
  this.ws?.terminate();
@@ -1172,7 +1188,6 @@ export class Relay {
1172
1188
  clearTimeout(this.reconnectTimer);
1173
1189
  this.reconnectTimer = null;
1174
1190
  }
1175
- this.reconnectAttempts = 0;
1176
1191
  if (this.useWebSocket)
1177
1192
  this.connectWs();
1178
1193
  else
@@ -1182,7 +1197,7 @@ export class Relay {
1182
1197
  const ok = await this.sendHeartbeatHttp();
1183
1198
  if (ok) {
1184
1199
  this.connected = true;
1185
- this.reconnectAttempts = 0;
1200
+ this.backoff.reset(); // legacy HTTP has no socket to watch: a good heartbeat is recovery
1186
1201
  console.log(`[relay] Reconnected (tier: ${this.tier})`);
1187
1202
  if (this.heartbeatInterval)
1188
1203
  clearInterval(this.heartbeatInterval);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * utf8-stream — decode a socket's bytes ONCE PER CHARACTER, not once per chunk (CA-63).
3
+ *
4
+ * ⚠⚠ WHY THIS EXISTS (staging, 2026-10-23). A 402-step `beehaven call canvas-act @build.json` (90,714
5
+ * bytes, the file holding the correct C2 B7) stored ONE text node's middle dot as "��" — the
6
+ * same node, the same character, on every resend — while dozens of identical dots elsewhere in the batch
7
+ * were stored intact. Every IPC reader did `buffer += chunk.toString()`. A local socket hands the reader
8
+ * the stream in pieces (8 KB on macOS, 64 KB on Linux), so any multi-byte character that straddles a
9
+ * piece boundary was decoded as two halves, each half an invalid sequence, each a U+FFFD. Which character
10
+ * lands on a boundary depends only on the bytes before it, so the same payload broke the same character
11
+ * every time. The CLI → daemon direction corrupted what was STORED; the daemon → CLI direction corrupted
12
+ * what a reader was SHOWN (a long scene read could carry U+FFFD the canvas does not have).
13
+ *
14
+ * A `StringDecoder` holds an incomplete trailing sequence until its remaining bytes arrive, so the text is
15
+ * exactly what one decode of the whole stream would give. One decoder per stream (it carries state), and
16
+ * never shared between two sockets.
17
+ */
18
+ import { StringDecoder } from 'node:string_decoder';
19
+ /** A stateful UTF-8 decoder for ONE byte stream. A string chunk (a socket with an encoding set) passes through. */
20
+ export function utf8Stream() {
21
+ const decoder = new StringDecoder('utf8');
22
+ return (chunk) => (typeof chunk === 'string' ? chunk : decoder.write(chunk));
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beehaven",
3
- "version": "0.8.35",
3
+ "version": "0.8.37",
4
4
  "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
5
  "private": false,
6
6
  "type": "module",
@@ -46,6 +46,7 @@ const SOURCES = {
46
46
  'clearly-docs': 'apps/mcp-server/plugin/skills/clearly-docs/SKILL.md',
47
47
  'clearly-init': 'apps/mcp-server/plugin/skills/clearly-init/SKILL.md',
48
48
  'clearly-workflows': 'apps/mcp-server/plugin/skills/clearly-workflows/SKILL.md',
49
+ 'clearly-artifacts': 'apps/mcp-server/plugin/skills/clearly-artifacts/SKILL.md',
49
50
  };
50
51
 
51
52
  let missing = 0;
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: clearly-artifacts
3
+ description: >-
4
+ Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so: the connector cannot make a running page.
5
+ Put a self-contained interactive HTML page into a Clearly workspace, the way a
6
+ Claude artifact works: an explainer, an animation, a small tool, a calculator, a
7
+ chart that runs. Covers which of the three HTML homes runs scripts (a SITE does,
8
+ a canvas ARTIFACT card does but cannot be clicked, a NOTE never does), the exact
9
+ CLI calls, the sandbox limits, updating, filing in a project, how a person opens
10
+ it, and QA. Use when asked to publish, host or add an HTML page, demo or animation
11
+ to the workspace. Triggers: "make an artifact", "publish this HTML", "add this page /
12
+ animation / explainer to the workspace", "host this demo", "interactive page",
13
+ "put this HTML on the canvas", "why doesn't my page's script run".
14
+ ---
15
+
16
+ # Clearly artifacts — a running HTML page in the workspace
17
+
18
+ Sign in first (`clearly-agent`). `beehaven env` must name the environment you mean; staging and
19
+ production hold different workspaces.
20
+
21
+ ## 1. Pick the home — only two of the three run code
22
+
23
+ | Home | Make it with | Scripts and CSS animation | Clickable | Search, history | Opens at |
24
+ |---|---|---|---|---|---|
25
+ | **Site** | `compose-upload-init` → `file-teleport-inline` → `compose-upload-finalize` | run | yes | not in `document-search`; no version history; a re-upload overwrites | Recents or project card → `/compositions/detail?id=<id>` |
26
+ | **Canvas artifact** | `canvas-act` `canvas.add-artifact` | run | **no**: the card never takes pointer events, so it pans like a picture | no; it is part of the canvas and is deleted with it | the canvas, `/c/<compositionId>` |
27
+ | **Note** | `document-create {html}`; `page-write` (in-app chat only) | **never**: it opens in the document editor, which drops `<script>`, `<style>` and inline `<svg>` | n/a | yes, both | `/documents/<id>` |
28
+
29
+ - An interactive page someone opens, clicks through or comes back to is a **site**. This is the
30
+ Claude-artifact equivalent and the default.
31
+ - A page that only needs to be **seen** beside other cards (a looping animation, a live chart in a
32
+ review board) is a **canvas artifact**. Buttons inside it cannot be pressed.
33
+ - Prose the team will read, search and edit is a **note**. Never put a script in a note: it
34
+ is stored, and then nothing ever runs it. Editing the note in the app rewrites its body.
35
+
36
+ No home gives a public link that runs. Every share link (`/r/…`) is served with
37
+ `script-src 'none'`, and a shared composition renders as a static picture. Say so when someone asks to
38
+ publish an interactive page outside the workspace.
39
+
40
+ ## 2. Write it self-contained
41
+
42
+ Both running homes inject the page with `srcdoc` into `<iframe sandbox="allow-scripts">`, with no
43
+ `allow-same-origin`. The page gets an opaque origin (`window.origin === "null"`).
44
+
45
+ - **One file.** `<!doctype html>`, `<meta name="viewport" content="width=device-width, initial-scale=1">`,
46
+ CSS in `<style>`, JS in `<script>`, art as inline SVG, images and fonts as `data:` URIs,
47
+ system font stacks. Make no network requests and load no CDN scripts.
48
+ - **The sandbox blocks:** `localStorage`, `sessionStorage`, cookies and IndexedDB (accessing them
49
+ throws), `alert`/`confirm`/`prompt`, form submission, popups, `window.open` and top-level
50
+ navigation. Keep state in memory, and wrap any storage access in `try`.
51
+ - **A site's other files are rarely reached.** The preview rewrites only `src="…"`/`href="…"`
52
+ in the entry HTML that name a build file exactly (up to 60 files). It does not rewrite `url()`
53
+ inside CSS, `import`, or `fetch` of a sibling file. Inline them.
54
+ - **Size:** a canvas artifact is at most 512 KB. A site file is at most 6 MB inline. Over the CLI
55
+ WebSocket, keep a single `beehaven call` under about 900 KB, or use `beehaven compose push`,
56
+ which switches to a presigned upload.
57
+ - **Motion:** honour `@media (prefers-reduced-motion: reduce)`. A canvas card lays the page out
58
+ at `designWidth` (default 1280) and grows to the document's full height. A `100vh` layout
59
+ makes it grow again on every fit, so size sections in px or by content.
60
+
61
+ ## 3. Make it (CLI)
62
+
63
+ Build JSON payloads with a script and pass them as `@file`. Do not hand-escape HTML into a shell
64
+ argument.
65
+
66
+ **Site, from one file** (one `file-teleport-inline` per file; `entryPoint` and each path are
67
+ relative to `build/`):
68
+
69
+ ```sh
70
+ beehaven call compose-upload-init '{"name":"MCP tokens explained","sourceType":"static","entryPoint":"index.html","projectId":"<projectId>"}'
71
+ # → { "id": "cmp_…", "created": true }
72
+ node -e 'const fs=require("fs"),b=fs.readFileSync("page.html");fs.writeFileSync("up.json",JSON.stringify({key:"compositions/<id>/build/index.html",composition:true,name:"index.html",mimeType:"text/html",size:b.length,data:b.toString("base64")}))'
73
+ beehaven call file-teleport-inline @up.json
74
+ beehaven call compose-upload-finalize '{"compositionId":"<id>","totalBytes":<bytes>,"entryPoint":"index.html"}'
75
+ beehaven call composition-fs '{"compositionId":"<id>","action":"list"}' # one row per file, sizes match
76
+ ```
77
+
78
+ Both parts of the upload are load-bearing. A `key` outside `compositions/<id>/build/`, or a
79
+ missing `composition:true`, still answers ok, and the site then renders nothing.
80
+
81
+ **Site, from a folder** that holds only the page and its assets:
82
+ `cd <folder> && beehaven compose create --name "<title>" && beehaven compose push`. Every file in
83
+ the folder is uploaded. A Vite, Next, Astro or SvelteKit `package.json` there runs its build first.
84
+ To push to an existing site, run `beehaven compose push --composition <id>` from the folder; there
85
+ is no `--dir` flag. `compose create` cannot file the site, so file it afterwards (§4). Ignore the
86
+ URL that `push` prints, because it does not open the site.
87
+
88
+ **Canvas artifact** (create or reuse the canvas with `composition-create {title, projectId}`):
89
+
90
+ ```sh
91
+ # payload: {"compositionId":"<canvasId>","action":"canvas.add-artifact",
92
+ # "args":{"html":"<!doctype html>…","title":"…","name":"explainer","x":0,"y":0,"w":1000,"designWidth":1000}}
93
+ beehaven call canvas-act @artifact.json
94
+ ```
95
+
96
+ `w` is the card's width on the board; `designWidth` is the viewport the page lays out at. Use 390
97
+ for a phone design. `canvas.place {kind:"site", id}` puts an existing site on a canvas as an
98
+ artifact card. It copies the entry HTML at that moment: later re-uploads do not reach the card,
99
+ and the site's other files do not load.
100
+
101
+ **Connector only (MCP):** no tool makes a site or an artifact. `clearly_file_upload` cannot choose
102
+ a site path, and `clearly_create` stores a document body as markdown. `clearly_canvas_place
103
+ {target, kind:"site", id}` can place a site that already exists. Otherwise, tell the person the
104
+ running page needs the CLI.
105
+
106
+ ## 4. Update, file, link
107
+
108
+ - **Update a site:** upload to the same `key`, then `compose-upload-finalize` again. Only someone
109
+ who may manage the site (its creator) can overwrite it. A file you stop shipping stays in the
110
+ build. Agents are refused `composition-fs … "action":"delete"`, so ask the person to remove it,
111
+ or stop referencing it. There is no history, so keep the source in the repository or a note.
112
+ - **Update a canvas artifact:** there is no edit verb. Add the new version at the old card's
113
+ `x`/`y`/`w`, then remove the old card with `canvas.delete {ids:[…]}`.
114
+ - **File:** `projectId` on `compose-upload-init` (to file later, call it again with
115
+ `{"compositionId","projectId"}`). For a canvas, `composition-create {projectId}`. For a note,
116
+ `document-create {projectId}` or `document-update {id, projectId}`. Unfiled agent work is
117
+ private to its author.
118
+ - **Link it to the ticket:** `beehaven call work-link '{"ticketId":"…","kind":"design","refKind":"composition","refId":"<id>"}'`.
119
+
120
+ ## 5. Hand it over
121
+
122
+ Give the person the place to click, not just an id.
123
+
124
+ - A site: "Recents (or the project) → the card", which opens `/compositions/detail?id=<id>`.
125
+ Do not hand over `/c/<siteId>`. That is what `beehaven open`, ticket links and workspace-ref
126
+ chips build for a site, and it opens an empty canvas instead of the page.
127
+ - A canvas artifact: `/c/<canvasId>`. Double-clicking the card opens its stored file in a new tab,
128
+ which shows source text or an access error, never the running page.
129
+
130
+ ## 6. QA — open it, read the console, check mobile
131
+
132
+ 1. **Render it in the real frame model** before uploading. Use Playwright: an
133
+ `<iframe sandbox="allow-scripts" srcdoc>` at 1280 and 390 wide. Confirm there are no console
134
+ errors and no requests from the frame, that `scrollWidth === clientWidth`, that the animation
135
+ advances by itself, and that its controls respond.
136
+ 2. **Read the stored copy back:** check that `composition-fs list` shows the size you sent.
137
+ 3. **See the server render it.** For a canvas artifact, run
138
+ `canvas-act {"compositionId":"<canvasId>","action":"canvas.review","args":{"ids":["<artifactId>"]}}`.
139
+ It returns a filmstrip of four frames across about 5.4 s; inspect the PNG. For a site, place it on a scratch canvas
140
+ (`canvas.place {kind:"site", id}`), review that card the same way, and then archive the scratch
141
+ canvas (`composition-set-archived {id, archived:true}`).
142
+ 4. Report what you checked and what you could not check. Opening the signed-in page itself needs a
143
+ person's session.
@@ -32,7 +32,7 @@ For a new composition or substantial redesign, use the workflow below.
32
32
 
33
33
  Keep the requested scope. A landing concept with a logo needs its page layouts and a usable
34
34
  logo; add application boards or a full guideline system only when the brief calls for them.
35
- Read only relevant references. Save large tool responses once and print compact summaries.
35
+ Read only relevant references. Save large tool responses once (`beehaven call … --out file.json`) and print compact summaries.
36
36
 
37
37
  ## The studio bar — what "done" means, every time
38
38
 
@@ -46,7 +46,8 @@ A composition is done when every requested artboard meets all five conditions:
46
46
  remains: text colliding or covered, contrast under WCAG (4.5:1 body, 3:1 display),
47
47
  type below the artboard's legible floor, missing media, text overflowing its box,
48
48
  placeholder copy. Fix it. For an intentional finding, use
49
- `accept:[{check,ids:[node IDs],reason}]`. Accepted findings stay visible and return `look`.
49
+ `accept:[{check,ids:[node IDs],reason}]`; one rule may name every layer with the same
50
+ deliberate finding. Accepted findings stay visible and return `look`.
50
51
  Legacy `ignore` can suppress multiple checks and is not an unconditional pass.
51
52
  2. **Every `look` finding was looked at**: type-size and palette sprawl, near-miss
52
53
  alignment, copy against the trim, text over imagery. Act on it or say why not.
@@ -117,8 +118,9 @@ Read `clearly-agent` before your first workspace call. Pin the intended workspac
117
118
  sign in once. Do not switch a user's explicit workspace to `home`.
118
119
 
119
120
  Your surface: the shell. The `beehaven` examples in this skill are literal commands to
120
- run; they are not shapes to translate into an MCP canvas-action tool. MCP artifact tools
121
- can read and edit artifact records, while canvas-specific actions use the CLI.
121
+ run; they are not shapes to translate into an MCP canvas-action tool. The Clearly connector
122
+ has its own canvas tools for agents without a shell (batched per kind of step, see
123
+ authoring.md); in a shell the CLI's mixed `canvas-act` batch takes fewer calls.
122
124
 
123
125
  ```sh
124
126
  beehaven env
@@ -156,7 +158,7 @@ builds on the last and can be reviewed alone. Build ONE commit per canvas-act ba
156
158
  `canvas.review` of the artboard and carries
157
159
  `"ticket":{"id":"<key>","status":"done","resolution":"<what changed>"}` — it lands only if that
158
160
  review is clean or look; `result.ticket` says whether it did, and the ticket logs the canvas
159
- revision and the layers the commit changed. Auto-layout, gradients, shadows, drawing and placeholders:
161
+ revision, the layers the commit changed and a link to its frame. Auto-layout, gradients, shadows, drawing and placeholders:
160
162
  see "Technique" in [authoring.md](authoring.md).
161
163
 
162
164
  `composition-board-get {compositionId}` reads the plan back. `design-start` with a project, ticket or
@@ -279,7 +281,7 @@ a detail view catches collisions, wrapping, cropping and legibility.
279
281
  The bundled helper saves pixels directly to disk so base64 does not fill your context:
280
282
 
281
283
  ```sh
282
- node <skill-directory>/review.mjs <composition-id> <frame-id-or-unique-name> /tmp/canvas-review/01
284
+ node <skill-directory>/review.mjs <composition-id> <frame-id-or-unique-name> canvas-review/01
283
285
  # Open the returned PNG using your image-viewing tool; read the JSON findings.
284
286
  ```
285
287
 
@@ -300,9 +302,15 @@ The helper prints the verdict and every finding. Work until the verdict is `clea
300
302
  use a real frame ID with `--perceive` for a preview-only check and run the text measurement
301
303
  and audit separately. A failed or missing render means visual verification is incomplete.
302
304
 
305
+ A deliberate finding (say why in the handoff): write the findings you are keeping to a file and
306
+ pass it to the same helper. They stay visible in the report and return `look`.
307
+
308
+ ```json
309
+ [{"check":"text-over-image","ids":["<text-id>","<image-id>"],"reason":"Intentional type over this photograph; rendered contrast was inspected."}]
310
+ ```
311
+
303
312
  ```sh
304
- # A deliberate finding — say why in the handoff:
305
- beehaven call canvas-act '{"compositionId":"<id>","action":"canvas.review","args":{"ids":["<frame>"],"accept":[{"check":"text-over-image","ids":["<text-id>","<image-id>"],"reason":"Intentional type over this photograph; rendered contrast was inspected."}]}}'
313
+ node <skill-directory>/review.mjs <composition-id> <frame> canvas-review/02 --accept accepted.json
306
314
  ```
307
315
 
308
316
  Asset storage is a reference-aware pipeline: an SVG may have `svgRef` instead of inline markup.
@@ -38,15 +38,34 @@ add('canvas.update-nodes', { ids: ['opening-title', 'opening-deck'], patch: { la
38
38
  add('canvas.auto-layout', { id: 'hero-copy', direction: 'v', gap: 24, sizingH: 'fixed', sizingV: 'hug' });
39
39
  add('canvas.auto-layout', { id: '01-opening', direction: 'v', gap: 64, padding: grid.margin, sizingH: 'fixed', sizingV: 'hug' });
40
40
  // Continue with the brief's central visual, supporting evidence and footer.
41
- writeFileSync('/tmp/canvas-build.json', JSON.stringify({ compositionId, batch }));
41
+ writeFileSync('canvas-build.json', JSON.stringify({ compositionId, batch }));
42
42
  ```
43
43
 
44
44
  ```sh
45
45
  beehaven call composition-create '{"title":"Project — concept","projectId":"<project-id>"}'
46
46
  node build.mjs <returned-id>
47
- beehaven call canvas-act @/tmp/canvas-build.json > /tmp/canvas-build-result.json
47
+ beehaven call canvas-act @canvas-build.json --brief
48
48
  ```
49
49
 
50
+ `--brief` prints a short receipt instead of the full JSON:
51
+ - step counts by kind;
52
+ - layers added, changed and removed;
53
+ - every failed step with its error;
54
+ - rollbacks and skips;
55
+ - lint to fix and review verdicts.
56
+
57
+ The exit code stays the same. Use it for routine batches instead of piping the JSON through a
58
+ summariser of your own. Drop it when you need a step's full value.
59
+
60
+ Keep builders, payloads and results in your **working directory**:
61
+ - Write files with your file-writing tool, not shell heredocs or `>` redirects into `/tmp`.
62
+ - Run each `beehaven` command on its own rather than chained with `&&`, `;`, `| tee` or `| head`.
63
+ - Save a large result with `beehaven call <action> '…' --out scene.json` (add `--brief` for the
64
+ receipt), not `> file`: the whole JSON goes to the file and only a line reaches the console.
65
+
66
+ Agent hosts that gate shell commands (Claude Code, Codex) refuse heredocs containing JSON and
67
+ compound pipelines outright. One recorded run lost four tool calls that way (MCP2-20).
68
+
50
69
  Check `ok`, each `results[].ok`, `count`, `skipped` and `rolledBack`. A failed headless
51
70
  batch may roll back its scene writes. Re-read the scene before deciding what to retry;
52
71
  do not blindly resend only skipped operations. Mixed live/headless actions may have
@@ -155,6 +174,7 @@ The same rules the in-app assistant works to (canvas-catalog returns them as `te
155
174
  - COMPUTED GEOMETRY: shapes from math (radial petals, spirals, grids) are computed and drawn in ONE call: canvas-act { compositionId, script } runs JavaScript with geo.petal/circle/arc/star/polygon/spiral/smooth/rotate/radial/grid/bounds (degrees, 0 = 3 o'clock, clockwise) and a read-only scene list, then applies the { action, args } steps it returns. Never copy numbers from one tool result into another call. A new top-level artboard goes beside or below what scene already holds (or leave out both x and y), never at 0,0 over an existing one.
156
175
  - SHEETS: several concepts or variants go on canvas.sheet { name, title, subtitle, cards or count, cols, cardW, cardH }: a live wrap grid of card frames that own their captions, so rows never overlap. Draw into a card with parentId = its id.
157
176
  - MEASURE: answer spacing, overlap and alignment questions with canvas.measure { frame } or { ids } and quote its numbers, never from memory or a preview.
177
+ - REFERENCES: canvas.link {id:<exact frame/item id>} returns a stable URL and Markdown for tickets, documents, comments and inbox refs. Use the returned link instead of a screenshot or a guessed canvas URL. It preserves the parent canvas permissions; rename/move retains the layer id.
158
178
  - PLACEHOLDERS: until real imagery exists, draw a native placeholder at the final size in a muted brand tone, never a flat accent block: a rect with the slot radius plus a simple motif (an ellipse sun, a vector.create mountain), grouped with canvas.group and named "<section> image placeholder", with any label in its own text node.
159
179
  - SECTIONS: one frame per page section, named exactly as its ticket says, inside the page artboard. Build a section in one canvas-act batch that ends with canvas.review of the artboard and carries ticket: { id: "<its key>", status: "done", resolution: "<what changed>" }. The ticket moves only when that review is clean or look; result.ticket says whether it did. Then start the next section.
160
180
 
@@ -215,15 +235,13 @@ the backing shows through as dark wedges at the corners.
215
235
 
216
236
  ### Images from MCP
217
237
 
218
- The artifact MCP tools do not upload local binary files or dispatch `canvas-act`.
219
- For an image already reachable by an absolute URL, read the composition with
220
- `clearly_read {type:"composition",target:<id>}`; append an image node
221
- `{id,type:"image",src,x,y,w,h,fit:"contain",parentId?}` to the returned complete node
222
- array; then call `clearly_replace {type:"composition",target:<id>,content:<JSON array>,
223
- expectedRev:<canvasNodesRev>}`. Preserve every existing node. A stale revision is a
224
- merge prompt, never a reason to drop `expectedRev`. `clearly_edit` can change an
225
- existing image's `src`, `fit` or geometry, but cannot create a node. For a local file,
226
- use the CLI upload path first; an MCP-only client needs a URL it can already access.
238
+ A connector-only agent places an image already reachable by an absolute URL (or a
239
+ `data:` URI) as an `image` node in `clearly_canvas_add_nodes`:
240
+ `{id,type:"image",name,src,x,y,w,h,fit:"contain",parentId?}`. Use PNG or JPEG: the headless
241
+ review cannot draw WebP yet and does not say so (CAN2-188), so a WebP image looks blank in the
242
+ review PNG while the editor shows it. `clearly_canvas_update_nodes`
243
+ changes an existing image's `src`, `fit` or geometry. For a local file in a shell, use the
244
+ CLI upload path first.
227
245
 
228
246
  Current headless rendering can resolve images, including workspace media. A blank image
229
247
  can indicate fetch, decoding or budget failure; check its source and inspect a live view.
@@ -236,8 +254,8 @@ design. Verify the returned artifact in a browser at realistic viewport sizes.
236
254
  ## Review and scoped perception
237
255
 
238
256
  ```sh
239
- beehaven call canvas-act '{"compositionId":"<id>","action":"canvas.review","args":{"ids":["<frame-id>"]}}' > /tmp/review.json
240
- beehaven call canvas-perceive '{"compositionId":"<id>","includePixels":true,"fitNodeIds":["<frame-id>"],"pixelScale":1}' > /tmp/perceive.json
257
+ beehaven call canvas-act '{"compositionId":"<id>","action":"canvas.review","args":{"ids":["<frame-id>"]}}' --brief
258
+ beehaven call canvas-perceive '{"compositionId":"<id>","includePixels":true,"fitNodeIds":["<frame-id>"],"pixelScale":1}' > perceive.json
241
259
  ```
242
260
 
243
261
  Use the bundled `review.mjs` to decode the PNG and keep base64 out of the conversation.
@@ -268,7 +286,12 @@ MCP's artifact tools are split by operation:
268
286
  node array — pass `expectedRev` from the read.
269
287
  - `clearly_edit` patches existing nodes with `changes:{ids,patch}`.
270
288
 
271
- Canvas action dispatch and targeted pixels use the CLI. Do not invent
272
- `clearly_canvas_act`/`clearly_canvas_perceive` tools. If only MCP is available, use the
273
- artifact schemas for supported edits and state that pixel review remains unverified.
274
- Do not claim the design was visually checked from scene JSON alone.
289
+ The connector also has native canvas tools for agents without a shell, batched per kind of
290
+ step rather than mixed (MCP2-22): `clearly_canvas_add_nodes` builds a section per call (up to
291
+ 300 frames, shapes, text, pen paths and groups of its own new layers),
292
+ `clearly_canvas_update_nodes {edits:[{ids,patch}]}` revises many layers at once,
293
+ `clearly_canvas_measure_text {texts:[…]}` measures several lines, and
294
+ `clearly_canvas_review` returns the saved artboard as an MCP image to inspect. There is no
295
+ `clearly_canvas_act`: a mixed batch is the CLI's `canvas-act`. In a shell, use the CLI; it
296
+ is fewer calls for the same scene. Do not claim the design was visually checked from scene
297
+ JSON alone.
@@ -123,14 +123,35 @@ export async function saveReviewPixels(report, prefix, fetchImage = fetch) {
123
123
  return saveReview({ ...report, preview: { ...report.preview, dataUrl: `data:image/png;base64,${bytes.toString('base64')}` } }, prefix);
124
124
  }
125
125
 
126
+ /**
127
+ * `--accept <findings.json>`: findings you have looked at and are keeping, each
128
+ * `{ check, ids: [node IDs], reason }`. They stay visible in the report and return `look`.
129
+ * Without this, a deliberate overlap (steam rising out of a bowl) could only be accepted by
130
+ * hand-writing the canvas.review call — agents did exactly that (MCP2-20).
131
+ */
132
+ export function takeAccept(argv) {
133
+ const i = argv.indexOf('--accept');
134
+ if (i < 0) return { argv, accept: undefined };
135
+ const file = argv[i + 1];
136
+ if (!file || file.startsWith('--')) throw new Error('--accept needs a JSON file: [{"check":"text-over-image","ids":["…"],"reason":"…"}]');
137
+ const accept = JSON.parse(readFileSync(file, 'utf8'));
138
+ if (!Array.isArray(accept) || !accept.every(a => a && typeof a.check === 'string' && Array.isArray(a.ids) && a.ids.length && typeof a.reason === 'string' && a.reason.trim())) {
139
+ throw new Error('--accept file must be an array of { check, ids: [node IDs], reason } with a non-empty reason.');
140
+ }
141
+ return { argv: argv.filter((_, j) => j !== i && j !== i + 1), accept };
142
+ }
143
+
126
144
  export async function main(argv = process.argv.slice(2)) {
145
+ const taken = takeAccept(argv);
146
+ argv = taken.argv;
147
+ const accept = taken.accept ? { accept: taken.accept } : {};
127
148
  if (argv.includes('--many')) {
128
149
  const args = argv.filter(a => a !== '--many');
129
- if (args.length !== 3) throw new Error('Usage: node review.mjs --many <compositionId> <artboard-ids.json> <output-directory>');
150
+ if (args.length !== 3) throw new Error('Usage: node review.mjs --many <compositionId> <artboard-ids.json> <output-directory> [--accept findings.json]');
130
151
  const [compositionId, manifest, directory] = args;
131
152
  const ids = JSON.parse(readFileSync(manifest, 'utf8'));
132
153
  if (!Array.isArray(ids) || !ids.length) throw new Error('The manifest must be a non-empty artboard ID/name array.');
133
- const response = rpc('canvas-act', { compositionId, action: 'canvas.review-many', args: { ids, preview: 'files' } });
154
+ const response = rpc('canvas-act', { compositionId, action: 'canvas.review-many', args: { ids, preview: 'files', ...accept } });
134
155
  const reports = response.results?.[0]?.value?.reviews;
135
156
  if (!Array.isArray(reports) || reports.length !== ids.length) throw new Error('Incomplete review-many result; do not claim all artboards passed.');
136
157
  const saved = [];
@@ -140,7 +161,7 @@ export async function main(argv = process.argv.slice(2)) {
140
161
  }
141
162
  const legacy = argv.includes('--perceive');
142
163
  const args = argv.filter((a) => a !== '--perceive');
143
- if (args.length !== 3) throw new Error('Usage: node review.mjs <compositionId> <frame-id-or-name> <output-prefix> [--perceive]\n--perceive: older workers, preview only; pass a frame ID.');
164
+ if (args.length !== 3) throw new Error('Usage: node review.mjs <compositionId> <frame-id-or-name> <output-prefix> [--accept findings.json] [--perceive]\n--perceive: older workers, preview only; pass a frame ID.');
144
165
  const [compositionId, id, prefix] = args;
145
166
  let report;
146
167
  if (legacy) {
@@ -150,7 +171,7 @@ export async function main(argv = process.argv.slice(2)) {
150
171
  report = { preview: response.room?.pixels, visualReviewRequired: true,
151
172
  limitations: ['Preview-only compatibility mode. Text metrics and scoped audit were not run.'] };
152
173
  } else {
153
- const response = rpc('canvas-act', { compositionId, action: 'canvas.review', args: { ids: [id], pixelScale: 1 } });
174
+ const response = rpc('canvas-act', { compositionId, action: 'canvas.review', args: { ids: [id], pixelScale: 1, ...accept } });
154
175
  report = response.results?.[0]?.value;
155
176
  if (!report?.scope) throw new Error('canvas.review unavailable. Check canvas-catalog; use --perceive for an explicitly preview-only check on an older worker.');
156
177
  }
package/skills/index.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "project-plans.md",
18
18
  "review.mjs"
19
19
  ],
20
- "sha": "e23e25bd46b02d9c"
20
+ "sha": "4b478740d8ef22c2"
21
21
  },
22
22
  {
23
23
  "name": "agent-home",
@@ -118,5 +118,12 @@
118
118
  "SKILL.md"
119
119
  ],
120
120
  "sha": "b9622f21173bab0d"
121
+ },
122
+ {
123
+ "name": "clearly-artifacts",
124
+ "files": [
125
+ "SKILL.md"
126
+ ],
127
+ "sha": "14434d4904da91a3"
121
128
  }
122
129
  ]