@clearlysh/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.
@@ -14,6 +14,7 @@ import { writeFileSync, readFileSync, renameSync, chmodSync, mkdirSync, existsSy
14
14
  import { createHash } from 'node:crypto';
15
15
  import { join } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
+ import { ReconnectBackoff } from './reconnect-backoff.js';
17
18
  const CONFIG_DIR = join(homedir(), '.beehaven');
18
19
  function scopedPresenceFile(scope) {
19
20
  const digest = createHash('sha256').update(String(scope || 'local')).digest('hex').slice(0, 24);
@@ -31,8 +32,6 @@ export class AgentSessions {
31
32
  presenceBroadcast = null;
32
33
  /** A desktop identity is presence, not a permanent login badge. */
33
34
  static IDLE_TIMEOUT_MS = 30 * 60 * 1000;
34
- static RECONNECT_MIN_MS = 1_000;
35
- static RECONNECT_MAX_MS = 30_000;
36
35
  /** @param wsBase returns the relay ws base, e.g. wss://…/ (no trailing path) */
37
36
  constructor(wsBase, presenceScope = 'local') {
38
37
  this.wsBase = wsBase;
@@ -188,8 +187,7 @@ export class AgentSessions {
188
187
  scheduleReconnect(agentId, spec) {
189
188
  if (this.specs.get(agentId) !== spec || spec.reconnectTimer)
190
189
  return;
191
- const delay = spec.reconnectDelay;
192
- spec.reconnectDelay = Math.min(spec.reconnectDelay * 2, AgentSessions.RECONNECT_MAX_MS);
190
+ const delay = spec.backoff.next();
193
191
  spec.reconnectTimer = setTimeout(() => {
194
192
  spec.reconnectTimer = null;
195
193
  this.openSocket(agentId, spec);
@@ -213,6 +211,7 @@ export class AgentSessions {
213
211
  const url = `${base}/my/workspace/ws?token=${encodeURIComponent(spec.token)}&clientType=agent&client=agent&deviceId=agent-${encodeURIComponent(agentId)}`;
214
212
  let ws;
215
213
  let opened = false;
214
+ let openedAt = null;
216
215
  let settled = false;
217
216
  let timer = null;
218
217
  const settleFirst = (result) => {
@@ -253,7 +252,10 @@ export class AgentSessions {
253
252
  // Reconnecting transport is not agent activity. A new socket stays inactive until a
254
253
  // verified call touches the identity; a persisted interaction keeps its existing clock.
255
254
  this.scheduleIdleExpiry(agentId);
256
- spec.reconnectDelay = AgentSessions.RECONNECT_MIN_MS;
255
+ // ⚠ PPO-532: opening is not recovering — this reset-on-open made a workspace that drops
256
+ // every new socket while hydrating (1006) see a reconnect every ~1 s, in step with the
257
+ // daemon's own socket. The backoff now resets only after the socket stays open 60 s.
258
+ openedAt = spec.backoff.opened();
257
259
  this.syncPersistedInteractions();
258
260
  this.syncActiveSessionsFile();
259
261
  settleFirst({ ok: true });
@@ -290,6 +292,7 @@ export class AgentSessions {
290
292
  return;
291
293
  }
292
294
  this.syncActiveSessionsFile();
295
+ spec.backoff.closed(openedAt);
293
296
  this.scheduleReconnect(agentId, spec);
294
297
  };
295
298
  }
@@ -300,7 +303,7 @@ export class AgentSessions {
300
303
  const spec = {
301
304
  token,
302
305
  reconnectTimer: null,
303
- reconnectDelay: AgentSessions.RECONNECT_MIN_MS,
306
+ backoff: new ReconnectBackoff(),
304
307
  };
305
308
  this.specs.set(agentId, spec);
306
309
  return new Promise((resolve) => this.openSocket(agentId, spec, resolve));
@@ -22,6 +22,22 @@ export const LEGACY_BLOCK_MARKERS = [
22
22
  ['<!-- clearly:cli:start -->', '<!-- clearly:cli:end -->'],
23
23
  ['<!-- clearly:mcp:start -->', '<!-- clearly:mcp:end -->'],
24
24
  ];
25
+ /**
26
+ * The opt-out (AWO-403): a project whose AGENTS.md or CLAUDE.md carries this exact line is not tracked in Clearly.
27
+ * The global block (agentsGlobal.ts) and the MCP standing instructions name it, so the opt-out is a literal an agent
28
+ * matches, not a sentence it interprets. `beehaven agents-md --not-tracked` writes it between Clearly's markers.
29
+ */
30
+ export const AGENTS_MD_OPT_OUT_LINE = 'Clearly: not tracked';
31
+ /** The opted-out project's block: the line, inside the same markers, so re-running the setup turns tracking back on. */
32
+ export function agentsMdOptOutBlock() {
33
+ return [
34
+ AGENTS_MD_START,
35
+ '<!-- managed-by: clearly. Run `beehaven agents-md` to track this project in Clearly again. -->',
36
+ '',
37
+ AGENTS_MD_OPT_OUT_LINE,
38
+ AGENTS_MD_END,
39
+ ].join('\n');
40
+ }
25
41
  /** How Claude Code runs the MCP server's `clearly-agents-md` prompt, which writes the block. */
26
42
  export const MCP_AGENTS_MD_SLASH = '/mcp__clearly__clearly-agents-md';
27
43
  /** What goes in CLAUDE.md so Claude Code reads the AGENTS.md block too. */
@@ -68,13 +84,17 @@ export function agentsMdBlock(agentName) {
68
84
  ' Record decisions, open questions and progress as comments (`clearly_comment`).',
69
85
  '5. Mark it done only after verifying the result, with the resolution and any remaining risk.',
70
86
  '',
87
+ 'Questions and reads need no ticket.',
88
+ '',
71
89
  '### End of every session',
72
90
  '',
73
91
  '- Leave a handoff: what you did and what is next. CLI: `beehaven agent handoff "<done · next>"`.',
74
92
  ' MCP: a comment on the work ticket.',
75
93
  '',
76
94
  'If this session has neither the `clearly_*` tools nor `beehaven`, stop and ask the person to connect',
77
- 'Clearly (clearly.sh/docs/mcp or clearly.sh/docs/cli). Do not skip the tracking.',
95
+ 'Clearly (clearly.sh/docs/mcp or clearly.sh/docs/cli). Do not skip the tracking. In CI or a cloud',
96
+ 'agent, where nobody can sign in, connect over MCP with an agent token from the agent\'s details in',
97
+ 'Clearly, kept as a secret.',
78
98
  AGENTS_MD_END,
79
99
  ].join('\n');
80
100
  }
@@ -291,3 +291,97 @@ export async function browserLogin() {
291
291
  persistBrowserLogin(captured);
292
292
  return captured;
293
293
  }
294
+ /** The relay's HTTPS origin for the configured env (prod when unconfigured). */
295
+ export function relayHttpFromConfig(cfg = readConfig(), env = process.env) {
296
+ const override = env.BEEHAVEN_RELAY_HTTP_URL;
297
+ if (override)
298
+ return override.replace(/\/+$/, '');
299
+ return resolveRelaySelection(cfg).url.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://').replace(/\/+$/, '');
300
+ }
301
+ /**
302
+ * True where a browser on this machine is unlikely: an SSH session, CI, or Linux with no display.
303
+ * `beehaven login --browser` overrides it; `--device-code` forces the device flow anywhere.
304
+ */
305
+ export function preferDeviceLogin(env = process.env, platform = process.platform) {
306
+ if (env.SSH_CONNECTION || env.SSH_TTY || env.CI)
307
+ return true;
308
+ return platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY;
309
+ }
310
+ /** The approval page, optionally with the code filled in. */
311
+ export function deviceAuthUrl(authOrigin, userCode) {
312
+ return withUtm(`${authOrigin}/cli/device${userCode ? `?code=${encodeURIComponent(userCode)}` : ''}`);
313
+ }
314
+ /** Email and name from the ID token's payload, for the "Signed in as" line. Display only. */
315
+ function idTokenClaims(idToken) {
316
+ try {
317
+ const payload = JSON.parse(Buffer.from(idToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
318
+ return { email: typeof payload.email === 'string' ? payload.email : undefined, name: typeof payload.name === 'string' ? payload.name : undefined };
319
+ }
320
+ catch {
321
+ return {};
322
+ }
323
+ }
324
+ export function captureDeviceLogin(opts) {
325
+ const { relayOrigin, authOrigin, client, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, log = (line) => console.log(line), } = opts;
326
+ const post = async (path, body) => {
327
+ const res = await fetchImpl(`${relayOrigin}${path}`, {
328
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
329
+ });
330
+ const json = await res.json().catch(() => ({}));
331
+ return { res, json };
332
+ };
333
+ return (async () => {
334
+ let start;
335
+ try {
336
+ const { res, json } = await post('/cli/device/start', { client });
337
+ if (!res.ok || !json?.ok)
338
+ throw new Error(json?.error === 'slow_down' ? 'Too many sign-in attempts from here. Wait a while and try again.' : (json?.error || `relay answered ${res.status}`));
339
+ start = json;
340
+ }
341
+ catch (err) {
342
+ throw new Error(`Couldn't start a device sign-in: ${err?.message ?? err}`);
343
+ }
344
+ log('');
345
+ log(` On any device with a browser, open ${color.cyan(deviceAuthUrl(authOrigin))}`);
346
+ log(` and enter this code: ${color.bold(start.userCode)}`);
347
+ log('');
348
+ log(color.dim(` Direct link: ${deviceAuthUrl(authOrigin, start.userCode)}`));
349
+ log(color.dim(` Waiting for approval (the code expires in ${Math.round(start.expiresIn / 60)} minutes)…`));
350
+ let interval = Math.max(1, start.interval) * 1000;
351
+ const deadline = now() + start.expiresIn * 1000;
352
+ while (now() < deadline) {
353
+ await sleep(interval);
354
+ let res;
355
+ let json;
356
+ try {
357
+ ({ res, json } = await post('/cli/device/token', { deviceCode: start.deviceCode }));
358
+ }
359
+ catch {
360
+ continue; // a dropped poll is retried at the next tick
361
+ }
362
+ if (res.ok && json?.ok && json.idToken && json.refreshToken && json.userId) {
363
+ const { email, name } = idTokenClaims(json.idToken);
364
+ return { idToken: json.idToken, refreshToken: json.refreshToken, userId: json.userId, email, displayName: name };
365
+ }
366
+ switch (json?.error) {
367
+ case 'authorization_pending': continue;
368
+ case 'slow_down':
369
+ interval += 5000;
370
+ continue;
371
+ case 'access_denied': throw new Error('The sign-in was denied in the browser.');
372
+ case 'expired_token': throw new Error('The code expired. Run `beehaven login --device-code` again.');
373
+ default:
374
+ if (res.status >= 500)
375
+ throw new Error(json?.error || `relay answered ${res.status}`);
376
+ }
377
+ }
378
+ throw new Error('The code expired. Run `beehaven login --device-code` again.');
379
+ })();
380
+ }
381
+ /** Device-code sign-in end to end: start, show the code, poll, persist like the browser flow. */
382
+ export async function deviceLogin(opts = {}) {
383
+ const { authOrigin } = resolveAuthOrigin();
384
+ const captured = await captureDeviceLogin({ relayOrigin: relayHttpFromConfig(), authOrigin, client: opts.client });
385
+ persistBrowserLogin(captured);
386
+ return captured;
387
+ }
@@ -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 = 'f866489dd2f1';
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-28T16:02Z';
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
@@ -284,6 +284,7 @@ ${DEV_ONLY_HELP ? ` PROJECT — build your own Durable Object backend (HIVE_DE
284
284
  mcp status Which clients are configured
285
285
  mcp remove Remove the entries + revoke the token
286
286
  mcp-config [--name N] Print the block instead of writing it
287
+ mcp-config --token-only --expires <days> Print ONLY the token, for a secret store
287
288
 
288
289
  Anywhere else — claude.ai, Claude Desktop, another machine — point the client
289
290
  at the endpoint and let it log you in; nothing to install and no token to paste:
@@ -407,7 +408,7 @@ ${DEV_ONLY_HELP ? `
407
408
  beehaven actions --all …including ones this caller cannot invoke
408
409
  beehaven actions --search <q> Find one by substring
409
410
  beehaven explain <action> Its exact arguments, from the live schema
410
- 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)
411
412
 
412
413
  # discover, then run
413
414
  beehaven actions --search ticket
@@ -1417,6 +1418,10 @@ async function run() {
1417
1418
  // (a live client) when none is connected; `--dev` → localhost.
1418
1419
  launch: { type: 'boolean', default: false },
1419
1420
  browser: { type: 'boolean', default: false },
1421
+ // `beehaven login --device-code` — sign in by approving a code on another device (AWO-402).
1422
+ 'device-code': { type: 'boolean', default: false },
1423
+ // `beehaven agents-md --not-tracked` — write the opt-out line instead of the tracked block (AWO-403).
1424
+ 'not-tracked': { type: 'boolean', default: false },
1420
1425
  dev: { type: 'boolean', default: false },
1421
1426
  // `beehaven canvas perceive --pixels --max 80`, `canvas verify --keep`
1422
1427
  pixels: { type: 'boolean', default: false },
@@ -1438,6 +1443,8 @@ async function run() {
1438
1443
  address: { type: 'string' },
1439
1444
  protocol: { type: 'string' },
1440
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 },
1441
1448
  // `--conversation <id>` threads `beehaven agent "<msg>"` onto an existing
1442
1449
  // Omni thread instead of minting a new one per invocation.
1443
1450
  conversation: { type: 'string' },
@@ -1461,6 +1468,8 @@ async function run() {
1461
1468
  client: { type: 'string' },
1462
1469
  /** `beehaven mcp install --rotate` — mint a new token instead of reusing the installed one. */
1463
1470
  rotate: { type: 'boolean' },
1471
+ /** `beehaven mcp-config --token-only --expires <days>` — the raw token alone on stdout, for piping. */
1472
+ 'token-only': { type: 'boolean' },
1464
1473
  password: { type: 'string' },
1465
1474
  email: { type: 'string' },
1466
1475
  expires: { type: 'string' },
@@ -2083,6 +2092,8 @@ async function run() {
2083
2092
  'slides', 'deck',
2084
2093
  // `brand tokens -o tokens.css` · `component render -o button.svg`
2085
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',
2086
2097
  ]);
2087
2098
  /**
2088
2099
  * ⚠ ONE REFUSAL, because there is now one flag. Both spellings are named in the message so a
@@ -3033,10 +3044,19 @@ async function run() {
3033
3044
  // own copy aimed at /auth/beehaven, a route the web app no longer serves.
3034
3045
  // What follows the sign-in — restarting the daemon, teaching the agents —
3035
3046
  // stays here, because the daemon fallback must not restart itself.
3047
+ // AWO-402 — the device-code flow for a machine with no browser of its own: forced with
3048
+ // `--device-code`, chosen automatically over SSH / CI / a display-less Linux, and never when
3049
+ // the person asked for `--browser`.
3036
3050
  let captured;
3037
3051
  try {
3038
- const { browserLogin } = await import('./browser-login.js');
3039
- captured = await browserLogin();
3052
+ const { browserLogin, deviceLogin, preferDeviceLogin } = await import('./browser-login.js');
3053
+ const useDevice = Boolean(values['device-code']) || (!values.browser && preferDeviceLogin());
3054
+ if (useDevice && !values['device-code']) {
3055
+ console.log(color.dim(' No browser on this machine, so signing in with a code (`beehaven login --browser` to open one here).'));
3056
+ }
3057
+ captured = useDevice
3058
+ ? await deviceLogin({ client: `beehaven ${getVersion()} (${process.platform})` })
3059
+ : await browserLogin();
3040
3060
  }
3041
3061
  catch (err) {
3042
3062
  console.error(` ${color.red('✗')} ${err?.message ?? err}`);
@@ -3170,6 +3190,7 @@ async function run() {
3170
3190
  mcp: !!values.mcp,
3171
3191
  print: !!values.print,
3172
3192
  remove: !!values.remove,
3193
+ notTracked: Boolean(values['not-tracked']),
3173
3194
  });
3174
3195
  break;
3175
3196
  }
@@ -4073,10 +4094,82 @@ complete -F _beehaven_completions beehaven`);
4073
4094
  const tgtAddress = tgt?.address ?? 'home';
4074
4095
  const mcpEntry = entryNameFor(tgtAddress);
4075
4096
  const wsId = isHomeAddress(tgtAddress) ? '' : tgtAddress.split('/').slice(1).join('/');
4076
- const label = values.name || `${mcpEntry} · ${new Date().toISOString().slice(0, 10)}`;
4097
+ const defaultMcpLabel = `${mcpEntry} · ${new Date().toISOString().slice(0, 10)}`;
4077
4098
  const { rpcOnce: _rpc } = await import('./ipc-client.js');
4078
4099
  const { resolveActiveAgent } = await import('./agent-state.js');
4100
+ const { mcpMintInput, runMcpTokenOnly } = await import('./commands/mcp-token.js');
4079
4101
  const activeMcpAgent = resolveActiveAgent(values);
4102
+ /**
4103
+ * ⚠⚠ DO NOT ECHO THE RAW ERROR — IT SENT PEOPLE TO THE WRONG FIX.
4104
+ *
4105
+ * Observed: this printed "Cannot connect to daemon. Run `beehaven start --headless`" in the
4106
+ * same second that `beehaven status` reported "Daemon: running". The daemon was up and the
4107
+ * RELAY had dropped. Following the advice runs into the single-instance guard, which
4108
+ * refuses — so the message costs a cycle and teaches the wrong model of the failure.
4109
+ *
4110
+ * The CLI already distinguishes these three states carefully (see `status`), precisely
4111
+ * because the fixes are mutually exclusive: a restart will NOT refresh an expired token,
4112
+ * and a re-login will NOT revive a dropped socket. This path bypassed all of it by
4113
+ * forwarding whatever string the transport happened to raise.
4114
+ *
4115
+ * Re-probe and say what is actually true. Stderr only, and it returns the exit code, so the
4116
+ * `--token-only` path can use it without touching stdout.
4117
+ */
4118
+ const explainMintFailure = async (err) => {
4119
+ const { isDaemonRunning } = await import('./daemon-client.js');
4120
+ let up = false;
4121
+ try {
4122
+ up = await isDaemonRunning();
4123
+ }
4124
+ catch {
4125
+ up = false;
4126
+ }
4127
+ let expired = false;
4128
+ try {
4129
+ const cfg = JSON.parse(readFileSync(join(homedir(), '.beehaven', 'config.json'), 'utf8'));
4130
+ const payload = JSON.parse(Buffer.from(String(cfg.token).split('.')[1], 'base64').toString());
4131
+ expired = !payload.exp || Date.now() / 1000 > payload.exp - 60;
4132
+ }
4133
+ catch {
4134
+ expired = true;
4135
+ }
4136
+ console.error(` ${color.red('✗')} Could not mint a token.`);
4137
+ console.error(!up
4138
+ ? ' The daemon is not running — `beehaven start --headless`'
4139
+ : expired
4140
+ ? ' The daemon is up but your token has EXPIRED — `beehaven login` (a restart will not refresh it)'
4141
+ : ' The daemon is up and your token is valid; the relay socket dropped — `beehaven restart`');
4142
+ console.error(color.dim(` (${err?.message ?? err})`));
4143
+ return 1;
4144
+ };
4145
+ /**
4146
+ * ⚠⚠ `--token-only`: THE RAW TOKEN ON STDOUT AND NOTHING ELSE, for
4147
+ * `… | gh secret set CLEARLY_MCP_TOKEN`. The rules (stderr for every message, `--expires`
4148
+ * required, the caller's own agent) live in commands/mcp-token.ts, where the tests call them.
4149
+ * It branches off HERE, before the reuse check (print never reuses) and before anything below
4150
+ * writes to stdout.
4151
+ */
4152
+ if (values['token-only']) {
4153
+ process.exitCode = await runMcpTokenOnly({
4154
+ sub: mcpSub,
4155
+ flags: values,
4156
+ defaultLabel: defaultMcpLabel,
4157
+ agentId: activeMcpAgent?.agentId,
4158
+ mint: (input) => _rpc('create-mcp-token', input, 30_000),
4159
+ out: (s) => { process.stdout.write(s); },
4160
+ err: (s) => { process.stderr.write(s); },
4161
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
4162
+ onMintError: explainMintFailure,
4163
+ });
4164
+ break;
4165
+ }
4166
+ // `--label` (else `--name`), `--expires <days>` and the caller's agent, for every mint below.
4167
+ const mintInput = mcpMintInput(values, { label: defaultMcpLabel, agentId: activeMcpAgent?.agentId });
4168
+ if ('error' in mintInput) {
4169
+ console.error(` ${color.red('✗')} ${mintInput.error}`);
4170
+ process.exit(2);
4171
+ }
4172
+ const label = mintInput.input.label;
4080
4173
  let res;
4081
4174
  /**
4082
4175
  * ⚠⚠ REUSE THE TOKEN ALREADY INSTALLED RATHER THAN MINTING ANOTHER. Minting
@@ -4121,54 +4214,11 @@ complete -F _beehaven_completions beehaven`);
4121
4214
  }
4122
4215
  }
4123
4216
  try {
4124
- if (!res) {
4125
- res = await _rpc('create-mcp-token', {
4126
- label,
4127
- ...(activeMcpAgent?.agentId ? { agentId: activeMcpAgent.agentId } : {}),
4128
- }, 30_000);
4129
- }
4217
+ if (!res)
4218
+ res = await _rpc('create-mcp-token', mintInput.input, 30_000);
4130
4219
  }
4131
4220
  catch (err) {
4132
- /**
4133
- * ⚠⚠ DO NOT ECHO THE RAW ERROR — IT SENT PEOPLE TO THE WRONG FIX.
4134
- *
4135
- * Observed: this printed "Cannot connect to daemon. Run `beehaven start --headless`" in the
4136
- * same second that `beehaven status` reported "Daemon: running". The daemon was up and the
4137
- * RELAY had dropped. Following the advice runs into the single-instance guard, which
4138
- * refuses — so the message costs a cycle and teaches the wrong model of the failure.
4139
- *
4140
- * The CLI already distinguishes these three states carefully (see `status`), precisely
4141
- * because the fixes are mutually exclusive: a restart will NOT refresh an expired token,
4142
- * and a re-login will NOT revive a dropped socket. This path bypassed all of it by
4143
- * forwarding whatever string the transport happened to raise.
4144
- *
4145
- * Re-probe and say what is actually true.
4146
- */
4147
- const { isDaemonRunning } = await import('./daemon-client.js');
4148
- let up = false;
4149
- try {
4150
- up = await isDaemonRunning();
4151
- }
4152
- catch {
4153
- up = false;
4154
- }
4155
- let expired = false;
4156
- try {
4157
- const cfg = JSON.parse(readFileSync(join(homedir(), '.beehaven', 'config.json'), 'utf8'));
4158
- const payload = JSON.parse(Buffer.from(String(cfg.token).split('.')[1], 'base64').toString());
4159
- expired = !payload.exp || Date.now() / 1000 > payload.exp - 60;
4160
- }
4161
- catch {
4162
- expired = true;
4163
- }
4164
- console.error(` ${color.red('✗')} Could not mint a token.`);
4165
- console.error(!up
4166
- ? ' The daemon is not running — `beehaven start --headless`'
4167
- : expired
4168
- ? ' The daemon is up but your token has EXPIRED — `beehaven login` (a restart will not refresh it)'
4169
- : ' The daemon is up and your token is valid; the relay socket dropped — `beehaven restart`');
4170
- console.error(color.dim(` (${err?.message ?? err})`));
4171
- process.exit(1);
4221
+ process.exit(await explainMintFailure(err));
4172
4222
  }
4173
4223
  if (!res?.key) {
4174
4224
  console.error(` ${color.red('✗')} ${res?.error ?? 'No token returned.'}`);