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.
- package/dist/agent-sessions.js +9 -6
- package/dist/agents-md-text.js +79 -43
- package/dist/browser-login.js +94 -0
- package/dist/build-channel.js +2 -2
- package/dist/cli.js +96 -50
- package/dist/commands/agent-instructions.js +38 -30
- package/dist/commands/agents-md.js +104 -12
- package/dist/commands/help.js +49 -14
- package/dist/commands/mcp-token.js +113 -0
- package/dist/reconnect-backoff.js +78 -0
- package/dist/relay.js +33 -22
- package/package.json +1 -1
- package/scripts/sync-skills.mjs +1 -0
- package/skills/clearly-artifacts/SKILL.md +143 -0
- package/skills/index.json +7 -0
|
@@ -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.
|
|
12
|
-
*
|
|
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 {
|
|
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,
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/help.js
CHANGED
|
@@ -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] [--
|
|
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 —
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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: '--
|
|
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
|
|
433
|
-
{ cmd: 'beehaven agents-md --
|
|
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 [--
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
* keeps
|
|
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
|
-
*
|
|
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
|
|
1118
|
-
|
|
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,
|
|
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.
|
|
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
package/scripts/sync-skills.mjs
CHANGED
|
@@ -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;
|