beehaven 0.8.34 → 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.
@@ -8,17 +8,81 @@
8
8
  * is its own command the person (or their agent, asked first) runs, not a side effect of install.
9
9
  *
10
10
  * ⚠ THE MARKERS ARE THE UPDATE MECHANISM, as on every other surface (lib/connections/agentsMd.ts):
11
- * re-running replaces only what lies between our markers and leaves the rest byte for byte. The
12
- * CLI and MCP blocks have their own markers, so a repo used both ways keeps both.
11
+ * re-running replaces only what lies between our markers and leaves the rest byte for byte.
12
+ *
13
+ * ⚠ ONE TRACKED BLOCK FOR BOTH ROUTES (AWO-401). The block names the MCP tool and the CLI command
14
+ * for each step, so `--mcp` is kept only so older instructions still work; it writes the same block.
15
+ * Writing it removes the per-route blocks earlier versions wrote (`LEGACY_BLOCK_MARKERS`), so a repo
16
+ * that had both converges on one.
13
17
  *
14
18
  * ⚠ AGENTS.md IS THE FILE; CLAUDE.md POINTS AT IT with one `@AGENTS.md` line — one copy to keep
15
19
  * current. An existing CLAUDE.md gains the line only if it lacks it; nothing else in it is touched.
20
+ * The project's `.claude/settings.json` gains `CLAUDE_BASH_RULE` the same way (AWO-401).
16
21
  */
17
- import { existsSync, readFileSync, writeFileSync } from 'fs';
22
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
18
23
  import { join, resolve } from 'path';
19
24
  import { execFileSync } from 'child_process';
20
25
  import { color } from '../spinner.js';
21
- import { CLI_BLOCK_START, CLI_BLOCK_END, MCP_BLOCK_START, MCP_BLOCK_END, CLAUDE_MD_IMPORT, agentsMdCliBlock, agentsMdMcpBlock, } 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
+ /**
28
+ * The Claude Code permission that lets an agent run `beehaven` without a prompt per call (AWO-401).
29
+ * The tracked block makes several calls a task; a prompt on each one pushes people to turn tracking
30
+ * off. Written to the PROJECT's `.claude/settings.json` (committed with AGENTS.md, so it is the
31
+ * team's rule, not a change to anyone's user-wide settings). Claude Code matches each part of a
32
+ * chained command separately, so the rule never approves `beehaven … && something-else`.
33
+ */
34
+ export const CLAUDE_BASH_RULE = 'Bash(beehaven:*)';
35
+ /**
36
+ * Settings text with the rule added (or removed). `null` text means no file. A file that is not a
37
+ * JSON object is left alone (`skipped`) — never rewritten from a guess.
38
+ */
39
+ export function editClaudeSettings(text, remove = false) {
40
+ if (text === null) {
41
+ if (remove)
42
+ return { next: null, change: 'absent' };
43
+ return { next: JSON.stringify({ permissions: { allow: [CLAUDE_BASH_RULE] } }, null, 2) + '\n', change: 'created' };
44
+ }
45
+ let data;
46
+ try {
47
+ data = JSON.parse(text);
48
+ }
49
+ catch {
50
+ return { next: text, change: 'skipped' };
51
+ }
52
+ if (!data || typeof data !== 'object' || Array.isArray(data))
53
+ return { next: text, change: 'skipped' };
54
+ const perms = data.permissions && typeof data.permissions === 'object' && !Array.isArray(data.permissions) ? data.permissions : null;
55
+ const allow = perms && Array.isArray(perms.allow) ? perms.allow : [];
56
+ if (remove) {
57
+ if (!allow.includes(CLAUDE_BASH_RULE))
58
+ return { next: text, change: 'absent' };
59
+ perms.allow = allow.filter((r) => r !== CLAUDE_BASH_RULE);
60
+ if (!perms.allow.length)
61
+ delete perms.allow;
62
+ if (!Object.keys(perms).length)
63
+ delete data.permissions;
64
+ return { next: Object.keys(data).length ? JSON.stringify(data, null, 2) + '\n' : null, change: 'removed' };
65
+ }
66
+ if (allow.includes(CLAUDE_BASH_RULE))
67
+ return { next: text, change: 'current' };
68
+ if (perms && perms.allow !== undefined && !Array.isArray(perms.allow))
69
+ return { next: text, change: 'skipped' };
70
+ data.permissions = { ...(perms ?? {}), allow: [...allow, CLAUDE_BASH_RULE] };
71
+ return { next: JSON.stringify(data, null, 2) + '\n', change: 'updated' };
72
+ }
73
+ /** Apply `editClaudeSettings` to `<root>/.claude/settings.json`. */
74
+ function writeClaudeSettings(root, remove) {
75
+ const path = join(root, '.claude', 'settings.json');
76
+ const before = existsSync(path) ? readFileSync(path, 'utf8') : null;
77
+ const { next, change } = editClaudeSettings(before, remove);
78
+ if (next === null && before !== null)
79
+ rmSync(path);
80
+ else if (next !== null && next !== before) {
81
+ mkdirSync(join(root, '.claude'), { recursive: true });
82
+ writeFileSync(path, next);
83
+ }
84
+ return { path, change };
85
+ }
22
86
  /** Our block replaced between its markers, or appended after one blank line. Keeps CRLF files CRLF. */
23
87
  export function spliceBlock(text, block, start, end) {
24
88
  const eol = text.includes('\r\n') ? '\r\n' : '\n';
@@ -57,22 +121,47 @@ export function projectRoot(dir) {
57
121
  }
58
122
  }
59
123
  /** Write (or remove) the block in `<root>/AGENTS.md` and make CLAUDE.md import it. */
124
+ /** The text with every per-route block from before AWO-401 taken out. */
125
+ export function removeLegacyBlocks(text) {
126
+ return LEGACY_BLOCK_MARKERS.reduce((t, [start, end]) => removeBlock(t, start, end), text);
127
+ }
128
+ /**
129
+ * The block written into `text`: in place of our current block, else in place of the first legacy
130
+ * block (so a migrated repo keeps its layout), else appended. Any other legacy block is removed.
131
+ */
132
+ export function upsertAgentsMdBlock(text, block) {
133
+ if (text.includes(AGENTS_MD_START))
134
+ return spliceBlock(removeLegacyBlocks(text), block, AGENTS_MD_START, AGENTS_MD_END);
135
+ const legacy = LEGACY_BLOCK_MARKERS.find(([start, end]) => {
136
+ const i = text.indexOf(start);
137
+ return i >= 0 && text.indexOf(end, i) >= 0;
138
+ });
139
+ if (legacy)
140
+ return removeLegacyBlocks(spliceBlock(text, block, legacy[0], legacy[1]));
141
+ return spliceBlock(text, block, AGENTS_MD_START, AGENTS_MD_END);
142
+ }
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
+ */
60
149
  export function writeProjectAgentsMd(opts) {
61
150
  const root = projectRoot(resolve(opts.dir));
62
- const [start, end] = opts.mcp ? [MCP_BLOCK_START, MCP_BLOCK_END] : [CLI_BLOCK_START, CLI_BLOCK_END];
63
151
  const agentsPath = join(root, 'AGENTS.md');
64
152
  const claudePath = join(root, 'CLAUDE.md');
65
153
  const files = [];
66
154
  const had = existsSync(agentsPath);
67
155
  const before = had ? readFileSync(agentsPath, 'utf8') : '';
68
156
  if (opts.remove) {
69
- const next = removeBlock(before, start, end);
157
+ const next = removeLegacyBlocks(removeBlock(before, AGENTS_MD_START, AGENTS_MD_END));
70
158
  if (next !== before)
71
159
  writeFileSync(agentsPath, next);
72
160
  files.push({ path: agentsPath, change: next !== before ? 'removed' : 'absent' });
161
+ files.push(writeClaudeSettings(root, true));
73
162
  return { root, files };
74
163
  }
75
- const next = spliceBlock(before, opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock(), start, end);
164
+ const next = upsertAgentsMdBlock(before, opts.notTracked ? agentsMdOptOutBlock() : agentsMdBlock());
76
165
  if (next !== before)
77
166
  writeFileSync(agentsPath, next);
78
167
  files.push({ path: agentsPath, change: !had ? 'created' : next !== before ? 'updated' : 'current' });
@@ -89,16 +178,18 @@ export function writeProjectAgentsMd(opts) {
89
178
  else {
90
179
  files.push({ path: claudePath, change: 'current' });
91
180
  }
181
+ if (!opts.notTracked)
182
+ files.push(writeClaudeSettings(root, false));
92
183
  return { root, files };
93
184
  }
94
185
  export function cmdAgentsMd(dir, opts) {
95
186
  if (opts.print) {
96
- process.stdout.write((opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock()) + '\n');
187
+ process.stdout.write((opts.notTracked ? agentsMdOptOutBlock() : agentsMdBlock()) + '\n');
97
188
  return 0;
98
189
  }
99
190
  let r;
100
191
  try {
101
- 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 });
102
193
  }
103
194
  catch (err) {
104
195
  console.error(` ${color.red('✗')} ${err?.message ?? err}`);
@@ -106,10 +197,11 @@ export function cmdAgentsMd(dir, opts) {
106
197
  }
107
198
  console.log('');
108
199
  for (const f of r.files) {
109
- const mark = f.change === 'current' || f.change === 'absent' ? color.dim('·') : color.green('✓');
110
- console.log(` ${mark} ${f.path} ${color.dim(f.change)}`);
200
+ const mark = f.change === 'current' || f.change === 'absent' ? color.dim('·') : f.change === 'skipped' ? color.yellow('!') : color.green('✓');
201
+ const note = f.change === 'skipped' ? ` — not valid JSON, so left unchanged; add "${CLAUDE_BASH_RULE}" to permissions.allow yourself` : '';
202
+ console.log(` ${mark} ${f.path} ${color.dim(f.change)}${note}`);
111
203
  }
112
- const changed = r.files.some((f) => f.change !== 'current' && f.change !== 'absent');
204
+ const changed = r.files.some((f) => f.change !== 'current' && f.change !== 'absent' && f.change !== 'skipped');
113
205
  if (changed && !opts.remove) {
114
206
  console.log(color.dim(`\n Commit ${r.files.map((f) => f.path.slice(r.root.length + 1)).join(' and ')} so every agent that opens this project knows the workspace.`));
115
207
  }
@@ -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,23 +424,27 @@ const PAGES = {
414
424
  related: ['login', 'guide'],
415
425
  },
416
426
  'agents-md': {
417
- synopsis: 'beehaven agents-md [dir] [--mcp] [--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
- repository — a teammate's Codex or Claude Code, a cloud agent, CI — knows the workspace and how
420
- to reach it. Writes at the git root (or the directory given), and makes CLAUDE.md import it with
421
- one \`@AGENTS.md\` line. Needs no login.
422
-
423
- The block sits between Clearly's markers: run it again to update the block, and everything
424
- else in the file stays as it was. \`beehaven login\` already teaches the agents on THIS machine
425
- (their global instruction files); this is the shared, committed copy for everyone else.`,
429
+ repository — a teammate's Codex or Claude Code, a cloud agent, CI — tracks its work in the
430
+ workspace: sign in, a ticket for every task, a handoff at the end. One block serves agents on
431
+ the CLI and over MCP. Writes at the git root (or the directory given), and makes CLAUDE.md
432
+ import it with one \`@AGENTS.md\` line, and lets Claude Code run \`beehaven\` without a prompt per
433
+ call (\`Bash(beehaven:*)\` in the project's .claude/settings.json). Needs no login.
434
+
435
+ The block sits between Clearly's markers: run it again to update it, and everything else in
436
+ the file stays as it was. Older per-route Clearly blocks are replaced. \`beehaven login\`
437
+ already points the agents on THIS machine at Clearly (their global instruction files); this is
438
+ the shared, committed copy for everyone who opens the repository.`,
426
439
  flags: [
427
- { name: '--mcp', desc: 'Write the block for agents connected over MCP instead of the CLI' },
440
+ { name: '--not-tracked', desc: 'Write the line `Clearly: not tracked` instead, so agents leave this project out' },
428
441
  { name: '--print', desc: 'Print the block and write nothing' },
429
442
  { name: '--remove', desc: 'Take the block out again' },
443
+ { name: '--mcp', desc: 'Accepted for older instructions; the block is the same for MCP and the CLI' },
430
444
  ],
431
445
  examples: [
432
- { cmd: 'beehaven agents-md', note: 'AGENTS.md + CLAUDE.md at the repository root' },
433
- { cmd: 'beehaven agents-md --mcp', note: 'for a team that connects over MCP' },
446
+ { cmd: 'beehaven agents-md', note: 'AGENTS.md, CLAUDE.md and .claude/settings.json at the repository root' },
447
+ { cmd: 'beehaven agents-md --print', note: 'see the block without writing it' },
434
448
  ],
435
449
  related: ['skills', 'login'],
436
450
  },
@@ -1687,7 +1701,7 @@ const PAGES = {
1687
1701
  related: ['context', 'pwd', 'workspace'],
1688
1702
  },
1689
1703
  'mcp-config': {
1690
- synopsis: 'beehaven mcp-config [--name <server-name>]',
1704
+ synopsis: 'beehaven mcp-config [--label <text>] [--expires <days>] [--token-only]',
1691
1705
  description: `Print the MCP server block instead of writing it — for a client
1692
1706
  \`mcp install\` does not know about, or a config you keep in version control.
1693
1707
 
@@ -1696,9 +1710,30 @@ const PAGES = {
1696
1710
  revoke with \`beehaven mcp remove\` rather than just deleting the text.
1697
1711
 
1698
1712
  ⚠ The block is minted for the workspace you are CONNECTED to — \`beehaven
1699
- 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
+ ],
1700
1731
  examples: [
1701
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
+ },
1702
1737
  { cmd: 'beehaven mcp install', note: 'write it for detected clients instead' },
1703
1738
  { cmd: 'beehaven mcp remove', note: 'revoke the token' },
1704
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": "beehaven",
3
- "version": "0.8.34",
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;