@clearlysh/beehaven 0.8.35 → 0.8.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-sessions.js +9 -6
- package/dist/agents-md-text.js +21 -1
- package/dist/browser-login.js +94 -0
- package/dist/build-channel.js +2 -2
- package/dist/call-brief.js +157 -0
- package/dist/claude-bridge.js +4 -1
- package/dist/cli.js +100 -50
- package/dist/commands/agent-instructions.js +38 -30
- package/dist/commands/agents-md.js +11 -5
- package/dist/commands/help.js +39 -5
- package/dist/commands/mcp-token.js +113 -0
- package/dist/commands/workspace.js +31 -2
- package/dist/daemon-client.js +7 -2
- package/dist/ipc-client.js +4 -1
- package/dist/ipc-server.js +4 -1
- package/dist/reconnect-backoff.js +78 -0
- package/dist/relay.js +42 -27
- package/dist/utf8-stream.js +23 -0
- package/package.json +1 -1
- package/scripts/sync-skills.mjs +1 -0
- package/skills/clearly-artifacts/SKILL.md +143 -0
- package/skills/clearly-canvas/SKILL.md +16 -8
- package/skills/clearly-canvas/authoring.md +40 -17
- package/skills/clearly-canvas/review.mjs +25 -4
- package/skills/index.json +8 -1
|
@@ -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
|
-
* ⚠
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
47
|
+
'## Clearly workspace: track your work there',
|
|
45
48
|
'',
|
|
46
|
-
'
|
|
47
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
82
|
-
'
|
|
83
|
-
'
|
|
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
|
-
|
|
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}`);
|
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,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' },
|
|
@@ -704,7 +715,7 @@ const PAGES = {
|
|
|
704
715
|
related: ['call', 'pwd', 'workspace'],
|
|
705
716
|
},
|
|
706
717
|
call: {
|
|
707
|
-
synopsis: 'beehaven call <action> [json-input] [--building <type>:<id>] [--json]',
|
|
718
|
+
synopsis: 'beehaven call <action> [json-input | @file | -] [--building <type>:<id>] [--json | --brief]',
|
|
708
719
|
description: `Invoke an RPC on the current target. Input is a JSON string.
|
|
709
720
|
Schema comes from the action's manifest — use \`beehaven actions\` first
|
|
710
721
|
to see what's available, or \`beehaven explain <action>\` for the exact
|
|
@@ -714,10 +725,12 @@ const PAGES = {
|
|
|
714
725
|
flags: [
|
|
715
726
|
{ name: '--building <type>:<id>', desc: 'Explicit target (e.g. shopifyStore:clearly-demo)' },
|
|
716
727
|
{ name: '--json', desc: 'Suppress breadcrumb + hint blocks; raw stdout only' },
|
|
728
|
+
{ name: '--brief', desc: 'A short receipt instead of the JSON: steps, layer changes, failures, lint to fix, review verdicts. Same exit code.' },
|
|
717
729
|
{ name: '--timeout <ms>', desc: 'Override the 30s default' },
|
|
718
730
|
],
|
|
719
731
|
examples: [
|
|
720
732
|
{ cmd: 'beehaven call note-create \'{"title":"Hello"}\'' },
|
|
733
|
+
{ cmd: 'beehaven call canvas-act @build.json --brief', note: 'a canvas batch, read as a one-line receipt' },
|
|
721
734
|
{ cmd: "beehaven call project-list '{}' --building home" },
|
|
722
735
|
{
|
|
723
736
|
cmd: 'beehaven call admin-agent-chat \'{"conversationId":"c1","message":"top sellers?","scope":"read"}\'',
|
|
@@ -1690,7 +1703,7 @@ const PAGES = {
|
|
|
1690
1703
|
related: ['context', 'pwd', 'workspace'],
|
|
1691
1704
|
},
|
|
1692
1705
|
'mcp-config': {
|
|
1693
|
-
synopsis: 'beehaven mcp-config [--
|
|
1706
|
+
synopsis: 'beehaven mcp-config [--label <text>] [--expires <days>] [--token-only]',
|
|
1694
1707
|
description: `Print the MCP server block instead of writing it — for a client
|
|
1695
1708
|
\`mcp install\` does not know about, or a config you keep in version control.
|
|
1696
1709
|
|
|
@@ -1699,9 +1712,30 @@ const PAGES = {
|
|
|
1699
1712
|
revoke with \`beehaven mcp remove\` rather than just deleting the text.
|
|
1700
1713
|
|
|
1701
1714
|
⚠ The block is minted for the workspace you are CONNECTED to — \`beehaven
|
|
1702
|
-
pwd\` first
|
|
1715
|
+
pwd\` first.
|
|
1716
|
+
|
|
1717
|
+
\`--token-only\` is for CI: stdout gets the raw token and one newline,
|
|
1718
|
+
nothing else; every other message goes to stderr, so it pipes straight
|
|
1719
|
+
into a secret store. It needs \`--expires <days>\` (\`0\` = never),
|
|
1720
|
+
because a CI secret is set once and rarely looked at again.
|
|
1721
|
+
|
|
1722
|
+
⚠⚠ WITH --token-only, STDOUT IS A LIVE SECRET. Pipe it; do not let it
|
|
1723
|
+
land in a terminal, a log or a file you commit. Use \`set -o pipefail\` so a
|
|
1724
|
+
refused or failed mint fails the job instead of storing an empty secret.
|
|
1725
|
+
The token is bound to YOUR agent identity (the one \`agent whoami\` shows),
|
|
1726
|
+
like \`mcp install\`; revoke it with the \`revoke-mcp-token\` line the
|
|
1727
|
+
command prints to stderr.`,
|
|
1728
|
+
flags: [
|
|
1729
|
+
{ name: '--token-only', desc: 'Print only the ck_mcp_ token on stdout (messages on stderr). Needs --expires.' },
|
|
1730
|
+
{ name: '--expires <days>', desc: 'Token lifetime in whole days; 0 = never expires. Default without it: 365.' },
|
|
1731
|
+
{ 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.' },
|
|
1732
|
+
],
|
|
1703
1733
|
examples: [
|
|
1704
1734
|
{ cmd: 'beehaven mcp-config --name clearly' },
|
|
1735
|
+
{
|
|
1736
|
+
cmd: 'set -o pipefail; beehaven mcp-config --token-only --expires 90 --label ci-github | gh secret set CLEARLY_MCP_TOKEN',
|
|
1737
|
+
note: 'CI secret: a 90-day token, never shown on screen',
|
|
1738
|
+
},
|
|
1705
1739
|
{ cmd: 'beehaven mcp install', note: 'write it for detected clients instead' },
|
|
1706
1740
|
{ cmd: 'beehaven mcp remove', note: 'revoke the token' },
|
|
1707
1741
|
],
|
|
@@ -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
|
+
}
|
|
@@ -1132,7 +1132,36 @@ export async function cmdCall(action, rawInput, values) {
|
|
|
1132
1132
|
printCallErrorHints(raw.message, action, values);
|
|
1133
1133
|
process.exit(1);
|
|
1134
1134
|
}
|
|
1135
|
-
|
|
1135
|
+
/**
|
|
1136
|
+
* MCP2-25 — `call … --out <file>` writes the whole JSON result to a file. The canvas skill tells
|
|
1137
|
+
* agents to save a large response once and read a summary, and the only way was `> file`, which a
|
|
1138
|
+
* headless agent needs permission for: a recorded Claude Code take lost a call to "`--out` is not
|
|
1139
|
+
* supported by `beehaven call`". The receipt (or a one-line note) still prints; the exit code is
|
|
1140
|
+
* the result's, exactly as without the flag.
|
|
1141
|
+
*/
|
|
1142
|
+
const outPath = values.out ?? values.output;
|
|
1143
|
+
if (typeof outPath === 'string' && outPath) {
|
|
1144
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
1145
|
+
const { dirname, resolve } = await import('node:path');
|
|
1146
|
+
const file = resolve(outPath);
|
|
1147
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1148
|
+
const text = result && typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
1149
|
+
writeFileSync(file, text + '\n');
|
|
1150
|
+
if (!values.brief)
|
|
1151
|
+
console.log(`wrote ${text.length.toLocaleString()} bytes to ${outPath}`);
|
|
1152
|
+
}
|
|
1153
|
+
let briefFailed = false;
|
|
1154
|
+
if (values.brief && !values.json) {
|
|
1155
|
+
// ⚠ THE SAME RESULT, READ FOR A PERSON — see call-brief.ts for what is kept and why.
|
|
1156
|
+
const { briefReceipt } = await import('../call-brief.js');
|
|
1157
|
+
const receipt = briefReceipt(action, result);
|
|
1158
|
+
console.log(receipt.lines.join('\n'));
|
|
1159
|
+
briefFailed = !receipt.ok;
|
|
1160
|
+
}
|
|
1161
|
+
else if (typeof outPath === 'string' && outPath) {
|
|
1162
|
+
// Written above; the console keeps only the note, so a large result stays out of the context.
|
|
1163
|
+
}
|
|
1164
|
+
else if (result && typeof result === 'object') {
|
|
1136
1165
|
console.log(JSON.stringify(result, null, 2));
|
|
1137
1166
|
}
|
|
1138
1167
|
else {
|
|
@@ -1234,7 +1263,7 @@ export async function cmdCall(action, rawInput, values) {
|
|
|
1234
1263
|
}
|
|
1235
1264
|
printKnownActionAdvice(action, input, result);
|
|
1236
1265
|
}
|
|
1237
|
-
if (isFailureResult(result))
|
|
1266
|
+
if (briefFailed || isFailureResult(result))
|
|
1238
1267
|
process.exit(1);
|
|
1239
1268
|
}
|
|
1240
1269
|
catch (err) {
|
package/dist/daemon-client.js
CHANGED
|
@@ -18,6 +18,7 @@ import net from 'net';
|
|
|
18
18
|
import { resolveClientAuthToken } from './ipc-auth.js';
|
|
19
19
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
20
20
|
import { ipcSocketPath, socketMayExist } from './ipc-path.js';
|
|
21
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
21
22
|
const TIMEOUT_MS = 60_000;
|
|
22
23
|
const AUTH_TIMEOUT_MS = 3_000;
|
|
23
24
|
/** One attempt. Resolves, never rejects. */
|
|
@@ -112,8 +113,10 @@ function handshake(socket) {
|
|
|
112
113
|
socket.removeListener('data', onData);
|
|
113
114
|
reject(new Error('IPC auth handshake timeout'));
|
|
114
115
|
}, AUTH_TIMEOUT_MS);
|
|
116
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
117
|
+
const decode = utf8Stream();
|
|
115
118
|
const onData = (chunk) => {
|
|
116
|
-
buffer += chunk
|
|
119
|
+
buffer += decode(chunk);
|
|
117
120
|
const lines = buffer.split('\n');
|
|
118
121
|
buffer = lines.pop() || '';
|
|
119
122
|
for (const line of lines) {
|
|
@@ -218,8 +221,10 @@ function sendIpc(socket, msg) {
|
|
|
218
221
|
socket.removeListener('data', onData);
|
|
219
222
|
reject(new Error(`Daemon RPC timeout after ${TIMEOUT_MS}ms`));
|
|
220
223
|
}, TIMEOUT_MS);
|
|
224
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
225
|
+
const decode = utf8Stream();
|
|
221
226
|
const onData = (chunk) => {
|
|
222
|
-
buffer += chunk
|
|
227
|
+
buffer += decode(chunk);
|
|
223
228
|
const lines = buffer.split('\n');
|
|
224
229
|
buffer = lines.pop() || '';
|
|
225
230
|
for (const line of lines) {
|
package/dist/ipc-client.js
CHANGED
|
@@ -11,6 +11,7 @@ import { EventEmitter } from 'events';
|
|
|
11
11
|
import { resolveClientAuthToken } from './ipc-auth.js';
|
|
12
12
|
import { ipcSocketPath, socketMayExist } from './ipc-path.js';
|
|
13
13
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
14
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
14
15
|
/** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
|
|
15
16
|
const IPC_SOCKET_PATH = ipcSocketPath();
|
|
16
17
|
/** Default timeout for RPC calls (ms) */
|
|
@@ -77,8 +78,10 @@ export class IpcClient extends EventEmitter {
|
|
|
77
78
|
socket.on('connect', () => {
|
|
78
79
|
this._connected = true;
|
|
79
80
|
});
|
|
81
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
82
|
+
const decode = utf8Stream();
|
|
80
83
|
socket.on('data', (data) => {
|
|
81
|
-
this.buffer += data
|
|
84
|
+
this.buffer += decode(data);
|
|
82
85
|
const lines = this.buffer.split('\n');
|
|
83
86
|
this.buffer = lines.pop() || '';
|
|
84
87
|
for (const line of lines) {
|
package/dist/ipc-server.js
CHANGED
|
@@ -27,6 +27,7 @@ import { createConnection, createServer } from 'net';
|
|
|
27
27
|
import { EventEmitter } from 'events';
|
|
28
28
|
import { resolveAuthScope } from './ipc-auth.js';
|
|
29
29
|
import { ipcSocketPath, socketMayExist, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
|
|
30
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
30
31
|
/** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
|
|
31
32
|
const IPC_SOCKET_PATH = ipcSocketPath();
|
|
32
33
|
/**
|
|
@@ -194,8 +195,10 @@ export class IpcServer extends EventEmitter {
|
|
|
194
195
|
daemonPid: process.pid,
|
|
195
196
|
requireAuth: true,
|
|
196
197
|
});
|
|
198
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
199
|
+
const decode = utf8Stream();
|
|
197
200
|
socket.on('data', (data) => {
|
|
198
|
-
client.buffer += data
|
|
201
|
+
client.buffer += decode(data);
|
|
199
202
|
const lines = client.buffer.split('\n');
|
|
200
203
|
client.buffer = lines.pop() || '';
|
|
201
204
|
for (const line of lines) {
|
|
@@ -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
|
+
}
|