@clearlysh/beehaven 0.8.35 → 0.8.36

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 = '31746b91dfbf';
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-29T05:50Z';
59
59
  /**
60
60
  * The build identity as one short string, for `--version`.
61
61
  *
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:
@@ -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 },
@@ -1461,6 +1466,8 @@ async function run() {
1461
1466
  client: { type: 'string' },
1462
1467
  /** `beehaven mcp install --rotate` — mint a new token instead of reusing the installed one. */
1463
1468
  rotate: { type: 'boolean' },
1469
+ /** `beehaven mcp-config --token-only --expires <days>` — the raw token alone on stdout, for piping. */
1470
+ 'token-only': { type: 'boolean' },
1464
1471
  password: { type: 'string' },
1465
1472
  email: { type: 'string' },
1466
1473
  expires: { type: 'string' },
@@ -3033,10 +3040,19 @@ async function run() {
3033
3040
  // own copy aimed at /auth/beehaven, a route the web app no longer serves.
3034
3041
  // What follows the sign-in — restarting the daemon, teaching the agents —
3035
3042
  // stays here, because the daemon fallback must not restart itself.
3043
+ // AWO-402 — the device-code flow for a machine with no browser of its own: forced with
3044
+ // `--device-code`, chosen automatically over SSH / CI / a display-less Linux, and never when
3045
+ // the person asked for `--browser`.
3036
3046
  let captured;
3037
3047
  try {
3038
- const { browserLogin } = await import('./browser-login.js');
3039
- captured = await browserLogin();
3048
+ const { browserLogin, deviceLogin, preferDeviceLogin } = await import('./browser-login.js');
3049
+ const useDevice = Boolean(values['device-code']) || (!values.browser && preferDeviceLogin());
3050
+ if (useDevice && !values['device-code']) {
3051
+ console.log(color.dim(' No browser on this machine, so signing in with a code (`beehaven login --browser` to open one here).'));
3052
+ }
3053
+ captured = useDevice
3054
+ ? await deviceLogin({ client: `beehaven ${getVersion()} (${process.platform})` })
3055
+ : await browserLogin();
3040
3056
  }
3041
3057
  catch (err) {
3042
3058
  console.error(` ${color.red('✗')} ${err?.message ?? err}`);
@@ -3170,6 +3186,7 @@ async function run() {
3170
3186
  mcp: !!values.mcp,
3171
3187
  print: !!values.print,
3172
3188
  remove: !!values.remove,
3189
+ notTracked: Boolean(values['not-tracked']),
3173
3190
  });
3174
3191
  break;
3175
3192
  }
@@ -4073,10 +4090,82 @@ complete -F _beehaven_completions beehaven`);
4073
4090
  const tgtAddress = tgt?.address ?? 'home';
4074
4091
  const mcpEntry = entryNameFor(tgtAddress);
4075
4092
  const wsId = isHomeAddress(tgtAddress) ? '' : tgtAddress.split('/').slice(1).join('/');
4076
- const label = values.name || `${mcpEntry} · ${new Date().toISOString().slice(0, 10)}`;
4093
+ const defaultMcpLabel = `${mcpEntry} · ${new Date().toISOString().slice(0, 10)}`;
4077
4094
  const { rpcOnce: _rpc } = await import('./ipc-client.js');
4078
4095
  const { resolveActiveAgent } = await import('./agent-state.js');
4096
+ const { mcpMintInput, runMcpTokenOnly } = await import('./commands/mcp-token.js');
4079
4097
  const activeMcpAgent = resolveActiveAgent(values);
4098
+ /**
4099
+ * ⚠⚠ DO NOT ECHO THE RAW ERROR — IT SENT PEOPLE TO THE WRONG FIX.
4100
+ *
4101
+ * Observed: this printed "Cannot connect to daemon. Run `beehaven start --headless`" in the
4102
+ * same second that `beehaven status` reported "Daemon: running". The daemon was up and the
4103
+ * RELAY had dropped. Following the advice runs into the single-instance guard, which
4104
+ * refuses — so the message costs a cycle and teaches the wrong model of the failure.
4105
+ *
4106
+ * The CLI already distinguishes these three states carefully (see `status`), precisely
4107
+ * because the fixes are mutually exclusive: a restart will NOT refresh an expired token,
4108
+ * and a re-login will NOT revive a dropped socket. This path bypassed all of it by
4109
+ * forwarding whatever string the transport happened to raise.
4110
+ *
4111
+ * Re-probe and say what is actually true. Stderr only, and it returns the exit code, so the
4112
+ * `--token-only` path can use it without touching stdout.
4113
+ */
4114
+ const explainMintFailure = async (err) => {
4115
+ const { isDaemonRunning } = await import('./daemon-client.js');
4116
+ let up = false;
4117
+ try {
4118
+ up = await isDaemonRunning();
4119
+ }
4120
+ catch {
4121
+ up = false;
4122
+ }
4123
+ let expired = false;
4124
+ try {
4125
+ const cfg = JSON.parse(readFileSync(join(homedir(), '.beehaven', 'config.json'), 'utf8'));
4126
+ const payload = JSON.parse(Buffer.from(String(cfg.token).split('.')[1], 'base64').toString());
4127
+ expired = !payload.exp || Date.now() / 1000 > payload.exp - 60;
4128
+ }
4129
+ catch {
4130
+ expired = true;
4131
+ }
4132
+ console.error(` ${color.red('✗')} Could not mint a token.`);
4133
+ console.error(!up
4134
+ ? ' The daemon is not running — `beehaven start --headless`'
4135
+ : expired
4136
+ ? ' The daemon is up but your token has EXPIRED — `beehaven login` (a restart will not refresh it)'
4137
+ : ' The daemon is up and your token is valid; the relay socket dropped — `beehaven restart`');
4138
+ console.error(color.dim(` (${err?.message ?? err})`));
4139
+ return 1;
4140
+ };
4141
+ /**
4142
+ * ⚠⚠ `--token-only`: THE RAW TOKEN ON STDOUT AND NOTHING ELSE, for
4143
+ * `… | gh secret set CLEARLY_MCP_TOKEN`. The rules (stderr for every message, `--expires`
4144
+ * required, the caller's own agent) live in commands/mcp-token.ts, where the tests call them.
4145
+ * It branches off HERE, before the reuse check (print never reuses) and before anything below
4146
+ * writes to stdout.
4147
+ */
4148
+ if (values['token-only']) {
4149
+ process.exitCode = await runMcpTokenOnly({
4150
+ sub: mcpSub,
4151
+ flags: values,
4152
+ defaultLabel: defaultMcpLabel,
4153
+ agentId: activeMcpAgent?.agentId,
4154
+ mint: (input) => _rpc('create-mcp-token', input, 30_000),
4155
+ out: (s) => { process.stdout.write(s); },
4156
+ err: (s) => { process.stderr.write(s); },
4157
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
4158
+ onMintError: explainMintFailure,
4159
+ });
4160
+ break;
4161
+ }
4162
+ // `--label` (else `--name`), `--expires <days>` and the caller's agent, for every mint below.
4163
+ const mintInput = mcpMintInput(values, { label: defaultMcpLabel, agentId: activeMcpAgent?.agentId });
4164
+ if ('error' in mintInput) {
4165
+ console.error(` ${color.red('✗')} ${mintInput.error}`);
4166
+ process.exit(2);
4167
+ }
4168
+ const label = mintInput.input.label;
4080
4169
  let res;
4081
4170
  /**
4082
4171
  * ⚠⚠ REUSE THE TOKEN ALREADY INSTALLED RATHER THAN MINTING ANOTHER. Minting
@@ -4121,54 +4210,11 @@ complete -F _beehaven_completions beehaven`);
4121
4210
  }
4122
4211
  }
4123
4212
  try {
4124
- if (!res) {
4125
- res = await _rpc('create-mcp-token', {
4126
- label,
4127
- ...(activeMcpAgent?.agentId ? { agentId: activeMcpAgent.agentId } : {}),
4128
- }, 30_000);
4129
- }
4213
+ if (!res)
4214
+ res = await _rpc('create-mcp-token', mintInput.input, 30_000);
4130
4215
  }
4131
4216
  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);
4217
+ process.exit(await explainMintFailure(err));
4172
4218
  }
4173
4219
  if (!res?.key) {
4174
4220
  console.error(` ${color.red('✗')} ${res?.error ?? 'No token returned.'}`);
@@ -14,9 +14,12 @@
14
14
  * means both converge and either uninstaller removes it. `agentInstructionParity.test.mjs` in the
15
15
  * root repo pins the two copies together.
16
16
  *
17
- * ⚠ CLAUDE GETS THE SHORT POINTER — `~/.claude/CLAUDE.md` is prepended to every Claude Code
18
- * session in every repository, so it carries a pointer to the skills, not a manual. Same rule as
19
- * the Mac app; see its note on `AGENT_INSTRUCTION_FILES`.
17
+ * ⚠ ONE TRACKED BLOCK FOR EVERY AGENT, MCP OR CLI (founder 2026-10-28, AWO-401 follow-up: anyone
18
+ * using Clearly gets the mandatory block). It is short (~15 lines) because `~/.claude/CLAUDE.md` is
19
+ * prepended to every Claude Code session in every repository; the exact verbs stay in the skills.
20
+ * It has an escape hatch — a project's AGENTS.md or the person can say a project is not tracked —
21
+ * and a project's own Clearly block takes precedence. The web copy for pasting (Cursor's user
22
+ * rules) is the monorepo's lib/connections/agentsGlobal.ts, pinned to this one by a test.
20
23
  *
21
24
  * ⚠ ONLY OUR BLOCK IS EVER TOUCHED. Text around it is preserved byte for byte; a file with no
22
25
  * block gets one appended; removal deletes the block and nothing else.
@@ -41,34 +44,24 @@ export function instructionBlock() {
41
44
  BLOCK_START,
42
45
  `<!-- ${MANAGED_MARKER}. Edits inside this block are overwritten on app launch; edit outside it. -->`,
43
46
  '',
44
- '## Clearly — drive it with the `beehaven` CLI',
47
+ '## Clearly workspace: track your work there',
45
48
  '',
46
- 'Clearly (clearly.sh) is a workspace — documents, projects, boards, tickets and a design',
47
- 'canvas. Reach it from the shell with the `beehaven` CLI, NOT an HTTP API you construct.',
49
+ 'This person keeps their plans, tickets, documents and designs in Clearly (clearly.sh). Track every',
50
+ 'task there, even unasked and in any project, unless the project\'s AGENTS.md or CLAUDE.md has the line',
51
+ '`Clearly: not tracked`, or the person says not to track it. A project\'s own Clearly block in AGENTS.md',
52
+ 'takes precedence over this one.',
48
53
  '',
49
- '⚠ SIGN IN FIRST. Every call is attributed to an agent identity, and without one the CLI',
50
- 'refuses. `login` MINTS the name if it does not exist yet, and binds this terminal:',
54
+ 'Use whichever route this session has: the `clearly_*` MCP tools, or the `beehaven` CLI.',
51
55
  '',
52
- '```sh',
53
- 'beehaven agent login <your-name> # required — mints + binds, once per session',
54
- 'beehaven connect home # target the personal workspace',
55
- 'beehaven actions # list every action available there',
56
- 'beehaven call <action> \'{...}\'',
57
- '```',
56
+ '- Start. CLI: `beehaven agent login <your-name> --label "<the task>"` (claude, codex, gemini…), then',
57
+ ' read the brief it prints; if the daemon is not running, `beehaven start --headless`. MCP: you are',
58
+ ' already signed in.',
59
+ '- Each task: search first, then create a ticket before changing code or files and move it to',
60
+ ' in-progress. File what you make into the project, record decisions as comments, and mark it done',
61
+ ' only after verifying. Questions and reads need no ticket.',
62
+ '- End: a handoff, `beehaven agent handoff "<done · next>"` on the CLI or a comment on the work ticket.',
58
63
  '',
59
- 'Read the full skills before driving it — they carry the exact verbs and node shapes:',
60
- '',
61
- '- `~/.<agent>/skills/clearly-agent/SKILL.md` — **read this first**: identity, sessions, your brief',
62
- '- `~/.<agent>/skills/beehaven-cli/SKILL.md` — connect / call / sql / logs, the daemon',
63
- '- `~/.<agent>/skills/clearly-workspace/SKILL.md` — audit what a workspace holds and who made it',
64
- '- `~/.<agent>/skills/clearly-canvas/SKILL.md` — build + perceive a canvas, comm blocks, code diffs',
65
- '- `~/.<agent>/skills/design-craft/SKILL.md` — **load before any visual work**: grid, type, colour, the studio pass',
66
- '- `~/.<agent>/skills/layout-systems/SKILL.md` — grids and systems across many artboards',
67
- '- `~/.<agent>/skills/pair-on-canvas/SKILL.md` — drive a coding task from a spec on the canvas',
68
- '- `~/.<agent>/skills/codebase-map/SKILL.md` — draw a repo’s architecture on the canvas',
69
- '',
70
- 'If a command reports the daemon is unreachable, `beehaven login` re-auths and',
71
- '`beehaven start --headless` starts a daemon when the Mac app is not running.',
64
+ 'Detail is in the `clearly-agent` skill (`beehaven skills install` puts the skills in place).',
72
65
  BLOCK_END,
73
66
  ].join('\n');
74
67
  }
@@ -78,9 +71,24 @@ export function claudeBlock() {
78
71
  BLOCK_START,
79
72
  `<!-- ${MANAGED_MARKER}. Edits inside this block are overwritten on app launch; edit outside it. -->`,
80
73
  '',
81
- '**Clearly workspace** — drive it from the shell with the `beehaven` CLI (documents, projects,',
82
- 'boards, tickets, canvas). Sign in once per session first: `beehaven agent login <name>` —',
83
- 'it mints the identity if new. Full detail is in the skills, `clearly-agent` first.',
74
+ '## Clearly workspace: track your work there',
75
+ '',
76
+ 'This person keeps their plans, tickets, documents and designs in Clearly (clearly.sh). Track every',
77
+ 'task there, even unasked and in any project, unless the project\'s AGENTS.md or CLAUDE.md has the line',
78
+ '`Clearly: not tracked`, or the person says not to track it. A project\'s own Clearly block in AGENTS.md',
79
+ 'takes precedence over this one.',
80
+ '',
81
+ 'Use whichever route this session has: the `clearly_*` MCP tools, or the `beehaven` CLI.',
82
+ '',
83
+ '- Start. CLI: `beehaven agent login <your-name> --label "<the task>"` (claude, codex, gemini…), then',
84
+ ' read the brief it prints; if the daemon is not running, `beehaven start --headless`. MCP: you are',
85
+ ' already signed in.',
86
+ '- Each task: search first, then create a ticket before changing code or files and move it to',
87
+ ' in-progress. File what you make into the project, record decisions as comments, and mark it done',
88
+ ' only after verifying. Questions and reads need no ticket.',
89
+ '- End: a handoff, `beehaven agent handoff "<done · next>"` on the CLI or a comment on the work ticket.',
90
+ '',
91
+ 'Detail is in the `clearly-agent` skill (`beehaven skills install` puts the skills in place).',
84
92
  BLOCK_END,
85
93
  ].join('\n');
86
94
  }
@@ -23,7 +23,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
23
23
  import { join, resolve } from 'path';
24
24
  import { execFileSync } from 'child_process';
25
25
  import { color } from '../spinner.js';
26
- import { AGENTS_MD_START, AGENTS_MD_END, LEGACY_BLOCK_MARKERS, CLAUDE_MD_IMPORT, agentsMdBlock } from '../agents-md-text.js';
26
+ import { AGENTS_MD_START, AGENTS_MD_END, LEGACY_BLOCK_MARKERS, CLAUDE_MD_IMPORT, agentsMdBlock, agentsMdOptOutBlock } from '../agents-md-text.js';
27
27
  /**
28
28
  * The Claude Code permission that lets an agent run `beehaven` without a prompt per call (AWO-401).
29
29
  * The tracked block makes several calls a task; a prompt on each one pushes people to turn tracking
@@ -141,6 +141,11 @@ export function upsertAgentsMdBlock(text, block) {
141
141
  return spliceBlock(text, block, AGENTS_MD_START, AGENTS_MD_END);
142
142
  }
143
143
  /** `mcp` is accepted for older instructions and changes nothing: there is one block (AWO-401). */
144
+ /**
145
+ * `notTracked` (AWO-403) writes the opt-out — the line `Clearly: not tracked` between the same markers — so the global
146
+ * block every agent carries leaves this project alone. It skips the `beehaven` permission rule (nothing here will call
147
+ * it); re-running without the flag puts the tracked block back.
148
+ */
144
149
  export function writeProjectAgentsMd(opts) {
145
150
  const root = projectRoot(resolve(opts.dir));
146
151
  const agentsPath = join(root, 'AGENTS.md');
@@ -156,7 +161,7 @@ export function writeProjectAgentsMd(opts) {
156
161
  files.push(writeClaudeSettings(root, true));
157
162
  return { root, files };
158
163
  }
159
- const next = upsertAgentsMdBlock(before, agentsMdBlock());
164
+ const next = upsertAgentsMdBlock(before, opts.notTracked ? agentsMdOptOutBlock() : agentsMdBlock());
160
165
  if (next !== before)
161
166
  writeFileSync(agentsPath, next);
162
167
  files.push({ path: agentsPath, change: !had ? 'created' : next !== before ? 'updated' : 'current' });
@@ -173,17 +178,18 @@ export function writeProjectAgentsMd(opts) {
173
178
  else {
174
179
  files.push({ path: claudePath, change: 'current' });
175
180
  }
176
- files.push(writeClaudeSettings(root, false));
181
+ if (!opts.notTracked)
182
+ files.push(writeClaudeSettings(root, false));
177
183
  return { root, files };
178
184
  }
179
185
  export function cmdAgentsMd(dir, opts) {
180
186
  if (opts.print) {
181
- process.stdout.write(agentsMdBlock() + '\n');
187
+ process.stdout.write((opts.notTracked ? agentsMdOptOutBlock() : agentsMdBlock()) + '\n');
182
188
  return 0;
183
189
  }
184
190
  let r;
185
191
  try {
186
- r = writeProjectAgentsMd({ dir: dir ?? process.cwd(), mcp: opts.mcp, remove: opts.remove });
192
+ r = writeProjectAgentsMd({ dir: dir ?? process.cwd(), mcp: opts.mcp, remove: opts.remove, notTracked: opts.notTracked });
187
193
  }
188
194
  catch (err) {
189
195
  console.error(` ${color.red('✗')} ${err?.message ?? err}`);
@@ -390,13 +390,23 @@ const PAGES = {
390
390
  * where they have already found the verb.
391
391
  */
392
392
  login: {
393
- synopsis: 'beehaven login',
393
+ synopsis: 'beehaven login [--device-code | --browser]',
394
394
  description: `Sign in. Opens your browser, then starts (or restarts) the daemon so the
395
395
  new tokens take effect — the daemon reads credentials at startup and has no hot-reload.
396
396
 
397
+ No browser on this machine (SSH, a container, a remote box)? \`--device-code\` prints a short
398
+ code; open the link it shows on any device, sign in and approve, and this machine is signed in.
399
+ It is chosen automatically over SSH, in CI and on Linux with no display; \`--browser\` opens
400
+ one here anyway.
401
+
397
402
  A restart alone will NOT refresh an expired token; \`login\` is the fix for that.`,
403
+ flags: [
404
+ { name: '--device-code', desc: 'Sign in by approving a code on another device' },
405
+ { name: '--browser', desc: 'Open a browser on this machine even over SSH' },
406
+ ],
398
407
  examples: [
399
408
  { cmd: 'beehaven login' },
409
+ { cmd: 'beehaven login --device-code', note: 'on a machine with no browser' },
400
410
  { cmd: 'beehaven status', note: 'confirms the relay actually connected' },
401
411
  ],
402
412
  related: ['logout', 'whoami', 'status', 'doctor'],
@@ -414,7 +424,7 @@ const PAGES = {
414
424
  related: ['login', 'guide'],
415
425
  },
416
426
  'agents-md': {
417
- synopsis: 'beehaven agents-md [dir] [--print] [--remove]',
427
+ synopsis: 'beehaven agents-md [dir] [--not-tracked] [--print] [--remove]',
418
428
  description: `Add the Clearly block to this project's AGENTS.md, so every agent that opens the
419
429
  repository — a teammate's Codex or Claude Code, a cloud agent, CI — tracks its work in the
420
430
  workspace: sign in, a ticket for every task, a handoff at the end. One block serves agents on
@@ -427,6 +437,7 @@ const PAGES = {
427
437
  already points the agents on THIS machine at Clearly (their global instruction files); this is
428
438
  the shared, committed copy for everyone who opens the repository.`,
429
439
  flags: [
440
+ { name: '--not-tracked', desc: 'Write the line `Clearly: not tracked` instead, so agents leave this project out' },
430
441
  { name: '--print', desc: 'Print the block and write nothing' },
431
442
  { name: '--remove', desc: 'Take the block out again' },
432
443
  { name: '--mcp', desc: 'Accepted for older instructions; the block is the same for MCP and the CLI' },
@@ -1690,7 +1701,7 @@ const PAGES = {
1690
1701
  related: ['context', 'pwd', 'workspace'],
1691
1702
  },
1692
1703
  'mcp-config': {
1693
- synopsis: 'beehaven mcp-config [--name <server-name>]',
1704
+ synopsis: 'beehaven mcp-config [--label <text>] [--expires <days>] [--token-only]',
1694
1705
  description: `Print the MCP server block instead of writing it — for a client
1695
1706
  \`mcp install\` does not know about, or a config you keep in version control.
1696
1707
 
@@ -1699,9 +1710,30 @@ const PAGES = {
1699
1710
  revoke with \`beehaven mcp remove\` rather than just deleting the text.
1700
1711
 
1701
1712
  ⚠ The block is minted for the workspace you are CONNECTED to — \`beehaven
1702
- pwd\` first.`,
1713
+ pwd\` first.
1714
+
1715
+ \`--token-only\` is for CI: stdout gets the raw token and one newline,
1716
+ nothing else; every other message goes to stderr, so it pipes straight
1717
+ into a secret store. It needs \`--expires <days>\` (\`0\` = never),
1718
+ because a CI secret is set once and rarely looked at again.
1719
+
1720
+ ⚠⚠ WITH --token-only, STDOUT IS A LIVE SECRET. Pipe it; do not let it
1721
+ land in a terminal, a log or a file you commit. Use \`set -o pipefail\` so a
1722
+ refused or failed mint fails the job instead of storing an empty secret.
1723
+ The token is bound to YOUR agent identity (the one \`agent whoami\` shows),
1724
+ like \`mcp install\`; revoke it with the \`revoke-mcp-token\` line the
1725
+ command prints to stderr.`,
1726
+ flags: [
1727
+ { name: '--token-only', desc: 'Print only the ck_mcp_ token on stdout (messages on stderr). Needs --expires.' },
1728
+ { name: '--expires <days>', desc: 'Token lifetime in whole days; 0 = never expires. Default without it: 365.' },
1729
+ { name: '--label <text>', desc: 'The token\'s label in Settings and list-mcp-tokens. --name is the older spelling; it labels the token, not the server entry.' },
1730
+ ],
1703
1731
  examples: [
1704
1732
  { cmd: 'beehaven mcp-config --name clearly' },
1733
+ {
1734
+ cmd: 'set -o pipefail; beehaven mcp-config --token-only --expires 90 --label ci-github | gh secret set CLEARLY_MCP_TOKEN',
1735
+ note: 'CI secret: a 90-day token, never shown on screen',
1736
+ },
1705
1737
  { cmd: 'beehaven mcp install', note: 'write it for detected clients instead' },
1706
1738
  { cmd: 'beehaven mcp remove', note: 'revoke the token' },
1707
1739
  ],
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `beehaven mcp-config --token-only` — the raw `ck_mcp_` token on stdout and nothing else, so it
3
+ * can be piped straight into a secret store (founder request, 2026-10-29):
4
+ *
5
+ * set -o pipefail
6
+ * beehaven mcp-config --token-only --expires 90 --label ci-github | gh secret set CLEARLY_MCP_TOKEN
7
+ *
8
+ * ⚠⚠ STDOUT IS THE SECRET, SO STDOUT CARRIES NOTHING ELSE. One stray byte on stdout (a ✓, a blank
9
+ * line, an update notice) becomes part of the stored secret, and the CI job then fails with an
10
+ * auth error that names nothing. Every message here, refusals included, goes to stderr, and on any
11
+ * failure stdout stays EMPTY, so the pipe stores nothing that looks like a working token.
12
+ *
13
+ * ⚠ AN EXPIRY IS REQUIRED. A token minted for CI goes into a secret store nobody re-reads; leaving
14
+ * `--expires` off would give it the server's 365-day default by omission, an unbounded secret in
15
+ * practice. `--expires 0` is the explicit "never expires" for a caller who means it.
16
+ *
17
+ * ⚠ THE TOKEN IS THE CALLER'S OWN. It is bound to the active agent identity exactly as
18
+ * `mcp install` binds it, and the server refuses a token for any other agent
19
+ * (`agentTokenRefusal`, apps/cloudflare/src/rpc/mcp-token-rpcs.ts).
20
+ *
21
+ * The decisions live here, not inline in cli.ts, so the tests can call them: a grep over cli.ts
22
+ * cannot tell "stdout holds exactly the token" from "the line moved".
23
+ */
24
+ import { color } from '../spinner.js';
25
+ /**
26
+ * `--expires <days>`: a whole number of days, `0` = no expiry. Absent means "not given".
27
+ * (Results are told apart with `'error' in r`: the CLI compiles without strictNullChecks, where
28
+ * an `ok: true | false` discriminant does not narrow.)
29
+ */
30
+ export function parseExpiresDays(raw) {
31
+ if (raw === undefined || raw === null)
32
+ return {};
33
+ const s = String(raw).trim();
34
+ if (!/^\d+$/.test(s) || !Number.isSafeInteger(Number(s))) {
35
+ return { error: `--expires takes a whole number of days (0 = never expires), not "${s}".` };
36
+ }
37
+ return { days: Number(s) };
38
+ }
39
+ /**
40
+ * The `create-mcp-token` input for every mint in `mcp` / `mcp-config`: `--label` (else `--name`,
41
+ * else the dated default), `--expires` as `expiresInDays` when given, and the caller's own agent.
42
+ */
43
+ export function mcpMintInput(flags, defaults) {
44
+ const expiry = parseExpiresDays(flags.expires);
45
+ if ('error' in expiry)
46
+ return expiry;
47
+ const text = (v) => (typeof v === 'string' ? v.trim() : '');
48
+ const input = { label: text(flags.label) || text(flags.name) || defaults.label };
49
+ if (expiry.days !== undefined)
50
+ input.expiresInDays = expiry.days;
51
+ if (defaults.agentId)
52
+ input.agentId = defaults.agentId;
53
+ return { input };
54
+ }
55
+ /** Why `--token-only` cannot run as asked, or null. */
56
+ export function tokenOnlyRefusal(sub, flags) {
57
+ if (sub !== 'print') {
58
+ return '--token-only prints a token for you to store; it does not install one.'
59
+ + ' Use: beehaven mcp-config --token-only --expires <days>';
60
+ }
61
+ if (flags.expires === undefined || flags.expires === null) {
62
+ return '--token-only needs --expires <days>. The token is headed for a CI secret that nobody'
63
+ + ' re-reads, and without an expiry it would stay valid for a year by default.'
64
+ + ' Pass --expires 90, or --expires 0 if it should never expire.';
65
+ }
66
+ return null;
67
+ }
68
+ /** A `ck_mcp_` bearer and nothing else: no whitespace, no JSON, no error text. */
69
+ const MCP_TOKEN = /^ck_mcp_[A-Za-z0-9_-]+$/;
70
+ /** Runs `--token-only`; returns the exit code. Writes stdout only on success. */
71
+ export async function runMcpTokenOnly(run) {
72
+ const refusal = tokenOnlyRefusal(run.sub, run.flags);
73
+ if (refusal) {
74
+ run.err(` ${color.red('✗')} ${refusal}\n`);
75
+ return 2;
76
+ }
77
+ const built = mcpMintInput(run.flags, { label: run.defaultLabel, agentId: run.agentId });
78
+ if ('error' in built) {
79
+ run.err(` ${color.red('✗')} ${built.error}\n`);
80
+ return 2;
81
+ }
82
+ let res;
83
+ try {
84
+ res = await run.mint(built.input);
85
+ }
86
+ catch (e) {
87
+ if (run.onMintError)
88
+ return run.onMintError(e);
89
+ run.err(` ${color.red('✗')} Could not mint a token: ${e?.message ?? e}\n`);
90
+ return 1;
91
+ }
92
+ const key = typeof res?.key === 'string' ? res.key : '';
93
+ if (!MCP_TOKEN.test(key)) {
94
+ run.err(` ${color.red('✗')} ${res?.error ?? 'No ck_mcp_ token returned.'}\n`);
95
+ return 1;
96
+ }
97
+ run.out(`${key}\n`);
98
+ const prefix = String(res.prefix ?? key.slice(0, 12));
99
+ const now = run.now?.() ?? Date.now();
100
+ const expires = typeof res.expiresAt === 'number'
101
+ ? `expires ${new Date(res.expiresAt).toISOString().slice(0, 10)}`
102
+ : built.input.expiresInDays === 0
103
+ ? 'never expires'
104
+ : `expires in ${built.input.expiresInDays} days (from ${new Date(now).toISOString().slice(0, 10)})`;
105
+ const agent = res.agentName || res.agentId || built.input.agentId;
106
+ run.err(` ${color.green('✓')} Minted ${prefix} ${color.dim([res.label ?? built.input.label, expires, agent ? `agent ${agent}` : ''].filter(Boolean).join(' · '))}\n`);
107
+ run.err(color.dim(' stdout carried a live workspace credential, shown this once and stored hashed.\n'));
108
+ if (run.stdoutIsTTY) {
109
+ run.err(` ${color.yellow('⚠')} stdout is a terminal, so the token is on screen. Pipe it into a secret store instead.\n`);
110
+ }
111
+ run.err(color.dim(` Revoke with: beehaven call revoke-mcp-token '{"prefix":"${prefix}"}'\n`));
112
+ return 0;
113
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ReconnectBackoff — when to redial a dropped socket, for the daemon's relay socket
3
+ * (`relay.ts`) and its agent sessions (`agent-sessions.ts`).
4
+ *
5
+ * ⚠ PPO-532. Both sockets reset their backoff the moment a socket OPENED. A workspace that
6
+ * runs out of memory while hydrating a new socket accepts it, then drops it (1006) seconds
7
+ * later — so every retry was "the first" and came back in ~1 s. On the staging Playground
8
+ * home that was a reconnect → hydrate → out-of-memory → reset loop, 46 reconnects an hour per
9
+ * socket, with the two sockets closing in pairs every ~13 s.
10
+ *
11
+ * Now a connection only counts as recovered once it has stayed open for `healthyMs` (60 s);
12
+ * until then the delay keeps doubling. Each delay is FULL JITTER — uniform over
13
+ * [0, min(cap, base·2^attempt)) — so the daemon socket and its agent sockets (which drop
14
+ * together) do not come back together. Same shape as HiveClient's PPO-249 backoff.
15
+ *
16
+ * No timers: the healthy check is a timestamp read when the socket closes (or when
17
+ * `attempts` is read), so there is nothing to clean up on shutdown.
18
+ */
19
+ export const RECONNECT_BASE_MS = 1_000;
20
+ export const RECONNECT_CAP_MS = 30_000;
21
+ export const RECONNECT_HEALTHY_MS = 60_000;
22
+ export class ReconnectBackoff {
23
+ attempt = 0;
24
+ openedAt = null;
25
+ baseMs;
26
+ capMs;
27
+ healthyMs;
28
+ random;
29
+ now;
30
+ constructor(opts = {}) {
31
+ this.baseMs = opts.baseMs ?? RECONNECT_BASE_MS;
32
+ this.capMs = opts.capMs ?? RECONNECT_CAP_MS;
33
+ this.healthyMs = opts.healthyMs ?? RECONNECT_HEALTHY_MS;
34
+ this.random = opts.random ?? Math.random;
35
+ this.now = opts.now ?? Date.now;
36
+ }
37
+ /** Reconnects since the last healthy connection. 0 = healthy (or never failed). */
38
+ get attempts() {
39
+ this.settle();
40
+ return this.attempt;
41
+ }
42
+ /**
43
+ * A socket opened. This does NOT reset the backoff — only staying open does. Returns the
44
+ * connection's stamp; pass it to `closed()` so a stale socket closing late cannot clear the
45
+ * clock of the socket that replaced it.
46
+ */
47
+ opened() {
48
+ const at = this.now();
49
+ this.openedAt = at;
50
+ return at;
51
+ }
52
+ /**
53
+ * A socket closed. If it had stayed open `healthyMs`, the next retry starts from the base.
54
+ * `openedAt` is the stamp `opened()` returned (null/omitted = never opened, or the current one).
55
+ */
56
+ closed(openedAt = this.openedAt) {
57
+ if (openedAt !== null && this.now() - openedAt >= this.healthyMs)
58
+ this.attempt = 0;
59
+ if (openedAt === this.openedAt)
60
+ this.openedAt = null;
61
+ }
62
+ /** Delay before the next reconnect attempt (ms), and count the attempt. */
63
+ next() {
64
+ this.settle();
65
+ const ceiling = Math.min(this.capMs, this.baseMs * 2 ** Math.min(this.attempt, 30));
66
+ this.attempt++;
67
+ return Math.round(this.random() * ceiling);
68
+ }
69
+ /** An explicit (re)start — user action, network back, watchdog: start over from the base. */
70
+ reset() {
71
+ this.attempt = 0;
72
+ this.openedAt = null;
73
+ }
74
+ settle() {
75
+ if (this.openedAt !== null && this.now() - this.openedAt >= this.healthyMs)
76
+ this.attempt = 0;
77
+ }
78
+ }
package/dist/relay.js CHANGED
@@ -10,6 +10,7 @@ import { join, dirname, resolve, sep } from 'path';
10
10
  import { execSync, execFileSync } from 'child_process';
11
11
  import WebSocket from 'ws';
12
12
  import { logger } from './logger.js';
13
+ import { ReconnectBackoff } from './reconnect-backoff.js';
13
14
  import { TerminalBlockParser } from './terminal/block-parser.js';
14
15
  import { writeIntegration, cleanupIntegration } from './terminal/shell-integration.js';
15
16
  import { transformPath } from './daemon-path-transform.js';
@@ -73,11 +74,16 @@ export class Relay {
73
74
  // ── WebSocket ─────────────────────────────────────────────────────────
74
75
  ws = null;
75
76
  wsConnecting = false;
76
- reconnectAttempts = 0;
77
+ /** ⚠ PPO-532: resets only after a connection stays open 60 s, not when one opens — see
78
+ * reconnect-backoff.ts. `reconnectAttempts` (read by the daemon's status.json) reads it. */
79
+ backoff = new ReconnectBackoff();
80
+ get reconnectAttempts() {
81
+ return this.backoff.attempts;
82
+ }
77
83
  reconnectTimer = null;
78
84
  /** Watchdog timer + the moment we first noticed we were disconnected. If the
79
85
  * relay stays down past the threshold, the watchdog forces a HARD reconnect
80
- * (terminate any zombie socket + reset backoff + redial). This is the
86
+ * (terminate any zombie socket + redial now; PPO-532 keeps the backoff). This is the
81
87
  * self-heal a HEADLESS / orphaned daemon needs — it has no Mac-app tray
82
88
  * parent to restart it, so a wedged WS (e.g. a half-open socket after system
83
89
  * sleep) would otherwise leave it "stuck reconnecting" forever. */
@@ -388,6 +394,8 @@ export class Relay {
388
394
  return;
389
395
  // Re-enable auto-reconnect in case a previous stop() set it.
390
396
  this.stopped = false;
397
+ // An explicit start (daemon boot, sign-in) is not a retry: no inherited backoff.
398
+ this.backoff.reset();
391
399
  // Validate endpoint against whitelist
392
400
  try {
393
401
  const endpointHost = new URL(this.config.endpoint.replace('wss://', 'https://').replace('ws://', 'http://')).hostname;
@@ -534,6 +542,8 @@ export class Relay {
534
542
  params.set('deviceId', String(this.deviceId).slice(0, 64));
535
543
  const url = `${this.wsBase()}/my/workspace/ws?${params.toString()}`;
536
544
  const ws = new WebSocket(url);
545
+ // This socket's healthy clock (PPO-532); null until it opens.
546
+ let openedAt = null;
537
547
  // Wait for open or first error/close before resolving
538
548
  await new Promise((resolve) => {
539
549
  const done = () => {
@@ -543,7 +553,10 @@ export class Relay {
543
553
  this.ws = ws;
544
554
  this.wsConnecting = false;
545
555
  this.connected = true;
546
- this.reconnectAttempts = 0;
556
+ // ⚠ PPO-532: opening is not recovering. A workspace that runs out of memory while
557
+ // hydrating accepts the socket and drops it seconds later; resetting here made every
558
+ // retry the first (~1 s), a reconnect loop. The backoff resets after 60 s open.
559
+ openedAt = this.backoff.opened();
547
560
  console.log(`[relay] WebSocket connected to ${this.config.endpoint}`);
548
561
  // Notify subscribers that the WebSocket is open so they
549
562
  // can pull fresh state. Wrapped because the callback
@@ -770,6 +783,7 @@ export class Relay {
770
783
  pending.reject(new Error('WebSocket closed'));
771
784
  }
772
785
  this.pendingRequests.clear();
786
+ this.backoff.closed(openedAt);
773
787
  // Always schedule reconnect while we're still configured and not
774
788
  // stopped. Previous guard `if (wasConnected)` broke the chain: if
775
789
  // a reconnect attempt failed to open, the new ws's close handler
@@ -1100,28 +1114,21 @@ export class Relay {
1100
1114
  }
1101
1115
  // ── Reconnection ──────────────────────────────────────────────────────
1102
1116
  /**
1103
- * Reconnect with exponential backoff + jitter.
1117
+ * Reconnect with exponential backoff + FULL jitter (PPO-532, reconnect-backoff.ts).
1104
1118
  *
1105
- * Backoff: 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped). Jitter adds
1106
- * 0-25% of the base delay so a fleet of clients reconnecting after a
1107
- * server outage doesn't synchronize into a thundering herd. The cap
1108
- * keeps idle daemons polling at most once a minute.
1119
+ * Delay: uniform over [0, min(30s, 1s·2^attempt)). The ±25% jitter this replaced kept the
1120
+ * daemon socket and its agent sockets — which drop together — reconnecting together. The
1121
+ * attempt count resets only once a connection has stayed open 60 s, so a socket that opens
1122
+ * and dies (an out-of-memory workspace) keeps backing off instead of retrying every second.
1109
1123
  *
1110
- * Single-flight: a reconnect already scheduled is a no-op. The timer
1111
- * is cleared on successful connect (reconnectAttempts → 0) and on
1112
- * stop().
1124
+ * Single-flight: a reconnect already scheduled is a no-op. The timer is cleared by stop()
1125
+ * and forceReconnect().
1113
1126
  */
1114
1127
  scheduleReconnect() {
1115
1128
  if (this.reconnectTimer)
1116
1129
  return;
1117
- const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60_000);
1118
- // Add 0-25% random jitter — `delay * (1 + 0..0.25)` keeps the
1119
- // floor at the exponential value so we never reconnect FASTER
1120
- // than the backoff intends, only slower.
1121
- const jitter = Math.random() * 0.25 * baseDelay;
1122
- const delay = Math.round(baseDelay + jitter);
1123
- this.reconnectAttempts++;
1124
- console.log(`[relay] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.reconnectAttempts})`);
1130
+ const delay = this.backoff.next();
1131
+ console.log(`[relay] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.backoff.attempts})`);
1125
1132
  this.reconnectTimer = setTimeout(() => {
1126
1133
  this.reconnectTimer = null;
1127
1134
  if (this.useWebSocket) {
@@ -1151,6 +1158,10 @@ export class Relay {
1151
1158
  this.disconnectedSince = Date.now();
1152
1159
  return;
1153
1160
  }
1161
+ // ⚠ PPO-532: a scheduled retry means the backoff chain is alive (≤30 s away). Forcing
1162
+ // past it turned a workspace that keeps dropping new sockets back into rapid redials.
1163
+ if (this.reconnectTimer)
1164
+ return;
1154
1165
  if (Date.now() - this.disconnectedSince > maxDownMs) {
1155
1166
  console.warn(`[relay] Watchdog: relay down ${Math.round((Date.now() - this.disconnectedSince) / 1000)}s — forcing hard reconnect`);
1156
1167
  this.disconnectedSince = Date.now();
@@ -1159,7 +1170,8 @@ export class Relay {
1159
1170
  }, 30_000);
1160
1171
  }
1161
1172
  /** Hard reconnect: drop any (possibly zombie) socket, clear pending reconnect
1162
- * state, reset the backoff so the next dial is immediate, then redial. */
1173
+ * state, then redial at once. The attempt count is kept (PPO-532): if this dial
1174
+ * fails too, the next retry continues the backoff instead of restarting at 1 s. */
1163
1175
  forceReconnect() {
1164
1176
  try {
1165
1177
  this.ws?.terminate();
@@ -1172,7 +1184,6 @@ export class Relay {
1172
1184
  clearTimeout(this.reconnectTimer);
1173
1185
  this.reconnectTimer = null;
1174
1186
  }
1175
- this.reconnectAttempts = 0;
1176
1187
  if (this.useWebSocket)
1177
1188
  this.connectWs();
1178
1189
  else
@@ -1182,7 +1193,7 @@ export class Relay {
1182
1193
  const ok = await this.sendHeartbeatHttp();
1183
1194
  if (ok) {
1184
1195
  this.connected = true;
1185
- this.reconnectAttempts = 0;
1196
+ this.backoff.reset(); // legacy HTTP has no socket to watch: a good heartbeat is recovery
1186
1197
  console.log(`[relay] Reconnected (tier: ${this.tier})`);
1187
1198
  if (this.heartbeatInterval)
1188
1199
  clearInterval(this.heartbeatInterval);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearlysh/beehaven",
3
- "version": "0.8.35",
3
+ "version": "0.8.36",
4
4
  "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
5
  "private": false,
6
6
  "type": "module",
@@ -46,6 +46,7 @@ const SOURCES = {
46
46
  'clearly-docs': 'apps/mcp-server/plugin/skills/clearly-docs/SKILL.md',
47
47
  'clearly-init': 'apps/mcp-server/plugin/skills/clearly-init/SKILL.md',
48
48
  'clearly-workflows': 'apps/mcp-server/plugin/skills/clearly-workflows/SKILL.md',
49
+ 'clearly-artifacts': 'apps/mcp-server/plugin/skills/clearly-artifacts/SKILL.md',
49
50
  };
50
51
 
51
52
  let missing = 0;
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: clearly-artifacts
3
+ description: >-
4
+ Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so: the connector cannot make a running page.
5
+ Put a self-contained interactive HTML page into a Clearly workspace, the way a
6
+ Claude artifact works: an explainer, an animation, a small tool, a calculator, a
7
+ chart that runs. Covers which of the three HTML homes runs scripts (a SITE does,
8
+ a canvas ARTIFACT card does but cannot be clicked, a NOTE never does), the exact
9
+ CLI calls, the sandbox limits, updating, filing in a project, how a person opens
10
+ it, and QA. Use when asked to publish, host or add an HTML page, demo or animation
11
+ to the workspace. Triggers: "make an artifact", "publish this HTML", "add this page /
12
+ animation / explainer to the workspace", "host this demo", "interactive page",
13
+ "put this HTML on the canvas", "why doesn't my page's script run".
14
+ ---
15
+
16
+ # Clearly artifacts — a running HTML page in the workspace
17
+
18
+ Sign in first (`clearly-agent`). `beehaven env` must name the environment you mean; staging and
19
+ production hold different workspaces.
20
+
21
+ ## 1. Pick the home — only two of the three run code
22
+
23
+ | Home | Make it with | Scripts and CSS animation | Clickable | Search, history | Opens at |
24
+ |---|---|---|---|---|---|
25
+ | **Site** | `compose-upload-init` → `file-teleport-inline` → `compose-upload-finalize` | run | yes | not in `document-search`; no version history; a re-upload overwrites | Recents or project card → `/compositions/detail?id=<id>` |
26
+ | **Canvas artifact** | `canvas-act` `canvas.add-artifact` | run | **no**: the card never takes pointer events, so it pans like a picture | no; it is part of the canvas and is deleted with it | the canvas, `/c/<compositionId>` |
27
+ | **Note** | `document-create {html}`; `page-write` (in-app chat only) | **never**: it opens in the document editor, which drops `<script>`, `<style>` and inline `<svg>` | n/a | yes, both | `/documents/<id>` |
28
+
29
+ - An interactive page someone opens, clicks through or comes back to is a **site**. This is the
30
+ Claude-artifact equivalent and the default.
31
+ - A page that only needs to be **seen** beside other cards (a looping animation, a live chart in a
32
+ review board) is a **canvas artifact**. Buttons inside it cannot be pressed.
33
+ - Prose the team will read, search and edit is a **note**. Never put a script in a note: it
34
+ is stored, and then nothing ever runs it. Editing the note in the app rewrites its body.
35
+
36
+ No home gives a public link that runs. Every share link (`/r/…`) is served with
37
+ `script-src 'none'`, and a shared composition renders as a static picture. Say so when someone asks to
38
+ publish an interactive page outside the workspace.
39
+
40
+ ## 2. Write it self-contained
41
+
42
+ Both running homes inject the page with `srcdoc` into `<iframe sandbox="allow-scripts">`, with no
43
+ `allow-same-origin`. The page gets an opaque origin (`window.origin === "null"`).
44
+
45
+ - **One file.** `<!doctype html>`, `<meta name="viewport" content="width=device-width, initial-scale=1">`,
46
+ CSS in `<style>`, JS in `<script>`, art as inline SVG, images and fonts as `data:` URIs,
47
+ system font stacks. Make no network requests and load no CDN scripts.
48
+ - **The sandbox blocks:** `localStorage`, `sessionStorage`, cookies and IndexedDB (accessing them
49
+ throws), `alert`/`confirm`/`prompt`, form submission, popups, `window.open` and top-level
50
+ navigation. Keep state in memory, and wrap any storage access in `try`.
51
+ - **A site's other files are rarely reached.** The preview rewrites only `src="…"`/`href="…"`
52
+ in the entry HTML that name a build file exactly (up to 60 files). It does not rewrite `url()`
53
+ inside CSS, `import`, or `fetch` of a sibling file. Inline them.
54
+ - **Size:** a canvas artifact is at most 512 KB. A site file is at most 6 MB inline. Over the CLI
55
+ WebSocket, keep a single `beehaven call` under about 900 KB, or use `beehaven compose push`,
56
+ which switches to a presigned upload.
57
+ - **Motion:** honour `@media (prefers-reduced-motion: reduce)`. A canvas card lays the page out
58
+ at `designWidth` (default 1280) and grows to the document's full height. A `100vh` layout
59
+ makes it grow again on every fit, so size sections in px or by content.
60
+
61
+ ## 3. Make it (CLI)
62
+
63
+ Build JSON payloads with a script and pass them as `@file`. Do not hand-escape HTML into a shell
64
+ argument.
65
+
66
+ **Site, from one file** (one `file-teleport-inline` per file; `entryPoint` and each path are
67
+ relative to `build/`):
68
+
69
+ ```sh
70
+ beehaven call compose-upload-init '{"name":"MCP tokens explained","sourceType":"static","entryPoint":"index.html","projectId":"<projectId>"}'
71
+ # → { "id": "cmp_…", "created": true }
72
+ node -e 'const fs=require("fs"),b=fs.readFileSync("page.html");fs.writeFileSync("up.json",JSON.stringify({key:"compositions/<id>/build/index.html",composition:true,name:"index.html",mimeType:"text/html",size:b.length,data:b.toString("base64")}))'
73
+ beehaven call file-teleport-inline @up.json
74
+ beehaven call compose-upload-finalize '{"compositionId":"<id>","totalBytes":<bytes>,"entryPoint":"index.html"}'
75
+ beehaven call composition-fs '{"compositionId":"<id>","action":"list"}' # one row per file, sizes match
76
+ ```
77
+
78
+ Both parts of the upload are load-bearing. A `key` outside `compositions/<id>/build/`, or a
79
+ missing `composition:true`, still answers ok, and the site then renders nothing.
80
+
81
+ **Site, from a folder** that holds only the page and its assets:
82
+ `cd <folder> && beehaven compose create --name "<title>" && beehaven compose push`. Every file in
83
+ the folder is uploaded. A Vite, Next, Astro or SvelteKit `package.json` there runs its build first.
84
+ To push to an existing site, run `beehaven compose push --composition <id>` from the folder; there
85
+ is no `--dir` flag. `compose create` cannot file the site, so file it afterwards (§4). Ignore the
86
+ URL that `push` prints, because it does not open the site.
87
+
88
+ **Canvas artifact** (create or reuse the canvas with `composition-create {title, projectId}`):
89
+
90
+ ```sh
91
+ # payload: {"compositionId":"<canvasId>","action":"canvas.add-artifact",
92
+ # "args":{"html":"<!doctype html>…","title":"…","name":"explainer","x":0,"y":0,"w":1000,"designWidth":1000}}
93
+ beehaven call canvas-act @artifact.json
94
+ ```
95
+
96
+ `w` is the card's width on the board; `designWidth` is the viewport the page lays out at. Use 390
97
+ for a phone design. `canvas.place {kind:"site", id}` puts an existing site on a canvas as an
98
+ artifact card. It copies the entry HTML at that moment: later re-uploads do not reach the card,
99
+ and the site's other files do not load.
100
+
101
+ **Connector only (MCP):** no tool makes a site or an artifact. `clearly_file_upload` cannot choose
102
+ a site path, and `clearly_create` stores a document body as markdown. `clearly_canvas_place
103
+ {target, kind:"site", id}` can place a site that already exists. Otherwise, tell the person the
104
+ running page needs the CLI.
105
+
106
+ ## 4. Update, file, link
107
+
108
+ - **Update a site:** upload to the same `key`, then `compose-upload-finalize` again. Only someone
109
+ who may manage the site (its creator) can overwrite it. A file you stop shipping stays in the
110
+ build. Agents are refused `composition-fs … "action":"delete"`, so ask the person to remove it,
111
+ or stop referencing it. There is no history, so keep the source in the repository or a note.
112
+ - **Update a canvas artifact:** there is no edit verb. Add the new version at the old card's
113
+ `x`/`y`/`w`, then remove the old card with `canvas.delete {ids:[…]}`.
114
+ - **File:** `projectId` on `compose-upload-init` (to file later, call it again with
115
+ `{"compositionId","projectId"}`). For a canvas, `composition-create {projectId}`. For a note,
116
+ `document-create {projectId}` or `document-update {id, projectId}`. Unfiled agent work is
117
+ private to its author.
118
+ - **Link it to the ticket:** `beehaven call work-link '{"ticketId":"…","kind":"design","refKind":"composition","refId":"<id>"}'`.
119
+
120
+ ## 5. Hand it over
121
+
122
+ Give the person the place to click, not just an id.
123
+
124
+ - A site: "Recents (or the project) → the card", which opens `/compositions/detail?id=<id>`.
125
+ Do not hand over `/c/<siteId>`. That is what `beehaven open`, ticket links and workspace-ref
126
+ chips build for a site, and it opens an empty canvas instead of the page.
127
+ - A canvas artifact: `/c/<canvasId>`. Double-clicking the card opens its stored file in a new tab,
128
+ which shows source text or an access error, never the running page.
129
+
130
+ ## 6. QA — open it, read the console, check mobile
131
+
132
+ 1. **Render it in the real frame model** before uploading. Use Playwright: an
133
+ `<iframe sandbox="allow-scripts" srcdoc>` at 1280 and 390 wide. Confirm there are no console
134
+ errors and no requests from the frame, that `scrollWidth === clientWidth`, that the animation
135
+ advances by itself, and that its controls respond.
136
+ 2. **Read the stored copy back:** check that `composition-fs list` shows the size you sent.
137
+ 3. **See the server render it.** For a canvas artifact, run
138
+ `canvas-act {"compositionId":"<canvasId>","action":"canvas.review","args":{"ids":["<artifactId>"]}}`.
139
+ It returns a filmstrip of four frames across about 5.4 s; inspect the PNG. For a site, place it on a scratch canvas
140
+ (`canvas.place {kind:"site", id}`), review that card the same way, and then archive the scratch
141
+ canvas (`composition-set-archived {id, archived:true}`).
142
+ 4. Report what you checked and what you could not check. Opening the signed-in page itself needs a
143
+ person's session.
package/skills/index.json CHANGED
@@ -118,5 +118,12 @@
118
118
  "SKILL.md"
119
119
  ],
120
120
  "sha": "b9622f21173bab0d"
121
+ },
122
+ {
123
+ "name": "clearly-artifacts",
124
+ "files": [
125
+ "SKILL.md"
126
+ ],
127
+ "sha": "14434d4904da91a3"
121
128
  }
122
129
  ]