beehaven 0.8.36 → 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.
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
53
53
  * ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
54
54
  * carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
55
55
  */
56
- export const BUILD_SHA = '31746b91dfbf';
56
+ export const BUILD_SHA = '7674ddaca41b';
57
57
  /** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
58
- export const BUILD_TIME = '2026-10-29T05:50Z';
58
+ export const BUILD_TIME = '2026-10-29T21:35Z';
59
59
  /**
60
60
  * The build identity as one short string, for `--version`.
61
61
  *
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `beehaven call <action> … --brief` — a receipt a person (or an agent's context window) can read.
3
+ *
4
+ * ⚠⚠ WHY THIS EXISTS. A `canvas-act` batch answers with every step's full value plus the lint of
5
+ * every touched artboard — routinely tens of kilobytes. Agents learned to pipe it into a summariser
6
+ * they wrote on the spot (`… 2>&1 | node summ.mjs`), and in the first recorded canvas take (MCP2-20)
7
+ * that improvisation cost four failed tool calls: shell pipes and `/tmp` redirects need approval in
8
+ * a headless agent, and the summariser crashed on the stderr breadcrumb it was fed. The receipt the
9
+ * agent wanted is printed here instead, from the same result, with the exit code unchanged.
10
+ *
11
+ * ⚠ IT DROPS NOTHING THAT DECIDES THE NEXT STEP: failed steps (with their error), rollbacks and
12
+ * skips, lint findings that need fixing, a review's verdict and blocking count. What it leaves out
13
+ * is bulk — per-step echoes, previews, font metadata. `--json` still prints the whole payload.
14
+ */
15
+ const clip = (s, n) => {
16
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
17
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
18
+ };
19
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
20
+ /** `canvas.create-node` → `create-node`; `frame.create` stays as written. */
21
+ const verb = (a) => (a.startsWith('canvas.') ? a.slice(7) : a);
22
+ export function briefReceipt(action, result) {
23
+ const r = (result && typeof result === 'object' ? result : { value: result });
24
+ const error = typeof r.error === 'string' && r.error ? r.error : null;
25
+ /**
26
+ * MCP2-28 (codex review) — A FAILED BATCH IS STILL A BATCH. This returned the one-line refusal for
27
+ * any `ok:false`, before looking at `results`, so a rolled-back canvas-act (step 2 of 2 refused)
28
+ * printed a truncated error with no step number and no "rolled back". The batch receipt is read
29
+ * first; the top-level failure is kept on it, so the exit code still says it failed.
30
+ */
31
+ if (Array.isArray(r.results) && r.results.length) {
32
+ const b = batchReceipt(action, r);
33
+ if (error || r.ok === false) {
34
+ b.ok = false;
35
+ b.lines[0] = b.lines[0].replace(/^✓/, '✗');
36
+ const why = clip(error ?? r.message ?? r.code ?? '', 200);
37
+ // Not repeated when a failed step already says it (the step line clips at 140, this at 200).
38
+ if (why && !b.lines.some((l) => l.includes(why.slice(0, 60))))
39
+ b.lines.splice(1, 0, ` ${why}`);
40
+ }
41
+ return b;
42
+ }
43
+ if (error || r.ok === false) {
44
+ return { ok: false, lines: [`✗ ${action} · ${clip(error ?? r.message ?? r.code ?? 'refused', 160)}`] };
45
+ }
46
+ if (Array.isArray(r.results))
47
+ return batchReceipt(action, r);
48
+ return { ok: true, lines: [`✓ ${action}${describeValue(r)}`] };
49
+ }
50
+ function batchReceipt(action, r) {
51
+ const steps = r.results;
52
+ const failed = steps.map((s, i) => ({ s, i })).filter(({ s }) => s?.ok === false);
53
+ const lines = [];
54
+ // What ran: a review or a set of measurements reads better as its result than as a step count.
55
+ const reviews = steps.filter((s) => /canvas\.review/.test(s?.action) && s?.ok !== false);
56
+ const measures = steps.filter((s) => s?.action === 'canvas.measure-text' && s?.ok !== false);
57
+ const counts = new Map();
58
+ for (const s of steps)
59
+ counts.set(verb(s?.action ?? '?'), (counts.get(verb(s?.action ?? '?')) ?? 0) + 1);
60
+ const mix = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([a, n]) => (n > 1 ? `${a} ×${n}` : a)).join(', ');
61
+ let added = 0, changed = 0, removed = 0, sawDelta = false;
62
+ for (const s of steps) {
63
+ const d = s?.value?.canvasChanges;
64
+ if (d && typeof d === 'object' && !Array.isArray(d)) {
65
+ sawDelta = true;
66
+ added += Number(d.added || 0);
67
+ changed += Number(d.changed || 0);
68
+ removed += Number(d.removed || 0);
69
+ }
70
+ }
71
+ const rev = r.canvasNodesRev ?? [...steps].reverse().find((s) => s?.value?.canvasNodesRev != null)?.value?.canvasNodesRev;
72
+ const head = [`${failed.length ? '✗' : '✓'} ${action}`, `${plural(steps.length, 'step')}${mix ? ` (${mix})` : ''}`];
73
+ if (sawDelta && added + changed + removed)
74
+ head.push(`+${added} ~${changed} −${removed} layers`);
75
+ if (rev != null)
76
+ head.push(`rev ${rev}`);
77
+ if (r.rolledBack)
78
+ head.push('rolled back');
79
+ if (r.partial)
80
+ head.push('partly applied');
81
+ if (Array.isArray(r.skipped) ? r.skipped.length : r.skipped)
82
+ head.push(`${Array.isArray(r.skipped) ? r.skipped.length : r.skipped} skipped`);
83
+ lines.push(head.join(' · '));
84
+ for (const { s, i } of failed.slice(0, 3)) {
85
+ lines.push(` ✗ step ${i + 1} ${verb(s.action ?? '?')}: ${clip(s.error ?? s.value?.error ?? 'failed', 140)}`);
86
+ }
87
+ if (failed.length > 3)
88
+ lines.push(` … ${failed.length - 3} more failed steps`);
89
+ for (const m of measures.slice(0, 6)) {
90
+ const v = m.value ?? {};
91
+ lines.push(` ${Math.round(v.width ?? 0)}×${Math.round(v.height ?? 0)} · ${plural(v.lineCount ?? 1, 'line')}${v.exact === false ? ' · estimated' : ''} — ${v.family ?? ''} ${v.size ?? ''}/${v.fontWeight ?? ''}`.trimEnd());
92
+ }
93
+ for (const rv of reviews) {
94
+ const v = rv.value ?? {};
95
+ const reports = Array.isArray(v.reviews) ? v.reviews : [v];
96
+ for (const rep of reports) {
97
+ const blocking = rep.blocking?.craft ?? 0;
98
+ const where = rep.scope?.name ?? rep.scope?.ids?.[0] ?? '';
99
+ lines.push(` review${where ? ` ${clip(where, 40)}` : ''}: ${rep.verdict ?? 'unknown'}${blocking ? ` · ${plural(blocking, 'blocking finding')}` : ''}`);
100
+ for (const f of (rep.craft?.findings ?? []).filter((x) => x.severity === 'warn').slice(0, 3)) {
101
+ lines.push(` ${f.check}: ${clip(f.detail, 120)}`);
102
+ }
103
+ }
104
+ }
105
+ // Lint of the touched artboards: what must be fixed next, never the whole report.
106
+ for (const d of (Array.isArray(r.diagnostics) ? r.diagnostics : [])) {
107
+ const errors = d.errors ?? [];
108
+ if (!errors.length && d.verdict !== 'fix')
109
+ continue;
110
+ lines.push(` lint ${clip(d.artboard ?? d.id, 40)}: ${d.verdict ?? 'fix'}`);
111
+ for (const e of errors.slice(0, 3))
112
+ lines.push(` ${clip(e, 140)}`);
113
+ if (errors.length > 3 || d.moreErrors)
114
+ lines.push(` … ${(errors.length - 3 > 0 ? errors.length - 3 : 0) + (d.moreErrors ?? 0)} more`);
115
+ }
116
+ const clean = (Array.isArray(r.diagnostics) ? r.diagnostics : []).filter((d) => d.verdict && d.verdict !== 'fix');
117
+ if (clean.length)
118
+ lines.push(` lint ${[...new Set(clean.map((d) => d.verdict))].join('/')} on ${plural(clean.length, 'artboard')}`);
119
+ /**
120
+ * MCP2-25 — THE CARRIED TICKET'S OUTCOME DECIDES THE NEXT STEP TOO. A batch carrying `ticket` moves
121
+ * it only when its review passes; the receipt said nothing either way, so an agent could not tell a
122
+ * done commit from one the review held back.
123
+ */
124
+ const t = r.ticket;
125
+ if (t && typeof t === 'object') {
126
+ if (t.applied)
127
+ lines.push(` ticket ${t.key ?? t.id} → ${t.status ?? 'updated'}${t.frame?.url ? ` · frame ${t.frame.url}` : ''}`);
128
+ else
129
+ lines.push(` ✗ ticket ${t.id ?? ''} not moved: ${clip(t.reason ?? t.error ?? 'refused', 200)}`);
130
+ }
131
+ return { ok: failed.length === 0, lines };
132
+ }
133
+ /** A one-line description of an ordinary result: its identity and what it holds. */
134
+ function describeValue(r) {
135
+ const bits = [];
136
+ const id = r.key ?? r.id ?? r.compositionId ?? r.documentId;
137
+ if (id)
138
+ bits.push(String(id).length > 24 ? String(id).slice(0, 8) : String(id));
139
+ const title = r.title ?? r.name;
140
+ if (typeof title === 'string' && title)
141
+ bits.push(`"${clip(title, 60)}"`);
142
+ if (typeof r.status === 'string')
143
+ bits.push(r.status);
144
+ if (r.verdict)
145
+ bits.push(`verdict ${r.verdict}`);
146
+ for (const [k, v] of Object.entries(r)) {
147
+ if (Array.isArray(v) && v.length && bits.length < 4 && !['results', 'diagnostics'].includes(k))
148
+ bits.push(`${v.length} ${k}`);
149
+ }
150
+ if (typeof r.count === 'number' && !bits.some((b) => b.endsWith(' items')))
151
+ bits.push(`count ${r.count}`);
152
+ if (typeof r.url === 'string')
153
+ bits.push(r.url);
154
+ if (r.value !== undefined && typeof r.value !== 'object')
155
+ bits.push(clip(r.value, 120));
156
+ return bits.length ? ` · ${bits.join(' · ')}` : '';
157
+ }
@@ -22,6 +22,7 @@ import { homedir } from 'os';
22
22
  import { EventEmitter } from 'events';
23
23
  import { activeAgentWrap } from './agent-attribution.js';
24
24
  import { ipcSocketPath, watcherSocketPath, socketMayExist } from './ipc-path.js';
25
+ import { utf8Stream } from './utf8-stream.js';
25
26
  const CONFIG_DIR = join(homedir(), '.beehaven');
26
27
  export class ClaudeBridge extends EventEmitter {
27
28
  socket = null;
@@ -56,8 +57,10 @@ export class ClaudeBridge extends EventEmitter {
56
57
  this.send({ type: 'session:announce', label: 'claude-code', app: 'bridge' });
57
58
  resolve(true);
58
59
  });
60
+ // CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
61
+ const decode = utf8Stream();
59
62
  socket.on('data', (data) => {
60
- this.buffer += data.toString();
63
+ this.buffer += decode(data);
61
64
  const lines = this.buffer.split('\n');
62
65
  this.buffer = lines.pop() || '';
63
66
  for (const line of lines) {
package/dist/cli.js CHANGED
@@ -408,7 +408,7 @@ ${DEV_ONLY_HELP ? `
408
408
  beehaven actions --all …including ones this caller cannot invoke
409
409
  beehaven actions --search <q> Find one by substring
410
410
  beehaven explain <action> Its exact arguments, from the live schema
411
- beehaven call <action> [json] Invoke it; returns the raw JSON result
411
+ beehaven call <action> [json] Invoke it; returns the raw JSON result (--brief: a short receipt; --out <file>: the JSON to a file)
412
412
 
413
413
  # discover, then run
414
414
  beehaven actions --search ticket
@@ -1443,6 +1443,8 @@ async function run() {
1443
1443
  address: { type: 'string' },
1444
1444
  protocol: { type: 'string' },
1445
1445
  json: { type: 'boolean', default: false },
1446
+ // `beehaven call … --brief`: a short receipt instead of the full JSON (call-brief.ts).
1447
+ brief: { type: 'boolean', default: false },
1446
1448
  // `--conversation <id>` threads `beehaven agent "<msg>"` onto an existing
1447
1449
  // Omni thread instead of minting a new one per invocation.
1448
1450
  conversation: { type: 'string' },
@@ -2090,6 +2092,8 @@ async function run() {
2090
2092
  'slides', 'deck',
2091
2093
  // `brand tokens -o tokens.css` · `component render -o button.svg`
2092
2094
  'brand', 'brands', 'component', 'components',
2095
+ // MCP2-25 — `call <action> … --out result.json`: the whole result to a file, the receipt to the console.
2096
+ 'call',
2093
2097
  ]);
2094
2098
  /**
2095
2099
  * ⚠ ONE REFUSAL, because there is now one flag. Both spellings are named in the message so a
@@ -715,7 +715,7 @@ const PAGES = {
715
715
  related: ['call', 'pwd', 'workspace'],
716
716
  },
717
717
  call: {
718
- synopsis: 'beehaven call <action> [json-input] [--building <type>:<id>] [--json]',
718
+ synopsis: 'beehaven call <action> [json-input | @file | -] [--building <type>:<id>] [--json | --brief]',
719
719
  description: `Invoke an RPC on the current target. Input is a JSON string.
720
720
  Schema comes from the action's manifest — use \`beehaven actions\` first
721
721
  to see what's available, or \`beehaven explain <action>\` for the exact
@@ -725,10 +725,12 @@ const PAGES = {
725
725
  flags: [
726
726
  { name: '--building <type>:<id>', desc: 'Explicit target (e.g. shopifyStore:clearly-demo)' },
727
727
  { name: '--json', desc: 'Suppress breadcrumb + hint blocks; raw stdout only' },
728
+ { name: '--brief', desc: 'A short receipt instead of the JSON: steps, layer changes, failures, lint to fix, review verdicts. Same exit code.' },
728
729
  { name: '--timeout <ms>', desc: 'Override the 30s default' },
729
730
  ],
730
731
  examples: [
731
732
  { cmd: 'beehaven call note-create \'{"title":"Hello"}\'' },
733
+ { cmd: 'beehaven call canvas-act @build.json --brief', note: 'a canvas batch, read as a one-line receipt' },
732
734
  { cmd: "beehaven call project-list '{}' --building home" },
733
735
  {
734
736
  cmd: 'beehaven call admin-agent-chat \'{"conversationId":"c1","message":"top sellers?","scope":"read"}\'',
@@ -1132,7 +1132,36 @@ export async function cmdCall(action, rawInput, values) {
1132
1132
  printCallErrorHints(raw.message, action, values);
1133
1133
  process.exit(1);
1134
1134
  }
1135
- if (result && typeof result === 'object') {
1135
+ /**
1136
+ * MCP2-25 — `call … --out <file>` writes the whole JSON result to a file. The canvas skill tells
1137
+ * agents to save a large response once and read a summary, and the only way was `> file`, which a
1138
+ * headless agent needs permission for: a recorded Claude Code take lost a call to "`--out` is not
1139
+ * supported by `beehaven call`". The receipt (or a one-line note) still prints; the exit code is
1140
+ * the result's, exactly as without the flag.
1141
+ */
1142
+ const outPath = values.out ?? values.output;
1143
+ if (typeof outPath === 'string' && outPath) {
1144
+ const { mkdirSync, writeFileSync } = await import('node:fs');
1145
+ const { dirname, resolve } = await import('node:path');
1146
+ const file = resolve(outPath);
1147
+ mkdirSync(dirname(file), { recursive: true });
1148
+ const text = result && typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
1149
+ writeFileSync(file, text + '\n');
1150
+ if (!values.brief)
1151
+ console.log(`wrote ${text.length.toLocaleString()} bytes to ${outPath}`);
1152
+ }
1153
+ let briefFailed = false;
1154
+ if (values.brief && !values.json) {
1155
+ // ⚠ THE SAME RESULT, READ FOR A PERSON — see call-brief.ts for what is kept and why.
1156
+ const { briefReceipt } = await import('../call-brief.js');
1157
+ const receipt = briefReceipt(action, result);
1158
+ console.log(receipt.lines.join('\n'));
1159
+ briefFailed = !receipt.ok;
1160
+ }
1161
+ else if (typeof outPath === 'string' && outPath) {
1162
+ // Written above; the console keeps only the note, so a large result stays out of the context.
1163
+ }
1164
+ else if (result && typeof result === 'object') {
1136
1165
  console.log(JSON.stringify(result, null, 2));
1137
1166
  }
1138
1167
  else {
@@ -1234,7 +1263,7 @@ export async function cmdCall(action, rawInput, values) {
1234
1263
  }
1235
1264
  printKnownActionAdvice(action, input, result);
1236
1265
  }
1237
- if (isFailureResult(result))
1266
+ if (briefFailed || isFailureResult(result))
1238
1267
  process.exit(1);
1239
1268
  }
1240
1269
  catch (err) {
@@ -18,6 +18,7 @@ import net from 'net';
18
18
  import { resolveClientAuthToken } from './ipc-auth.js';
19
19
  import { activeAgentWrap } from './agent-attribution.js';
20
20
  import { ipcSocketPath, socketMayExist } from './ipc-path.js';
21
+ import { utf8Stream } from './utf8-stream.js';
21
22
  const TIMEOUT_MS = 60_000;
22
23
  const AUTH_TIMEOUT_MS = 3_000;
23
24
  /** One attempt. Resolves, never rejects. */
@@ -112,8 +113,10 @@ function handshake(socket) {
112
113
  socket.removeListener('data', onData);
113
114
  reject(new Error('IPC auth handshake timeout'));
114
115
  }, AUTH_TIMEOUT_MS);
116
+ // CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
117
+ const decode = utf8Stream();
115
118
  const onData = (chunk) => {
116
- buffer += chunk.toString();
119
+ buffer += decode(chunk);
117
120
  const lines = buffer.split('\n');
118
121
  buffer = lines.pop() || '';
119
122
  for (const line of lines) {
@@ -218,8 +221,10 @@ function sendIpc(socket, msg) {
218
221
  socket.removeListener('data', onData);
219
222
  reject(new Error(`Daemon RPC timeout after ${TIMEOUT_MS}ms`));
220
223
  }, TIMEOUT_MS);
224
+ // CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
225
+ const decode = utf8Stream();
221
226
  const onData = (chunk) => {
222
- buffer += chunk.toString();
227
+ buffer += decode(chunk);
223
228
  const lines = buffer.split('\n');
224
229
  buffer = lines.pop() || '';
225
230
  for (const line of lines) {
@@ -11,6 +11,7 @@ import { EventEmitter } from 'events';
11
11
  import { resolveClientAuthToken } from './ipc-auth.js';
12
12
  import { ipcSocketPath, socketMayExist } from './ipc-path.js';
13
13
  import { activeAgentWrap } from './agent-attribution.js';
14
+ import { utf8Stream } from './utf8-stream.js';
14
15
  /** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
15
16
  const IPC_SOCKET_PATH = ipcSocketPath();
16
17
  /** Default timeout for RPC calls (ms) */
@@ -77,8 +78,10 @@ export class IpcClient extends EventEmitter {
77
78
  socket.on('connect', () => {
78
79
  this._connected = true;
79
80
  });
81
+ // CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
82
+ const decode = utf8Stream();
80
83
  socket.on('data', (data) => {
81
- this.buffer += data.toString();
84
+ this.buffer += decode(data);
82
85
  const lines = this.buffer.split('\n');
83
86
  this.buffer = lines.pop() || '';
84
87
  for (const line of lines) {
@@ -27,6 +27,7 @@ import { createConnection, createServer } from 'net';
27
27
  import { EventEmitter } from 'events';
28
28
  import { resolveAuthScope } from './ipc-auth.js';
29
29
  import { ipcSocketPath, socketMayExist, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
30
+ import { utf8Stream } from './utf8-stream.js';
30
31
  /** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
31
32
  const IPC_SOCKET_PATH = ipcSocketPath();
32
33
  /**
@@ -194,8 +195,10 @@ export class IpcServer extends EventEmitter {
194
195
  daemonPid: process.pid,
195
196
  requireAuth: true,
196
197
  });
198
+ // CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
199
+ const decode = utf8Stream();
197
200
  socket.on('data', (data) => {
198
- client.buffer += data.toString();
201
+ client.buffer += decode(data);
199
202
  const lines = client.buffer.split('\n');
200
203
  client.buffer = lines.pop() || '';
201
204
  for (const line of lines) {
package/dist/relay.js CHANGED
@@ -777,10 +777,14 @@ export class Relay {
777
777
  this.heartbeatInterval = null;
778
778
  this.wsPingInterval = null;
779
779
  this.wsWakeDetector = null;
780
- // Reject pending requests
781
- 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) {
782
785
  clearTimeout(pending.timer);
783
- 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.'));
784
788
  }
785
789
  this.pendingRequests.clear();
786
790
  this.backoff.closed(openedAt);
@@ -1098,12 +1102,12 @@ export class Relay {
1098
1102
  return reject(new Error('WebSocket not connected'));
1099
1103
  }
1100
1104
  const requestId = Math.random().toString(36).slice(2) + Date.now().toString(36);
1105
+ const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
1101
1106
  const timer = setTimeout(() => {
1102
1107
  this.pendingRequests.delete(requestId);
1103
- const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
1104
1108
  reject(new Error(`Request timeout after ${timeout}ms (action=${action}, requestId=${requestId}); outcome unknown`));
1105
1109
  }, timeout);
1106
- this.pendingRequests.set(requestId, { resolve, reject, timer });
1110
+ this.pendingRequests.set(requestId, { resolve, reject, timer, action });
1107
1111
  // Send both rid (legacy) and requestId (workspace actions) for compatibility
1108
1112
  if (!this.wsSend({ ...msg, rid: requestId, requestId })) {
1109
1113
  clearTimeout(timer);
@@ -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.36",
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",
@@ -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",