beehaven 0.8.36 → 0.8.39

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.
Files changed (38) hide show
  1. package/dist/agent-limit-refusal.js +43 -0
  2. package/dist/agent-mint-response.js +10 -0
  3. package/dist/agent-state.js +8 -1
  4. package/dist/build-channel.js +2 -2
  5. package/dist/call-brief.js +172 -0
  6. package/dist/claude-bridge.js +4 -1
  7. package/dist/clearly.js +0 -0
  8. package/dist/cli.js +19 -4
  9. package/dist/commands/agent.js +27 -3
  10. package/dist/commands/daemon-cmd.js +17 -1
  11. package/dist/commands/help.js +3 -1
  12. package/dist/commands/workspace.js +50 -11
  13. package/dist/connection-ready.js +25 -0
  14. package/dist/daemon-client.js +7 -2
  15. package/dist/daemon-process.js +26 -11
  16. package/dist/daemon.js +4 -3
  17. package/dist/ipc-client.js +4 -1
  18. package/dist/ipc-server.js +4 -1
  19. package/dist/limit-advice.js +32 -0
  20. package/dist/relay.js +19 -6
  21. package/dist/utf8-stream.js +23 -0
  22. package/package.json +1 -1
  23. package/scripts/sync-skills.mjs +12 -0
  24. package/skills/clearly-agent/SKILL.md +13 -1
  25. package/skills/clearly-artifacts/SKILL.md +6 -5
  26. package/skills/clearly-canvas/SKILL.md +16 -8
  27. package/skills/clearly-canvas/authoring.md +40 -17
  28. package/skills/clearly-canvas/review.mjs +25 -4
  29. package/skills/clearly-docs/SKILL.md +39 -2
  30. package/skills/clearly-init/SKILL.md +19 -10
  31. package/skills/clearly-workflows/SKILL.md +3 -3
  32. package/skills/index.json +22 -7
  33. package/skills/print-production/SKILL.md +96 -0
  34. package/skills/print-production/formats.md +90 -0
  35. package/skills/social-posts/SKILL.md +46 -0
  36. package/skills/visualize/SKILL.md +5 -6
  37. package/dist/daemon-codex.js +0 -349
  38. package/dist/daemon-write-sandbox.js +0 -191
@@ -0,0 +1,43 @@
1
+ /**
2
+ * MCP2-65 — what `beehaven agent login <new name>` says when the workspace already has all the
3
+ * agents its plan includes (Free includes one).
4
+ *
5
+ * The identity mints at the account; joining the workspace is where the cap refuses
6
+ * (`agent-session-start` → automatic admission → `agent-create`, code `agent-limit-reached`). The
7
+ * reason alone left the person at a wall. The three choices, in this order:
8
+ * 1. log in as an agent they already have — one agent works from several apps;
9
+ * 2. upgrade for more agents (a pricing link: this is the terminal, not an MCP tool result);
10
+ * 3. archive one in Agents settings, then log in again.
11
+ *
12
+ * Pure: returns plain lines, the command colours them.
13
+ */
14
+ export const AGENT_LIMIT_CODE = 'agent-limit-reached';
15
+ export function isAgentLimitRefusal(code) {
16
+ return String(code ?? '') === AGENT_LIMIT_CODE;
17
+ }
18
+ /** An absolute pricing link from the refusal's relative `upgradeUrl` and the web origin for this env. */
19
+ export function upgradeLink(origin, upgradeUrl) {
20
+ const base = String(origin || 'https://www.clearly.sh').replace(/\/+$/, '');
21
+ const raw = typeof upgradeUrl === 'string' ? upgradeUrl.trim() : '';
22
+ // Only a site-relative path is joined; anything else falls back to the pricing page.
23
+ const path = /^\/(?!\/)[A-Za-z0-9/_?=&.-]*$/.test(raw) ? raw : '/pricing';
24
+ return `${base}${path}`;
25
+ }
26
+ export function agentLimitLoginLines(opts) {
27
+ const agents = (Array.isArray(opts.details?.activeAgents) ? opts.details.activeAgents : [])
28
+ .map((a) => ({ id: String(a?.id ?? '').trim(), name: String(a?.name ?? '').trim() }))
29
+ .filter((a) => a.id && a.id !== opts.name);
30
+ const first = agents[0];
31
+ const listed = agents.map((a) => (a.name && a.name !== a.id ? `${a.id} ("${a.name}")` : a.id)).join(', ');
32
+ const reason = String(opts.reason || '').trim();
33
+ return {
34
+ headline: `Signed in, but ${opts.workspace || 'this workspace'} is at its agent limit${reason ? `: ${reason}` : '.'}`,
35
+ choices: [
36
+ first
37
+ ? `Use the agent you already have (one agent works from several apps): beehaven agent login ${first.id}${agents.length > 1 ? ` · also yours here: ${listed}` : ''}`
38
+ : 'Use an agent you already have (one agent works from several apps): beehaven agent login <its name>',
39
+ `Upgrade for more agents: ${upgradeLink(opts.origin, opts.details?.upgradeUrl)}`,
40
+ `Or archive one in Agents settings, then run: beehaven agent login ${opts.name}`,
41
+ ],
42
+ };
43
+ }
@@ -0,0 +1,10 @@
1
+ /** Only an explicit appearance choice may rebind a portable identity on another device. */
2
+ export function accountMintRequest(agentId, requestedAvatarId) {
3
+ return { agentId, ...(requestedAvatarId ? { avatarId: requestedAvatarId } : {}) };
4
+ }
5
+ export function accountMintResponse(response) {
6
+ const value = response?.result ?? response;
7
+ if (!value?.token)
8
+ throw Object.assign(new Error(`${String(value?.error || 'The server returned no agent token')}${value?.code ? ` (${value.code})` : ''}`), { code: value?.code || 'mint-refused' });
9
+ return value;
10
+ }
@@ -365,11 +365,18 @@ export function loginSessionOutcome(started, thrown) {
365
365
  return { kind: 'started', sessionId: id, brief: payload?.brief };
366
366
  const err = payload?.error ?? started?.error;
367
367
  if (err) {
368
+ const code = String(payload?.code ?? started?.code ?? '');
368
369
  return {
369
370
  kind: 'refused',
370
371
  reason: typeof err === 'string' ? err : String(err?.message ?? JSON.stringify(err)),
371
- code: String(payload?.code ?? started?.code ?? ''),
372
+ code,
372
373
  scope: String(payload?.scope ?? started?.scope ?? ''),
374
+ // MCP2-65: the agent-cap refusal names the caller's live agents and a pricing path, so the
375
+ // login can print the three choices instead of a bare reason.
376
+ ...(code === 'agent-limit-reached' ? { details: {
377
+ activeAgents: Array.isArray(payload?.activeAgents) ? payload.activeAgents : [],
378
+ ...(typeof payload?.upgradeUrl === 'string' ? { upgradeUrl: payload.upgradeUrl } : {}),
379
+ } } : {}),
373
380
  };
374
381
  }
375
382
  return { kind: 'refused', reason: 'the workspace returned no session', code: '', scope: '' };
@@ -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 = '3026e5a147f9';
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-11-12T22:52Z';
59
59
  /**
60
60
  * The build identity as one short string, for `--version`.
61
61
  *
@@ -0,0 +1,172 @@
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
+ /**
62
+ * CDS-163 — THE BATCH'S OWN DELTA WINS. Only update steps carry a per-step `canvasChanges`, so
63
+ * summing steps printed "+0 ~69 −0 layers" for a 343-step build that created 211 layers. The worker
64
+ * returns one batch-level `canvasChanges`, diffed over every node the batch addressed (creates
65
+ * included); the per-step sum is kept for an older worker that does not send it.
66
+ */
67
+ let added = 0, changed = 0, removed = 0, sawDelta = false;
68
+ const isDelta = (d) => !!d && typeof d === 'object' && !Array.isArray(d);
69
+ if (isDelta(r.canvasChanges)) {
70
+ sawDelta = true;
71
+ added = Number(r.canvasChanges.added || 0);
72
+ changed = Number(r.canvasChanges.changed || 0);
73
+ removed = Number(r.canvasChanges.removed || 0);
74
+ }
75
+ else {
76
+ for (const s of steps) {
77
+ const d = s?.value?.canvasChanges;
78
+ if (isDelta(d)) {
79
+ sawDelta = true;
80
+ added += Number(d.added || 0);
81
+ changed += Number(d.changed || 0);
82
+ removed += Number(d.removed || 0);
83
+ }
84
+ }
85
+ }
86
+ const rev = r.canvasNodesRev ?? [...steps].reverse().find((s) => s?.value?.canvasNodesRev != null)?.value?.canvasNodesRev;
87
+ const head = [`${failed.length ? '✗' : '✓'} ${action}`, `${plural(steps.length, 'step')}${mix ? ` (${mix})` : ''}`];
88
+ if (sawDelta && added + changed + removed)
89
+ head.push(`+${added} ~${changed} −${removed} layers`);
90
+ if (rev != null)
91
+ head.push(`rev ${rev}`);
92
+ if (r.rolledBack)
93
+ head.push('rolled back');
94
+ if (r.partial)
95
+ head.push('partly applied');
96
+ if (Array.isArray(r.skipped) ? r.skipped.length : r.skipped)
97
+ head.push(`${Array.isArray(r.skipped) ? r.skipped.length : r.skipped} skipped`);
98
+ lines.push(head.join(' · '));
99
+ for (const { s, i } of failed.slice(0, 3)) {
100
+ lines.push(` ✗ step ${i + 1} ${verb(s.action ?? '?')}: ${clip(s.error ?? s.value?.error ?? 'failed', 140)}`);
101
+ }
102
+ if (failed.length > 3)
103
+ lines.push(` … ${failed.length - 3} more failed steps`);
104
+ for (const m of measures.slice(0, 6)) {
105
+ const v = m.value ?? {};
106
+ 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());
107
+ }
108
+ for (const rv of reviews) {
109
+ const v = rv.value ?? {};
110
+ const reports = Array.isArray(v.reviews) ? v.reviews : [v];
111
+ for (const rep of reports) {
112
+ const blocking = rep.blocking?.craft ?? 0;
113
+ const where = rep.scope?.name ?? rep.scope?.ids?.[0] ?? '';
114
+ lines.push(` review${where ? ` ${clip(where, 40)}` : ''}: ${rep.verdict ?? 'unknown'}${blocking ? ` · ${plural(blocking, 'blocking finding')}` : ''}`);
115
+ for (const f of (rep.craft?.findings ?? []).filter((x) => x.severity === 'warn').slice(0, 3)) {
116
+ lines.push(` ${f.check}: ${clip(f.detail, 120)}`);
117
+ }
118
+ }
119
+ }
120
+ // Lint of the touched artboards: what must be fixed next, never the whole report.
121
+ for (const d of (Array.isArray(r.diagnostics) ? r.diagnostics : [])) {
122
+ const errors = d.errors ?? [];
123
+ if (!errors.length && d.verdict !== 'fix')
124
+ continue;
125
+ lines.push(` lint ${clip(d.artboard ?? d.id, 40)}: ${d.verdict ?? 'fix'}`);
126
+ for (const e of errors.slice(0, 3))
127
+ lines.push(` ${clip(e, 140)}`);
128
+ if (errors.length > 3 || d.moreErrors)
129
+ lines.push(` … ${(errors.length - 3 > 0 ? errors.length - 3 : 0) + (d.moreErrors ?? 0)} more`);
130
+ }
131
+ const clean = (Array.isArray(r.diagnostics) ? r.diagnostics : []).filter((d) => d.verdict && d.verdict !== 'fix');
132
+ if (clean.length)
133
+ lines.push(` lint ${[...new Set(clean.map((d) => d.verdict))].join('/')} on ${plural(clean.length, 'artboard')}`);
134
+ /**
135
+ * MCP2-25 — THE CARRIED TICKET'S OUTCOME DECIDES THE NEXT STEP TOO. A batch carrying `ticket` moves
136
+ * it only when its review passes; the receipt said nothing either way, so an agent could not tell a
137
+ * done commit from one the review held back.
138
+ */
139
+ const t = r.ticket;
140
+ if (t && typeof t === 'object') {
141
+ if (t.applied)
142
+ lines.push(` ticket ${t.key ?? t.id} → ${t.status ?? 'updated'}${t.frame?.url ? ` · frame ${t.frame.url}` : ''}`);
143
+ else
144
+ lines.push(` ✗ ticket ${t.id ?? ''} not moved: ${clip(t.reason ?? t.error ?? 'refused', 200)}`);
145
+ }
146
+ return { ok: failed.length === 0, lines };
147
+ }
148
+ /** A one-line description of an ordinary result: its identity and what it holds. */
149
+ function describeValue(r) {
150
+ const bits = [];
151
+ const id = r.key ?? r.id ?? r.compositionId ?? r.documentId;
152
+ if (id)
153
+ bits.push(String(id).length > 24 ? String(id).slice(0, 8) : String(id));
154
+ const title = r.title ?? r.name;
155
+ if (typeof title === 'string' && title)
156
+ bits.push(`"${clip(title, 60)}"`);
157
+ if (typeof r.status === 'string')
158
+ bits.push(r.status);
159
+ if (r.verdict)
160
+ bits.push(`verdict ${r.verdict}`);
161
+ for (const [k, v] of Object.entries(r)) {
162
+ if (Array.isArray(v) && v.length && bits.length < 4 && !['results', 'diagnostics'].includes(k))
163
+ bits.push(`${v.length} ${k}`);
164
+ }
165
+ if (typeof r.count === 'number' && !bits.some((b) => b.endsWith(' items')))
166
+ bits.push(`count ${r.count}`);
167
+ if (typeof r.url === 'string')
168
+ bits.push(r.url);
169
+ if (r.value !== undefined && typeof r.value !== 'object')
170
+ bits.push(clip(r.value, 120));
171
+ return bits.length ? ` · ${bits.join(' · ')}` : '';
172
+ }
@@ -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/clearly.js CHANGED
File without changes
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' },
@@ -1879,6 +1881,7 @@ async function run() {
1879
1881
  * would be refused while `conversations --json` worked.
1880
1882
  */
1881
1883
  const JSON_CAPABLE = new Set([
1884
+ 'doc', 'search',
1882
1885
  'admin', 'apps', 'd1', 'describe', 'explain', 'marketplace', 'secrets',
1883
1886
  'shop', 'user', 'webhook', 'webhooks', 'workspaces', 'ws',
1884
1887
  // Honour what the modules already implement.
@@ -2090,6 +2093,8 @@ async function run() {
2090
2093
  'slides', 'deck',
2091
2094
  // `brand tokens -o tokens.css` · `component render -o button.svg`
2092
2095
  'brand', 'brands', 'component', 'components',
2096
+ // MCP2-25 — `call <action> … --out result.json`: the whole result to a file, the receipt to the console.
2097
+ 'call',
2093
2098
  ]);
2094
2099
  /**
2095
2100
  * ⚠ ONE REFUSAL, because there is now one flag. Both spellings are named in the message so a
@@ -3060,13 +3065,13 @@ async function run() {
3060
3065
  }
3061
3066
  console.log('');
3062
3067
  console.log(` ${color.green('✓')} Signed in as ${color.bold(captured.email ?? captured.userId)}`);
3063
- console.log(` Tokens stored in keychain + ~/.beehaven/config.json`);
3068
+ console.log(` Sign-in saved on this device`);
3064
3069
  // Auto-restart the daemon. New tokens won't take effect until
3065
3070
  // the daemon restarts (it reads creds at startup, keeps them
3066
3071
  // in memory, has no hot-reload IPC verb). Doing the restart
3067
3072
  // here matches gh / firebase-tools UX — login-then-go.
3068
3073
  const { findLiveDaemons, stopDaemonProcess } = await import('./daemon.js');
3069
- const { spawnStandaloneDaemon } = await import('./commands/daemon-cmd.js');
3074
+ const { spawnStandaloneDaemon, waitForLoginConnection } = await import('./commands/daemon-cmd.js');
3070
3075
  /**
3071
3076
  * ⚠⚠ THE RESTART IS PLUMBING AND WAS NARRATED AS THOUGH IT WERE THE POINT. What a person saw
3072
3077
  * after signing in was:
@@ -3111,6 +3116,7 @@ async function run() {
3111
3116
  * started with developer scope.
3112
3117
  */
3113
3118
  const pid = spawnStandaloneDaemon(values);
3119
+ const connection = await waitForLoginConnection();
3114
3120
  /**
3115
3121
  * ⚠ ONE OUTCOME LINE, IN THE SAME SHAPE AS THE ONE ABOVE IT. `✓ (PID 73532)` was a tick, a
3116
3122
  * bracket and a number — grammatically nothing, and unreadable as a result. This says what
@@ -3121,7 +3127,16 @@ async function run() {
3121
3127
  * `beehaven status` labels the same thing `Relay:`. Naming the outcome rather than the
3122
3128
  * process is what stops a sign-in ending on the word "stopped".
3123
3129
  */
3124
- console.log(` ${color.green('✓')} Connected — your new sign-in is live`);
3130
+ if (connection.ready) {
3131
+ console.log(` ${color.green('✓')} Connected — your new sign-in is live`);
3132
+ }
3133
+ else {
3134
+ console.error(` ${color.yellow('!')} Signed in, but the workspace connection is not ready.`);
3135
+ console.error(connection.reason === 'environment-mismatch'
3136
+ ? ' The running service is on a different environment. Run `beehaven status` before retrying.'
3137
+ : ' Run `beehaven status` to check the connection, then retry your workspace command.');
3138
+ process.exitCode = 2;
3139
+ }
3125
3140
  console.log(` Background service ${wasRunning ? 'restarted' : 'started'} (PID ${pid})`);
3126
3141
  /**
3127
3142
  * ⚠⚠ TEACH THE AGENTS AS PART OF SIGNING IN (PPO-190). "Install the CLI, log in, and your
@@ -25,6 +25,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
25
25
  import { join, dirname } from 'node:path';
26
26
  import { homedir } from 'node:os';
27
27
  import { rpcOnce } from '../ipc-client.js';
28
+ import { accountMintRequest, accountMintResponse } from '../agent-mint-response.js';
29
+ import { agentLimitLoginLines, isAgentLimitRefusal } from '../agent-limit-refusal.js';
28
30
  import { saveAgent, loadAgent, listAgents, deleteAgent, findAgentByAvatar, isValidAgentName, resolveActiveAgent, activeAgentName, saveAgentSession, clearAgentSession, clearAgentSessionId, decodeAgentTokenClaims, } from '../agent-state.js';
29
31
  const VALID_KINDS = ['claude-code', 'daemon-agent', 'clearly-ai'];
30
32
  export const FREE_SKINS = [
@@ -643,7 +645,7 @@ async function agentLogin(args, values) {
643
645
  }
644
646
  const avatarId = requestedAvatarId || existingRecord?.avatarId || chosenSkin || name;
645
647
  // Local-first avatar-collision guard (durable guard is the cloud pool).
646
- const collision = findAgentByAvatar(avatarId, name);
648
+ const collision = requestedAvatarId ? findAgentByAvatar(requestedAvatarId, name) : null;
647
649
  if (collision) {
648
650
  console.error(` Avatar "${avatarId}" is already claimed by agent "${collision}".`);
649
651
  console.error(` Pick another: beehaven agent login ${name} --avatar <other-id>`);
@@ -670,7 +672,7 @@ async function agentLogin(args, values) {
670
672
  * credential.
671
673
  */
672
674
  try {
673
- const cloud = await rpcOnce('agent-account-mint', { agentId: name, avatarId }, 15_000);
675
+ const cloud = accountMintResponse(await rpcOnce('agent-account-mint', accountMintRequest(name, requestedAvatarId), 15_000));
674
676
  const tok = cloud?.token ?? cloud?.result?.token;
675
677
  if (tok) {
676
678
  minted = {
@@ -698,7 +700,14 @@ async function agentLogin(args, values) {
698
700
  }
699
701
  catch (err) {
700
702
  console.error(` Could not mint identity: ${err?.message ?? err}`);
701
- console.error(' Is the daemon connected? Check with: beehaven status');
703
+ if (String(err?.code) === 'identity-limit-reached') {
704
+ // MCP2-71: the account's safety ceiling — no plan lifts it; signing in as one you have always works.
705
+ console.error(' Sign in as an agent you already have: beehaven agent list, then beehaven agent login <name>');
706
+ console.error(' Or delete one you no longer use in Agents, then run this again.');
707
+ process.exit(1);
708
+ }
709
+ if (!err?.code || /^(ECONN|ENOENT|ETIMEDOUT)/.test(String(err.code)))
710
+ console.error(' Is the daemon connected? Check with: beehaven status');
702
711
  process.exit(1);
703
712
  }
704
713
  /**
@@ -962,6 +971,21 @@ async function agentLogin(args, values) {
962
971
  console.log(` ${green('◷')} Workspace work session ${cyan(outcome.sessionId.slice(0, 8))} started.`);
963
972
  printBrief(outcome.brief);
964
973
  }
974
+ else if (outcome.kind === 'refused' && isAgentLimitRefusal(outcome.code)) {
975
+ // MCP2-65: at the plan's agent limit, say what to do — not just that it was refused.
976
+ const { resolveAuthOrigin } = await import('../browser-login.js');
977
+ const lines = agentLimitLoginLines({
978
+ name, workspace: (await workspaceLabel(chosenWorkspace)).split(/ {2}\(| — /)[0], reason: outcome.reason,
979
+ details: outcome.details, origin: resolveAuthOrigin().authOrigin,
980
+ });
981
+ console.log('');
982
+ console.error(` ${yellow('⚠')} ${lines.headline}`);
983
+ console.error(' Your choices:');
984
+ for (const line of lines.choices)
985
+ console.error(` · ${line}`);
986
+ console.error(dim(` Calls from this session run as ${name} and will be refused — they will NOT fall back to another agent.`));
987
+ process.exitCode = 1;
988
+ }
965
989
  else if (outcome.kind === 'refused') {
966
990
  console.log('');
967
991
  console.error(` ${yellow('⚠')} Signed in, but this workspace refused ${cyan(name)}: ${outcome.reason}`);
@@ -16,6 +16,22 @@ import { fileURLToPath } from 'node:url';
16
16
  import { existsSync } from 'node:fs';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { findLiveDaemons, resolveRelayUrlForDaemon } from '../daemon.js';
19
+ import { waitForConnection } from '../connection-ready.js';
20
+ /** Each attempt authenticates over IPC; a PID or greeting alone cannot prove a usable sign-in. */
21
+ export async function waitForLoginConnection() {
22
+ const { IpcClient } = await import('../ipc-client.js');
23
+ return waitForConnection(async () => {
24
+ const client = new IpcClient({ timeout: 1000 });
25
+ try {
26
+ await client.connect();
27
+ const state = await client.call('daemon:session-status', {}, 1000);
28
+ return { connected: state?.relay?.connected === true, endpoint: state?.relay?.endpoint };
29
+ }
30
+ finally {
31
+ client.disconnect();
32
+ }
33
+ }, resolveRelayUrlForDaemon());
34
+ }
19
35
  /** dist/daemon-entry.js — sibling of dist/cli.js (this file is dist/commands/). */
20
36
  function daemonEntryPath() {
21
37
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'daemon-entry.js');
@@ -82,7 +98,7 @@ export async function startHeadlessDaemon(values) {
82
98
  const { daemonRpc } = await import('../daemon-client.js');
83
99
  let activeRelay = null;
84
100
  try {
85
- const status = await daemonRpc('daemon:status', {});
101
+ const status = await daemonRpc('daemon:session-status', {});
86
102
  activeRelay = status?.relay?.endpoint ?? null;
87
103
  }
88
104
  catch { /* status is advisory; the socket probe already succeeded */ }
@@ -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"}\'',
@@ -26,6 +26,8 @@ import { resolveActiveAgent } from '../agent-state.js';
26
26
  import { isAgentRuntime } from '../agent-scope.js';
27
27
  import { getTarget } from '../cli-state.js';
28
28
  import { attachShellCwd, absorbShellCwd, shellTargetKey } from '../shell-session.js';
29
+ import { planLimitAdvice } from '../limit-advice.js';
30
+ import { resolveAuthOrigin } from '../browser-login.js';
29
31
  /** Workspace-agent jobs wait for the server's ceiling (900s) plus the report's tail. */
30
32
  export const AGENT_TURN_TIMEOUT_MS = 930_000;
31
33
  const AGENT_TURN_ACTIONS = new Set(['agent-prompt', 'agent-turn']);
@@ -1132,7 +1134,36 @@ export async function cmdCall(action, rawInput, values) {
1132
1134
  printCallErrorHints(raw.message, action, values);
1133
1135
  process.exit(1);
1134
1136
  }
1135
- if (result && typeof result === 'object') {
1137
+ /**
1138
+ * MCP2-25 — `call … --out <file>` writes the whole JSON result to a file. The canvas skill tells
1139
+ * agents to save a large response once and read a summary, and the only way was `> file`, which a
1140
+ * headless agent needs permission for: a recorded Claude Code take lost a call to "`--out` is not
1141
+ * supported by `beehaven call`". The receipt (or a one-line note) still prints; the exit code is
1142
+ * the result's, exactly as without the flag.
1143
+ */
1144
+ const outPath = values.out ?? values.output;
1145
+ if (typeof outPath === 'string' && outPath) {
1146
+ const { mkdirSync, writeFileSync } = await import('node:fs');
1147
+ const { dirname, resolve } = await import('node:path');
1148
+ const file = resolve(outPath);
1149
+ mkdirSync(dirname(file), { recursive: true });
1150
+ const text = result && typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
1151
+ writeFileSync(file, text + '\n');
1152
+ if (!values.brief)
1153
+ console.log(`wrote ${text.length.toLocaleString()} bytes to ${outPath}`);
1154
+ }
1155
+ let briefFailed = false;
1156
+ if (values.brief && !values.json) {
1157
+ // ⚠ THE SAME RESULT, READ FOR A PERSON — see call-brief.ts for what is kept and why.
1158
+ const { briefReceipt } = await import('../call-brief.js');
1159
+ const receipt = briefReceipt(action, result);
1160
+ console.log(receipt.lines.join('\n'));
1161
+ briefFailed = !receipt.ok;
1162
+ }
1163
+ else if (typeof outPath === 'string' && outPath) {
1164
+ // Written above; the console keeps only the note, so a large result stays out of the context.
1165
+ }
1166
+ else if (result && typeof result === 'object') {
1136
1167
  console.log(JSON.stringify(result, null, 2));
1137
1168
  }
1138
1169
  else {
@@ -1234,7 +1265,7 @@ export async function cmdCall(action, rawInput, values) {
1234
1265
  }
1235
1266
  printKnownActionAdvice(action, input, result);
1236
1267
  }
1237
- if (isFailureResult(result))
1268
+ if (briefFailed || isFailureResult(result))
1238
1269
  process.exit(1);
1239
1270
  }
1240
1271
  catch (err) {
@@ -1258,6 +1289,16 @@ function printKnownActionAdvice(action, input, result) {
1258
1289
  if (action === 'ticket-get' && result?.code === 'not-found') {
1259
1290
  console.error(' If this key belongs to a document, read it with document-get; ticket-get reads board cards only.');
1260
1291
  }
1292
+ // MCP2-70 / MCP2-71: a plan or account limit says what is true and what to do, in words.
1293
+ if (/-limit-reached$/.test(String(result?.code ?? ''))) {
1294
+ let origin = 'https://www.clearly.sh';
1295
+ try {
1296
+ origin = resolveAuthOrigin().authOrigin;
1297
+ }
1298
+ catch { /* the default web origin */ }
1299
+ for (const line of planLimitAdvice(result, origin))
1300
+ console.error(line);
1301
+ }
1261
1302
  }
1262
1303
  /**
1263
1304
  * Emit navigation hints for common call failures. Agents parse these to
@@ -2434,8 +2475,8 @@ export async function cmdTask(title, values) {
2434
2475
  }
2435
2476
  // ── beehaven doc add <file> — local file → searchable project document ───
2436
2477
  //
2437
- // One-shot: reads a local file, mints a note (note-create) + seeds its body
2438
- // (note-content-seed). Content lands in the workspace SQLite note-content
2478
+ // One-shot: reads a local file and creates it through document-create.
2479
+ // Content lands in the workspace SQLite note-content
2439
2480
  // table + FTS5 index, so `search-notes` and the cloud agent can retrieve it.
2440
2481
  // This is the DOCUMENT path; `teleport` is the raw-blob (R2) path. Markdown is
2441
2482
  // stored verbatim; code is wrapped in a fenced block (with the language) so it
@@ -2496,16 +2537,14 @@ export async function cmdDocAdd(filePath, values) {
2496
2537
  const title = values.title ||
2497
2538
  (isMarkdown ? raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || fileName : fileName);
2498
2539
  try {
2499
- const created = await rpcOnce('note-create', {
2500
- noteData: { projectId: values.project || undefined, title },
2501
- fileType: 'document',
2540
+ const created = await rpcOnce('document-create', {
2541
+ projectId: values.project || undefined, title, markdown: data,
2502
2542
  }, getTimeout(values));
2503
- const noteId = created?.noteId || created?.id;
2543
+ assertNoRpcError(created, 'document-create');
2544
+ const noteId = created?.id;
2504
2545
  if (!noteId) {
2505
- console.error(` note-create failed: ${JSON.stringify(created)}`);
2506
- process.exit(1);
2546
+ throw new Error(`document-create failed: ${JSON.stringify(created)}`);
2507
2547
  }
2508
- await rpcOnce('note-content-seed', { noteId, data, title }, getTimeout(values));
2509
2548
  if (values.json) {
2510
2549
  console.log(JSON.stringify({
2511
2550
  noteId,
@@ -0,0 +1,25 @@
1
+ export async function waitForConnection(probe, expectedEndpoint, options = {}) {
2
+ const now = options.now ?? Date.now;
3
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
4
+ const deadline = now() + (options.timeoutMs ?? 30_000);
5
+ const normalize = (value) => value.replace(/\/+$/, '');
6
+ let reason = 'starting';
7
+ do {
8
+ try {
9
+ const state = await probe();
10
+ if (state.endpoint && normalize(state.endpoint) !== normalize(expectedEndpoint))
11
+ return { ready: false, reason: 'environment-mismatch' };
12
+ if (state.connected && state.endpoint && normalize(state.endpoint) === normalize(expectedEndpoint))
13
+ return { ready: true, reason: 'connected' };
14
+ reason = 'relay-unavailable';
15
+ }
16
+ catch {
17
+ reason = 'starting';
18
+ }
19
+ const remaining = deadline - now();
20
+ if (remaining <= 0)
21
+ break;
22
+ await sleep(Math.min(options.pollMs ?? 250, remaining));
23
+ } while (now() < deadline);
24
+ return { ready: false, reason };
25
+ }
@@ -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) {