@clearlysh/beehaven 0.8.35 → 0.8.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/cli.js +95 -49
- package/dist/commands/agent-instructions.js +38 -30
- package/dist/commands/agents-md.js +11 -5
- package/dist/commands/help.js +36 -4
- 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
package/dist/agent-sessions.js
CHANGED
|
@@ -14,6 +14,7 @@ import { writeFileSync, readFileSync, renameSync, chmodSync, mkdirSync, existsSy
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { homedir } from 'node:os';
|
|
17
|
+
import { ReconnectBackoff } from './reconnect-backoff.js';
|
|
17
18
|
const CONFIG_DIR = join(homedir(), '.beehaven');
|
|
18
19
|
function scopedPresenceFile(scope) {
|
|
19
20
|
const digest = createHash('sha256').update(String(scope || 'local')).digest('hex').slice(0, 24);
|
|
@@ -31,8 +32,6 @@ export class AgentSessions {
|
|
|
31
32
|
presenceBroadcast = null;
|
|
32
33
|
/** A desktop identity is presence, not a permanent login badge. */
|
|
33
34
|
static IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
|
-
static RECONNECT_MIN_MS = 1_000;
|
|
35
|
-
static RECONNECT_MAX_MS = 30_000;
|
|
36
35
|
/** @param wsBase returns the relay ws base, e.g. wss://…/ (no trailing path) */
|
|
37
36
|
constructor(wsBase, presenceScope = 'local') {
|
|
38
37
|
this.wsBase = wsBase;
|
|
@@ -188,8 +187,7 @@ export class AgentSessions {
|
|
|
188
187
|
scheduleReconnect(agentId, spec) {
|
|
189
188
|
if (this.specs.get(agentId) !== spec || spec.reconnectTimer)
|
|
190
189
|
return;
|
|
191
|
-
const delay = spec.
|
|
192
|
-
spec.reconnectDelay = Math.min(spec.reconnectDelay * 2, AgentSessions.RECONNECT_MAX_MS);
|
|
190
|
+
const delay = spec.backoff.next();
|
|
193
191
|
spec.reconnectTimer = setTimeout(() => {
|
|
194
192
|
spec.reconnectTimer = null;
|
|
195
193
|
this.openSocket(agentId, spec);
|
|
@@ -213,6 +211,7 @@ export class AgentSessions {
|
|
|
213
211
|
const url = `${base}/my/workspace/ws?token=${encodeURIComponent(spec.token)}&clientType=agent&client=agent&deviceId=agent-${encodeURIComponent(agentId)}`;
|
|
214
212
|
let ws;
|
|
215
213
|
let opened = false;
|
|
214
|
+
let openedAt = null;
|
|
216
215
|
let settled = false;
|
|
217
216
|
let timer = null;
|
|
218
217
|
const settleFirst = (result) => {
|
|
@@ -253,7 +252,10 @@ export class AgentSessions {
|
|
|
253
252
|
// Reconnecting transport is not agent activity. A new socket stays inactive until a
|
|
254
253
|
// verified call touches the identity; a persisted interaction keeps its existing clock.
|
|
255
254
|
this.scheduleIdleExpiry(agentId);
|
|
256
|
-
|
|
255
|
+
// ⚠ PPO-532: opening is not recovering — this reset-on-open made a workspace that drops
|
|
256
|
+
// every new socket while hydrating (1006) see a reconnect every ~1 s, in step with the
|
|
257
|
+
// daemon's own socket. The backoff now resets only after the socket stays open 60 s.
|
|
258
|
+
openedAt = spec.backoff.opened();
|
|
257
259
|
this.syncPersistedInteractions();
|
|
258
260
|
this.syncActiveSessionsFile();
|
|
259
261
|
settleFirst({ ok: true });
|
|
@@ -290,6 +292,7 @@ export class AgentSessions {
|
|
|
290
292
|
return;
|
|
291
293
|
}
|
|
292
294
|
this.syncActiveSessionsFile();
|
|
295
|
+
spec.backoff.closed(openedAt);
|
|
293
296
|
this.scheduleReconnect(agentId, spec);
|
|
294
297
|
};
|
|
295
298
|
}
|
|
@@ -300,7 +303,7 @@ export class AgentSessions {
|
|
|
300
303
|
const spec = {
|
|
301
304
|
token,
|
|
302
305
|
reconnectTimer: null,
|
|
303
|
-
|
|
306
|
+
backoff: new ReconnectBackoff(),
|
|
304
307
|
};
|
|
305
308
|
this.specs.set(agentId, spec);
|
|
306
309
|
return new Promise((resolve) => this.openSocket(agentId, spec, resolve));
|
package/dist/agents-md-text.js
CHANGED
|
@@ -22,6 +22,22 @@ export const LEGACY_BLOCK_MARKERS = [
|
|
|
22
22
|
['<!-- clearly:cli:start -->', '<!-- clearly:cli:end -->'],
|
|
23
23
|
['<!-- clearly:mcp:start -->', '<!-- clearly:mcp:end -->'],
|
|
24
24
|
];
|
|
25
|
+
/**
|
|
26
|
+
* The opt-out (AWO-403): a project whose AGENTS.md or CLAUDE.md carries this exact line is not tracked in Clearly.
|
|
27
|
+
* The global block (agentsGlobal.ts) and the MCP standing instructions name it, so the opt-out is a literal an agent
|
|
28
|
+
* matches, not a sentence it interprets. `beehaven agents-md --not-tracked` writes it between Clearly's markers.
|
|
29
|
+
*/
|
|
30
|
+
export const AGENTS_MD_OPT_OUT_LINE = 'Clearly: not tracked';
|
|
31
|
+
/** The opted-out project's block: the line, inside the same markers, so re-running the setup turns tracking back on. */
|
|
32
|
+
export function agentsMdOptOutBlock() {
|
|
33
|
+
return [
|
|
34
|
+
AGENTS_MD_START,
|
|
35
|
+
'<!-- managed-by: clearly. Run `beehaven agents-md` to track this project in Clearly again. -->',
|
|
36
|
+
'',
|
|
37
|
+
AGENTS_MD_OPT_OUT_LINE,
|
|
38
|
+
AGENTS_MD_END,
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
25
41
|
/** How Claude Code runs the MCP server's `clearly-agents-md` prompt, which writes the block. */
|
|
26
42
|
export const MCP_AGENTS_MD_SLASH = '/mcp__clearly__clearly-agents-md';
|
|
27
43
|
/** What goes in CLAUDE.md so Claude Code reads the AGENTS.md block too. */
|
|
@@ -68,13 +84,17 @@ export function agentsMdBlock(agentName) {
|
|
|
68
84
|
' Record decisions, open questions and progress as comments (`clearly_comment`).',
|
|
69
85
|
'5. Mark it done only after verifying the result, with the resolution and any remaining risk.',
|
|
70
86
|
'',
|
|
87
|
+
'Questions and reads need no ticket.',
|
|
88
|
+
'',
|
|
71
89
|
'### End of every session',
|
|
72
90
|
'',
|
|
73
91
|
'- Leave a handoff: what you did and what is next. CLI: `beehaven agent handoff "<done · next>"`.',
|
|
74
92
|
' MCP: a comment on the work ticket.',
|
|
75
93
|
'',
|
|
76
94
|
'If this session has neither the `clearly_*` tools nor `beehaven`, stop and ask the person to connect',
|
|
77
|
-
'Clearly (clearly.sh/docs/mcp or clearly.sh/docs/cli). Do not skip the tracking.',
|
|
95
|
+
'Clearly (clearly.sh/docs/mcp or clearly.sh/docs/cli). Do not skip the tracking. In CI or a cloud',
|
|
96
|
+
'agent, where nobody can sign in, connect over MCP with an agent token from the agent\'s details in',
|
|
97
|
+
'Clearly, kept as a secret.',
|
|
78
98
|
AGENTS_MD_END,
|
|
79
99
|
].join('\n');
|
|
80
100
|
}
|
package/dist/browser-login.js
CHANGED
|
@@ -291,3 +291,97 @@ export async function browserLogin() {
|
|
|
291
291
|
persistBrowserLogin(captured);
|
|
292
292
|
return captured;
|
|
293
293
|
}
|
|
294
|
+
/** The relay's HTTPS origin for the configured env (prod when unconfigured). */
|
|
295
|
+
export function relayHttpFromConfig(cfg = readConfig(), env = process.env) {
|
|
296
|
+
const override = env.BEEHAVEN_RELAY_HTTP_URL;
|
|
297
|
+
if (override)
|
|
298
|
+
return override.replace(/\/+$/, '');
|
|
299
|
+
return resolveRelaySelection(cfg).url.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://').replace(/\/+$/, '');
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* True where a browser on this machine is unlikely: an SSH session, CI, or Linux with no display.
|
|
303
|
+
* `beehaven login --browser` overrides it; `--device-code` forces the device flow anywhere.
|
|
304
|
+
*/
|
|
305
|
+
export function preferDeviceLogin(env = process.env, platform = process.platform) {
|
|
306
|
+
if (env.SSH_CONNECTION || env.SSH_TTY || env.CI)
|
|
307
|
+
return true;
|
|
308
|
+
return platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
309
|
+
}
|
|
310
|
+
/** The approval page, optionally with the code filled in. */
|
|
311
|
+
export function deviceAuthUrl(authOrigin, userCode) {
|
|
312
|
+
return withUtm(`${authOrigin}/cli/device${userCode ? `?code=${encodeURIComponent(userCode)}` : ''}`);
|
|
313
|
+
}
|
|
314
|
+
/** Email and name from the ID token's payload, for the "Signed in as" line. Display only. */
|
|
315
|
+
function idTokenClaims(idToken) {
|
|
316
|
+
try {
|
|
317
|
+
const payload = JSON.parse(Buffer.from(idToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
|
|
318
|
+
return { email: typeof payload.email === 'string' ? payload.email : undefined, name: typeof payload.name === 'string' ? payload.name : undefined };
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export function captureDeviceLogin(opts) {
|
|
325
|
+
const { relayOrigin, authOrigin, client, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, log = (line) => console.log(line), } = opts;
|
|
326
|
+
const post = async (path, body) => {
|
|
327
|
+
const res = await fetchImpl(`${relayOrigin}${path}`, {
|
|
328
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
329
|
+
});
|
|
330
|
+
const json = await res.json().catch(() => ({}));
|
|
331
|
+
return { res, json };
|
|
332
|
+
};
|
|
333
|
+
return (async () => {
|
|
334
|
+
let start;
|
|
335
|
+
try {
|
|
336
|
+
const { res, json } = await post('/cli/device/start', { client });
|
|
337
|
+
if (!res.ok || !json?.ok)
|
|
338
|
+
throw new Error(json?.error === 'slow_down' ? 'Too many sign-in attempts from here. Wait a while and try again.' : (json?.error || `relay answered ${res.status}`));
|
|
339
|
+
start = json;
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
throw new Error(`Couldn't start a device sign-in: ${err?.message ?? err}`);
|
|
343
|
+
}
|
|
344
|
+
log('');
|
|
345
|
+
log(` On any device with a browser, open ${color.cyan(deviceAuthUrl(authOrigin))}`);
|
|
346
|
+
log(` and enter this code: ${color.bold(start.userCode)}`);
|
|
347
|
+
log('');
|
|
348
|
+
log(color.dim(` Direct link: ${deviceAuthUrl(authOrigin, start.userCode)}`));
|
|
349
|
+
log(color.dim(` Waiting for approval (the code expires in ${Math.round(start.expiresIn / 60)} minutes)…`));
|
|
350
|
+
let interval = Math.max(1, start.interval) * 1000;
|
|
351
|
+
const deadline = now() + start.expiresIn * 1000;
|
|
352
|
+
while (now() < deadline) {
|
|
353
|
+
await sleep(interval);
|
|
354
|
+
let res;
|
|
355
|
+
let json;
|
|
356
|
+
try {
|
|
357
|
+
({ res, json } = await post('/cli/device/token', { deviceCode: start.deviceCode }));
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
continue; // a dropped poll is retried at the next tick
|
|
361
|
+
}
|
|
362
|
+
if (res.ok && json?.ok && json.idToken && json.refreshToken && json.userId) {
|
|
363
|
+
const { email, name } = idTokenClaims(json.idToken);
|
|
364
|
+
return { idToken: json.idToken, refreshToken: json.refreshToken, userId: json.userId, email, displayName: name };
|
|
365
|
+
}
|
|
366
|
+
switch (json?.error) {
|
|
367
|
+
case 'authorization_pending': continue;
|
|
368
|
+
case 'slow_down':
|
|
369
|
+
interval += 5000;
|
|
370
|
+
continue;
|
|
371
|
+
case 'access_denied': throw new Error('The sign-in was denied in the browser.');
|
|
372
|
+
case 'expired_token': throw new Error('The code expired. Run `beehaven login --device-code` again.');
|
|
373
|
+
default:
|
|
374
|
+
if (res.status >= 500)
|
|
375
|
+
throw new Error(json?.error || `relay answered ${res.status}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
throw new Error('The code expired. Run `beehaven login --device-code` again.');
|
|
379
|
+
})();
|
|
380
|
+
}
|
|
381
|
+
/** Device-code sign-in end to end: start, show the code, poll, persist like the browser flow. */
|
|
382
|
+
export async function deviceLogin(opts = {}) {
|
|
383
|
+
const { authOrigin } = resolveAuthOrigin();
|
|
384
|
+
const captured = await captureDeviceLogin({ relayOrigin: relayHttpFromConfig(), authOrigin, client: opts.client });
|
|
385
|
+
persistBrowserLogin(captured);
|
|
386
|
+
return captured;
|
|
387
|
+
}
|
package/dist/build-channel.js
CHANGED
|
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
|
|
|
53
53
|
* ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
|
|
54
54
|
* carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
|
|
55
55
|
*/
|
|
56
|
-
export const BUILD_SHA = '
|
|
56
|
+
export const BUILD_SHA = '31746b91dfbf';
|
|
57
57
|
/** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
|
|
58
|
-
export const BUILD_TIME = '2026-10-
|
|
58
|
+
export const BUILD_TIME = '2026-10-29T05:50Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
package/dist/cli.js
CHANGED
|
@@ -284,6 +284,7 @@ ${DEV_ONLY_HELP ? ` PROJECT — build your own Durable Object backend (HIVE_DE
|
|
|
284
284
|
mcp status Which clients are configured
|
|
285
285
|
mcp remove Remove the entries + revoke the token
|
|
286
286
|
mcp-config [--name N] Print the block instead of writing it
|
|
287
|
+
mcp-config --token-only --expires <days> Print ONLY the token, for a secret store
|
|
287
288
|
|
|
288
289
|
Anywhere else — claude.ai, Claude Desktop, another machine — point the client
|
|
289
290
|
at the endpoint and let it log you in; nothing to install and no token to paste:
|
|
@@ -1417,6 +1418,10 @@ async function run() {
|
|
|
1417
1418
|
// (a live client) when none is connected; `--dev` → localhost.
|
|
1418
1419
|
launch: { type: 'boolean', default: false },
|
|
1419
1420
|
browser: { type: 'boolean', default: false },
|
|
1421
|
+
// `beehaven login --device-code` — sign in by approving a code on another device (AWO-402).
|
|
1422
|
+
'device-code': { type: 'boolean', default: false },
|
|
1423
|
+
// `beehaven agents-md --not-tracked` — write the opt-out line instead of the tracked block (AWO-403).
|
|
1424
|
+
'not-tracked': { type: 'boolean', default: false },
|
|
1420
1425
|
dev: { type: 'boolean', default: false },
|
|
1421
1426
|
// `beehaven canvas perceive --pixels --max 80`, `canvas verify --keep`
|
|
1422
1427
|
pixels: { type: 'boolean', default: false },
|
|
@@ -1461,6 +1466,8 @@ async function run() {
|
|
|
1461
1466
|
client: { type: 'string' },
|
|
1462
1467
|
/** `beehaven mcp install --rotate` — mint a new token instead of reusing the installed one. */
|
|
1463
1468
|
rotate: { type: 'boolean' },
|
|
1469
|
+
/** `beehaven mcp-config --token-only --expires <days>` — the raw token alone on stdout, for piping. */
|
|
1470
|
+
'token-only': { type: 'boolean' },
|
|
1464
1471
|
password: { type: 'string' },
|
|
1465
1472
|
email: { type: 'string' },
|
|
1466
1473
|
expires: { type: 'string' },
|
|
@@ -3033,10 +3040,19 @@ async function run() {
|
|
|
3033
3040
|
// own copy aimed at /auth/beehaven, a route the web app no longer serves.
|
|
3034
3041
|
// What follows the sign-in — restarting the daemon, teaching the agents —
|
|
3035
3042
|
// stays here, because the daemon fallback must not restart itself.
|
|
3043
|
+
// AWO-402 — the device-code flow for a machine with no browser of its own: forced with
|
|
3044
|
+
// `--device-code`, chosen automatically over SSH / CI / a display-less Linux, and never when
|
|
3045
|
+
// the person asked for `--browser`.
|
|
3036
3046
|
let captured;
|
|
3037
3047
|
try {
|
|
3038
|
-
const { browserLogin } = await import('./browser-login.js');
|
|
3039
|
-
|
|
3048
|
+
const { browserLogin, deviceLogin, preferDeviceLogin } = await import('./browser-login.js');
|
|
3049
|
+
const useDevice = Boolean(values['device-code']) || (!values.browser && preferDeviceLogin());
|
|
3050
|
+
if (useDevice && !values['device-code']) {
|
|
3051
|
+
console.log(color.dim(' No browser on this machine, so signing in with a code (`beehaven login --browser` to open one here).'));
|
|
3052
|
+
}
|
|
3053
|
+
captured = useDevice
|
|
3054
|
+
? await deviceLogin({ client: `beehaven ${getVersion()} (${process.platform})` })
|
|
3055
|
+
: await browserLogin();
|
|
3040
3056
|
}
|
|
3041
3057
|
catch (err) {
|
|
3042
3058
|
console.error(` ${color.red('✗')} ${err?.message ?? err}`);
|
|
@@ -3170,6 +3186,7 @@ async function run() {
|
|
|
3170
3186
|
mcp: !!values.mcp,
|
|
3171
3187
|
print: !!values.print,
|
|
3172
3188
|
remove: !!values.remove,
|
|
3189
|
+
notTracked: Boolean(values['not-tracked']),
|
|
3173
3190
|
});
|
|
3174
3191
|
break;
|
|
3175
3192
|
}
|
|
@@ -4073,10 +4090,82 @@ complete -F _beehaven_completions beehaven`);
|
|
|
4073
4090
|
const tgtAddress = tgt?.address ?? 'home';
|
|
4074
4091
|
const mcpEntry = entryNameFor(tgtAddress);
|
|
4075
4092
|
const wsId = isHomeAddress(tgtAddress) ? '' : tgtAddress.split('/').slice(1).join('/');
|
|
4076
|
-
const
|
|
4093
|
+
const defaultMcpLabel = `${mcpEntry} · ${new Date().toISOString().slice(0, 10)}`;
|
|
4077
4094
|
const { rpcOnce: _rpc } = await import('./ipc-client.js');
|
|
4078
4095
|
const { resolveActiveAgent } = await import('./agent-state.js');
|
|
4096
|
+
const { mcpMintInput, runMcpTokenOnly } = await import('./commands/mcp-token.js');
|
|
4079
4097
|
const activeMcpAgent = resolveActiveAgent(values);
|
|
4098
|
+
/**
|
|
4099
|
+
* ⚠⚠ DO NOT ECHO THE RAW ERROR — IT SENT PEOPLE TO THE WRONG FIX.
|
|
4100
|
+
*
|
|
4101
|
+
* Observed: this printed "Cannot connect to daemon. Run `beehaven start --headless`" in the
|
|
4102
|
+
* same second that `beehaven status` reported "Daemon: running". The daemon was up and the
|
|
4103
|
+
* RELAY had dropped. Following the advice runs into the single-instance guard, which
|
|
4104
|
+
* refuses — so the message costs a cycle and teaches the wrong model of the failure.
|
|
4105
|
+
*
|
|
4106
|
+
* The CLI already distinguishes these three states carefully (see `status`), precisely
|
|
4107
|
+
* because the fixes are mutually exclusive: a restart will NOT refresh an expired token,
|
|
4108
|
+
* and a re-login will NOT revive a dropped socket. This path bypassed all of it by
|
|
4109
|
+
* forwarding whatever string the transport happened to raise.
|
|
4110
|
+
*
|
|
4111
|
+
* Re-probe and say what is actually true. Stderr only, and it returns the exit code, so the
|
|
4112
|
+
* `--token-only` path can use it without touching stdout.
|
|
4113
|
+
*/
|
|
4114
|
+
const explainMintFailure = async (err) => {
|
|
4115
|
+
const { isDaemonRunning } = await import('./daemon-client.js');
|
|
4116
|
+
let up = false;
|
|
4117
|
+
try {
|
|
4118
|
+
up = await isDaemonRunning();
|
|
4119
|
+
}
|
|
4120
|
+
catch {
|
|
4121
|
+
up = false;
|
|
4122
|
+
}
|
|
4123
|
+
let expired = false;
|
|
4124
|
+
try {
|
|
4125
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), '.beehaven', 'config.json'), 'utf8'));
|
|
4126
|
+
const payload = JSON.parse(Buffer.from(String(cfg.token).split('.')[1], 'base64').toString());
|
|
4127
|
+
expired = !payload.exp || Date.now() / 1000 > payload.exp - 60;
|
|
4128
|
+
}
|
|
4129
|
+
catch {
|
|
4130
|
+
expired = true;
|
|
4131
|
+
}
|
|
4132
|
+
console.error(` ${color.red('✗')} Could not mint a token.`);
|
|
4133
|
+
console.error(!up
|
|
4134
|
+
? ' The daemon is not running — `beehaven start --headless`'
|
|
4135
|
+
: expired
|
|
4136
|
+
? ' The daemon is up but your token has EXPIRED — `beehaven login` (a restart will not refresh it)'
|
|
4137
|
+
: ' The daemon is up and your token is valid; the relay socket dropped — `beehaven restart`');
|
|
4138
|
+
console.error(color.dim(` (${err?.message ?? err})`));
|
|
4139
|
+
return 1;
|
|
4140
|
+
};
|
|
4141
|
+
/**
|
|
4142
|
+
* ⚠⚠ `--token-only`: THE RAW TOKEN ON STDOUT AND NOTHING ELSE, for
|
|
4143
|
+
* `… | gh secret set CLEARLY_MCP_TOKEN`. The rules (stderr for every message, `--expires`
|
|
4144
|
+
* required, the caller's own agent) live in commands/mcp-token.ts, where the tests call them.
|
|
4145
|
+
* It branches off HERE, before the reuse check (print never reuses) and before anything below
|
|
4146
|
+
* writes to stdout.
|
|
4147
|
+
*/
|
|
4148
|
+
if (values['token-only']) {
|
|
4149
|
+
process.exitCode = await runMcpTokenOnly({
|
|
4150
|
+
sub: mcpSub,
|
|
4151
|
+
flags: values,
|
|
4152
|
+
defaultLabel: defaultMcpLabel,
|
|
4153
|
+
agentId: activeMcpAgent?.agentId,
|
|
4154
|
+
mint: (input) => _rpc('create-mcp-token', input, 30_000),
|
|
4155
|
+
out: (s) => { process.stdout.write(s); },
|
|
4156
|
+
err: (s) => { process.stderr.write(s); },
|
|
4157
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
4158
|
+
onMintError: explainMintFailure,
|
|
4159
|
+
});
|
|
4160
|
+
break;
|
|
4161
|
+
}
|
|
4162
|
+
// `--label` (else `--name`), `--expires <days>` and the caller's agent, for every mint below.
|
|
4163
|
+
const mintInput = mcpMintInput(values, { label: defaultMcpLabel, agentId: activeMcpAgent?.agentId });
|
|
4164
|
+
if ('error' in mintInput) {
|
|
4165
|
+
console.error(` ${color.red('✗')} ${mintInput.error}`);
|
|
4166
|
+
process.exit(2);
|
|
4167
|
+
}
|
|
4168
|
+
const label = mintInput.input.label;
|
|
4080
4169
|
let res;
|
|
4081
4170
|
/**
|
|
4082
4171
|
* ⚠⚠ REUSE THE TOKEN ALREADY INSTALLED RATHER THAN MINTING ANOTHER. Minting
|
|
@@ -4121,54 +4210,11 @@ complete -F _beehaven_completions beehaven`);
|
|
|
4121
4210
|
}
|
|
4122
4211
|
}
|
|
4123
4212
|
try {
|
|
4124
|
-
if (!res)
|
|
4125
|
-
res = await _rpc('create-mcp-token',
|
|
4126
|
-
label,
|
|
4127
|
-
...(activeMcpAgent?.agentId ? { agentId: activeMcpAgent.agentId } : {}),
|
|
4128
|
-
}, 30_000);
|
|
4129
|
-
}
|
|
4213
|
+
if (!res)
|
|
4214
|
+
res = await _rpc('create-mcp-token', mintInput.input, 30_000);
|
|
4130
4215
|
}
|
|
4131
4216
|
catch (err) {
|
|
4132
|
-
|
|
4133
|
-
* ⚠⚠ DO NOT ECHO THE RAW ERROR — IT SENT PEOPLE TO THE WRONG FIX.
|
|
4134
|
-
*
|
|
4135
|
-
* Observed: this printed "Cannot connect to daemon. Run `beehaven start --headless`" in the
|
|
4136
|
-
* same second that `beehaven status` reported "Daemon: running". The daemon was up and the
|
|
4137
|
-
* RELAY had dropped. Following the advice runs into the single-instance guard, which
|
|
4138
|
-
* refuses — so the message costs a cycle and teaches the wrong model of the failure.
|
|
4139
|
-
*
|
|
4140
|
-
* The CLI already distinguishes these three states carefully (see `status`), precisely
|
|
4141
|
-
* because the fixes are mutually exclusive: a restart will NOT refresh an expired token,
|
|
4142
|
-
* and a re-login will NOT revive a dropped socket. This path bypassed all of it by
|
|
4143
|
-
* forwarding whatever string the transport happened to raise.
|
|
4144
|
-
*
|
|
4145
|
-
* Re-probe and say what is actually true.
|
|
4146
|
-
*/
|
|
4147
|
-
const { isDaemonRunning } = await import('./daemon-client.js');
|
|
4148
|
-
let up = false;
|
|
4149
|
-
try {
|
|
4150
|
-
up = await isDaemonRunning();
|
|
4151
|
-
}
|
|
4152
|
-
catch {
|
|
4153
|
-
up = false;
|
|
4154
|
-
}
|
|
4155
|
-
let expired = false;
|
|
4156
|
-
try {
|
|
4157
|
-
const cfg = JSON.parse(readFileSync(join(homedir(), '.beehaven', 'config.json'), 'utf8'));
|
|
4158
|
-
const payload = JSON.parse(Buffer.from(String(cfg.token).split('.')[1], 'base64').toString());
|
|
4159
|
-
expired = !payload.exp || Date.now() / 1000 > payload.exp - 60;
|
|
4160
|
-
}
|
|
4161
|
-
catch {
|
|
4162
|
-
expired = true;
|
|
4163
|
-
}
|
|
4164
|
-
console.error(` ${color.red('✗')} Could not mint a token.`);
|
|
4165
|
-
console.error(!up
|
|
4166
|
-
? ' The daemon is not running — `beehaven start --headless`'
|
|
4167
|
-
: expired
|
|
4168
|
-
? ' The daemon is up but your token has EXPIRED — `beehaven login` (a restart will not refresh it)'
|
|
4169
|
-
: ' The daemon is up and your token is valid; the relay socket dropped — `beehaven restart`');
|
|
4170
|
-
console.error(color.dim(` (${err?.message ?? err})`));
|
|
4171
|
-
process.exit(1);
|
|
4217
|
+
process.exit(await explainMintFailure(err));
|
|
4172
4218
|
}
|
|
4173
4219
|
if (!res?.key) {
|
|
4174
4220
|
console.error(` ${color.red('✗')} ${res?.error ?? 'No token returned.'}`);
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
* means both converge and either uninstaller removes it. `agentInstructionParity.test.mjs` in the
|
|
15
15
|
* root repo pins the two copies together.
|
|
16
16
|
*
|
|
17
|
-
* ⚠
|
|
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' },
|
|
@@ -1690,7 +1701,7 @@ const PAGES = {
|
|
|
1690
1701
|
related: ['context', 'pwd', 'workspace'],
|
|
1691
1702
|
},
|
|
1692
1703
|
'mcp-config': {
|
|
1693
|
-
synopsis: 'beehaven mcp-config [--
|
|
1704
|
+
synopsis: 'beehaven mcp-config [--label <text>] [--expires <days>] [--token-only]',
|
|
1694
1705
|
description: `Print the MCP server block instead of writing it — for a client
|
|
1695
1706
|
\`mcp install\` does not know about, or a config you keep in version control.
|
|
1696
1707
|
|
|
@@ -1699,9 +1710,30 @@ const PAGES = {
|
|
|
1699
1710
|
revoke with \`beehaven mcp remove\` rather than just deleting the text.
|
|
1700
1711
|
|
|
1701
1712
|
⚠ The block is minted for the workspace you are CONNECTED to — \`beehaven
|
|
1702
|
-
pwd\` first
|
|
1713
|
+
pwd\` first.
|
|
1714
|
+
|
|
1715
|
+
\`--token-only\` is for CI: stdout gets the raw token and one newline,
|
|
1716
|
+
nothing else; every other message goes to stderr, so it pipes straight
|
|
1717
|
+
into a secret store. It needs \`--expires <days>\` (\`0\` = never),
|
|
1718
|
+
because a CI secret is set once and rarely looked at again.
|
|
1719
|
+
|
|
1720
|
+
⚠⚠ WITH --token-only, STDOUT IS A LIVE SECRET. Pipe it; do not let it
|
|
1721
|
+
land in a terminal, a log or a file you commit. Use \`set -o pipefail\` so a
|
|
1722
|
+
refused or failed mint fails the job instead of storing an empty secret.
|
|
1723
|
+
The token is bound to YOUR agent identity (the one \`agent whoami\` shows),
|
|
1724
|
+
like \`mcp install\`; revoke it with the \`revoke-mcp-token\` line the
|
|
1725
|
+
command prints to stderr.`,
|
|
1726
|
+
flags: [
|
|
1727
|
+
{ name: '--token-only', desc: 'Print only the ck_mcp_ token on stdout (messages on stderr). Needs --expires.' },
|
|
1728
|
+
{ name: '--expires <days>', desc: 'Token lifetime in whole days; 0 = never expires. Default without it: 365.' },
|
|
1729
|
+
{ name: '--label <text>', desc: 'The token\'s label in Settings and list-mcp-tokens. --name is the older spelling; it labels the token, not the server entry.' },
|
|
1730
|
+
],
|
|
1703
1731
|
examples: [
|
|
1704
1732
|
{ cmd: 'beehaven mcp-config --name clearly' },
|
|
1733
|
+
{
|
|
1734
|
+
cmd: 'set -o pipefail; beehaven mcp-config --token-only --expires 90 --label ci-github | gh secret set CLEARLY_MCP_TOKEN',
|
|
1735
|
+
note: 'CI secret: a 90-day token, never shown on screen',
|
|
1736
|
+
},
|
|
1705
1737
|
{ cmd: 'beehaven mcp install', note: 'write it for detected clients instead' },
|
|
1706
1738
|
{ cmd: 'beehaven mcp remove', note: 'revoke the token' },
|
|
1707
1739
|
],
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beehaven mcp-config --token-only` — the raw `ck_mcp_` token on stdout and nothing else, so it
|
|
3
|
+
* can be piped straight into a secret store (founder request, 2026-10-29):
|
|
4
|
+
*
|
|
5
|
+
* set -o pipefail
|
|
6
|
+
* beehaven mcp-config --token-only --expires 90 --label ci-github | gh secret set CLEARLY_MCP_TOKEN
|
|
7
|
+
*
|
|
8
|
+
* ⚠⚠ STDOUT IS THE SECRET, SO STDOUT CARRIES NOTHING ELSE. One stray byte on stdout (a ✓, a blank
|
|
9
|
+
* line, an update notice) becomes part of the stored secret, and the CI job then fails with an
|
|
10
|
+
* auth error that names nothing. Every message here, refusals included, goes to stderr, and on any
|
|
11
|
+
* failure stdout stays EMPTY, so the pipe stores nothing that looks like a working token.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ AN EXPIRY IS REQUIRED. A token minted for CI goes into a secret store nobody re-reads; leaving
|
|
14
|
+
* `--expires` off would give it the server's 365-day default by omission, an unbounded secret in
|
|
15
|
+
* practice. `--expires 0` is the explicit "never expires" for a caller who means it.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ THE TOKEN IS THE CALLER'S OWN. It is bound to the active agent identity exactly as
|
|
18
|
+
* `mcp install` binds it, and the server refuses a token for any other agent
|
|
19
|
+
* (`agentTokenRefusal`, apps/cloudflare/src/rpc/mcp-token-rpcs.ts).
|
|
20
|
+
*
|
|
21
|
+
* The decisions live here, not inline in cli.ts, so the tests can call them: a grep over cli.ts
|
|
22
|
+
* cannot tell "stdout holds exactly the token" from "the line moved".
|
|
23
|
+
*/
|
|
24
|
+
import { color } from '../spinner.js';
|
|
25
|
+
/**
|
|
26
|
+
* `--expires <days>`: a whole number of days, `0` = no expiry. Absent means "not given".
|
|
27
|
+
* (Results are told apart with `'error' in r`: the CLI compiles without strictNullChecks, where
|
|
28
|
+
* an `ok: true | false` discriminant does not narrow.)
|
|
29
|
+
*/
|
|
30
|
+
export function parseExpiresDays(raw) {
|
|
31
|
+
if (raw === undefined || raw === null)
|
|
32
|
+
return {};
|
|
33
|
+
const s = String(raw).trim();
|
|
34
|
+
if (!/^\d+$/.test(s) || !Number.isSafeInteger(Number(s))) {
|
|
35
|
+
return { error: `--expires takes a whole number of days (0 = never expires), not "${s}".` };
|
|
36
|
+
}
|
|
37
|
+
return { days: Number(s) };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The `create-mcp-token` input for every mint in `mcp` / `mcp-config`: `--label` (else `--name`,
|
|
41
|
+
* else the dated default), `--expires` as `expiresInDays` when given, and the caller's own agent.
|
|
42
|
+
*/
|
|
43
|
+
export function mcpMintInput(flags, defaults) {
|
|
44
|
+
const expiry = parseExpiresDays(flags.expires);
|
|
45
|
+
if ('error' in expiry)
|
|
46
|
+
return expiry;
|
|
47
|
+
const text = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
48
|
+
const input = { label: text(flags.label) || text(flags.name) || defaults.label };
|
|
49
|
+
if (expiry.days !== undefined)
|
|
50
|
+
input.expiresInDays = expiry.days;
|
|
51
|
+
if (defaults.agentId)
|
|
52
|
+
input.agentId = defaults.agentId;
|
|
53
|
+
return { input };
|
|
54
|
+
}
|
|
55
|
+
/** Why `--token-only` cannot run as asked, or null. */
|
|
56
|
+
export function tokenOnlyRefusal(sub, flags) {
|
|
57
|
+
if (sub !== 'print') {
|
|
58
|
+
return '--token-only prints a token for you to store; it does not install one.'
|
|
59
|
+
+ ' Use: beehaven mcp-config --token-only --expires <days>';
|
|
60
|
+
}
|
|
61
|
+
if (flags.expires === undefined || flags.expires === null) {
|
|
62
|
+
return '--token-only needs --expires <days>. The token is headed for a CI secret that nobody'
|
|
63
|
+
+ ' re-reads, and without an expiry it would stay valid for a year by default.'
|
|
64
|
+
+ ' Pass --expires 90, or --expires 0 if it should never expire.';
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** A `ck_mcp_` bearer and nothing else: no whitespace, no JSON, no error text. */
|
|
69
|
+
const MCP_TOKEN = /^ck_mcp_[A-Za-z0-9_-]+$/;
|
|
70
|
+
/** Runs `--token-only`; returns the exit code. Writes stdout only on success. */
|
|
71
|
+
export async function runMcpTokenOnly(run) {
|
|
72
|
+
const refusal = tokenOnlyRefusal(run.sub, run.flags);
|
|
73
|
+
if (refusal) {
|
|
74
|
+
run.err(` ${color.red('✗')} ${refusal}\n`);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
const built = mcpMintInput(run.flags, { label: run.defaultLabel, agentId: run.agentId });
|
|
78
|
+
if ('error' in built) {
|
|
79
|
+
run.err(` ${color.red('✗')} ${built.error}\n`);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
let res;
|
|
83
|
+
try {
|
|
84
|
+
res = await run.mint(built.input);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (run.onMintError)
|
|
88
|
+
return run.onMintError(e);
|
|
89
|
+
run.err(` ${color.red('✗')} Could not mint a token: ${e?.message ?? e}\n`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const key = typeof res?.key === 'string' ? res.key : '';
|
|
93
|
+
if (!MCP_TOKEN.test(key)) {
|
|
94
|
+
run.err(` ${color.red('✗')} ${res?.error ?? 'No ck_mcp_ token returned.'}\n`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
run.out(`${key}\n`);
|
|
98
|
+
const prefix = String(res.prefix ?? key.slice(0, 12));
|
|
99
|
+
const now = run.now?.() ?? Date.now();
|
|
100
|
+
const expires = typeof res.expiresAt === 'number'
|
|
101
|
+
? `expires ${new Date(res.expiresAt).toISOString().slice(0, 10)}`
|
|
102
|
+
: built.input.expiresInDays === 0
|
|
103
|
+
? 'never expires'
|
|
104
|
+
: `expires in ${built.input.expiresInDays} days (from ${new Date(now).toISOString().slice(0, 10)})`;
|
|
105
|
+
const agent = res.agentName || res.agentId || built.input.agentId;
|
|
106
|
+
run.err(` ${color.green('✓')} Minted ${prefix} ${color.dim([res.label ?? built.input.label, expires, agent ? `agent ${agent}` : ''].filter(Boolean).join(' · '))}\n`);
|
|
107
|
+
run.err(color.dim(' stdout carried a live workspace credential, shown this once and stored hashed.\n'));
|
|
108
|
+
if (run.stdoutIsTTY) {
|
|
109
|
+
run.err(` ${color.yellow('⚠')} stdout is a terminal, so the token is on screen. Pipe it into a secret store instead.\n`);
|
|
110
|
+
}
|
|
111
|
+
run.err(color.dim(` Revoke with: beehaven call revoke-mcp-token '{"prefix":"${prefix}"}'\n`));
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReconnectBackoff — when to redial a dropped socket, for the daemon's relay socket
|
|
3
|
+
* (`relay.ts`) and its agent sessions (`agent-sessions.ts`).
|
|
4
|
+
*
|
|
5
|
+
* ⚠ PPO-532. Both sockets reset their backoff the moment a socket OPENED. A workspace that
|
|
6
|
+
* runs out of memory while hydrating a new socket accepts it, then drops it (1006) seconds
|
|
7
|
+
* later — so every retry was "the first" and came back in ~1 s. On the staging Playground
|
|
8
|
+
* home that was a reconnect → hydrate → out-of-memory → reset loop, 46 reconnects an hour per
|
|
9
|
+
* socket, with the two sockets closing in pairs every ~13 s.
|
|
10
|
+
*
|
|
11
|
+
* Now a connection only counts as recovered once it has stayed open for `healthyMs` (60 s);
|
|
12
|
+
* until then the delay keeps doubling. Each delay is FULL JITTER — uniform over
|
|
13
|
+
* [0, min(cap, base·2^attempt)) — so the daemon socket and its agent sockets (which drop
|
|
14
|
+
* together) do not come back together. Same shape as HiveClient's PPO-249 backoff.
|
|
15
|
+
*
|
|
16
|
+
* No timers: the healthy check is a timestamp read when the socket closes (or when
|
|
17
|
+
* `attempts` is read), so there is nothing to clean up on shutdown.
|
|
18
|
+
*/
|
|
19
|
+
export const RECONNECT_BASE_MS = 1_000;
|
|
20
|
+
export const RECONNECT_CAP_MS = 30_000;
|
|
21
|
+
export const RECONNECT_HEALTHY_MS = 60_000;
|
|
22
|
+
export class ReconnectBackoff {
|
|
23
|
+
attempt = 0;
|
|
24
|
+
openedAt = null;
|
|
25
|
+
baseMs;
|
|
26
|
+
capMs;
|
|
27
|
+
healthyMs;
|
|
28
|
+
random;
|
|
29
|
+
now;
|
|
30
|
+
constructor(opts = {}) {
|
|
31
|
+
this.baseMs = opts.baseMs ?? RECONNECT_BASE_MS;
|
|
32
|
+
this.capMs = opts.capMs ?? RECONNECT_CAP_MS;
|
|
33
|
+
this.healthyMs = opts.healthyMs ?? RECONNECT_HEALTHY_MS;
|
|
34
|
+
this.random = opts.random ?? Math.random;
|
|
35
|
+
this.now = opts.now ?? Date.now;
|
|
36
|
+
}
|
|
37
|
+
/** Reconnects since the last healthy connection. 0 = healthy (or never failed). */
|
|
38
|
+
get attempts() {
|
|
39
|
+
this.settle();
|
|
40
|
+
return this.attempt;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A socket opened. This does NOT reset the backoff — only staying open does. Returns the
|
|
44
|
+
* connection's stamp; pass it to `closed()` so a stale socket closing late cannot clear the
|
|
45
|
+
* clock of the socket that replaced it.
|
|
46
|
+
*/
|
|
47
|
+
opened() {
|
|
48
|
+
const at = this.now();
|
|
49
|
+
this.openedAt = at;
|
|
50
|
+
return at;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A socket closed. If it had stayed open `healthyMs`, the next retry starts from the base.
|
|
54
|
+
* `openedAt` is the stamp `opened()` returned (null/omitted = never opened, or the current one).
|
|
55
|
+
*/
|
|
56
|
+
closed(openedAt = this.openedAt) {
|
|
57
|
+
if (openedAt !== null && this.now() - openedAt >= this.healthyMs)
|
|
58
|
+
this.attempt = 0;
|
|
59
|
+
if (openedAt === this.openedAt)
|
|
60
|
+
this.openedAt = null;
|
|
61
|
+
}
|
|
62
|
+
/** Delay before the next reconnect attempt (ms), and count the attempt. */
|
|
63
|
+
next() {
|
|
64
|
+
this.settle();
|
|
65
|
+
const ceiling = Math.min(this.capMs, this.baseMs * 2 ** Math.min(this.attempt, 30));
|
|
66
|
+
this.attempt++;
|
|
67
|
+
return Math.round(this.random() * ceiling);
|
|
68
|
+
}
|
|
69
|
+
/** An explicit (re)start — user action, network back, watchdog: start over from the base. */
|
|
70
|
+
reset() {
|
|
71
|
+
this.attempt = 0;
|
|
72
|
+
this.openedAt = null;
|
|
73
|
+
}
|
|
74
|
+
settle() {
|
|
75
|
+
if (this.openedAt !== null && this.now() - this.openedAt >= this.healthyMs)
|
|
76
|
+
this.attempt = 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/relay.js
CHANGED
|
@@ -10,6 +10,7 @@ import { join, dirname, resolve, sep } from 'path';
|
|
|
10
10
|
import { execSync, execFileSync } from 'child_process';
|
|
11
11
|
import WebSocket from 'ws';
|
|
12
12
|
import { logger } from './logger.js';
|
|
13
|
+
import { ReconnectBackoff } from './reconnect-backoff.js';
|
|
13
14
|
import { TerminalBlockParser } from './terminal/block-parser.js';
|
|
14
15
|
import { writeIntegration, cleanupIntegration } from './terminal/shell-integration.js';
|
|
15
16
|
import { transformPath } from './daemon-path-transform.js';
|
|
@@ -73,11 +74,16 @@ export class Relay {
|
|
|
73
74
|
// ── WebSocket ─────────────────────────────────────────────────────────
|
|
74
75
|
ws = null;
|
|
75
76
|
wsConnecting = false;
|
|
76
|
-
|
|
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;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clearly-artifacts
|
|
3
|
+
description: >-
|
|
4
|
+
Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so: the connector cannot make a running page.
|
|
5
|
+
Put a self-contained interactive HTML page into a Clearly workspace, the way a
|
|
6
|
+
Claude artifact works: an explainer, an animation, a small tool, a calculator, a
|
|
7
|
+
chart that runs. Covers which of the three HTML homes runs scripts (a SITE does,
|
|
8
|
+
a canvas ARTIFACT card does but cannot be clicked, a NOTE never does), the exact
|
|
9
|
+
CLI calls, the sandbox limits, updating, filing in a project, how a person opens
|
|
10
|
+
it, and QA. Use when asked to publish, host or add an HTML page, demo or animation
|
|
11
|
+
to the workspace. Triggers: "make an artifact", "publish this HTML", "add this page /
|
|
12
|
+
animation / explainer to the workspace", "host this demo", "interactive page",
|
|
13
|
+
"put this HTML on the canvas", "why doesn't my page's script run".
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Clearly artifacts — a running HTML page in the workspace
|
|
17
|
+
|
|
18
|
+
Sign in first (`clearly-agent`). `beehaven env` must name the environment you mean; staging and
|
|
19
|
+
production hold different workspaces.
|
|
20
|
+
|
|
21
|
+
## 1. Pick the home — only two of the three run code
|
|
22
|
+
|
|
23
|
+
| Home | Make it with | Scripts and CSS animation | Clickable | Search, history | Opens at |
|
|
24
|
+
|---|---|---|---|---|---|
|
|
25
|
+
| **Site** | `compose-upload-init` → `file-teleport-inline` → `compose-upload-finalize` | run | yes | not in `document-search`; no version history; a re-upload overwrites | Recents or project card → `/compositions/detail?id=<id>` |
|
|
26
|
+
| **Canvas artifact** | `canvas-act` `canvas.add-artifact` | run | **no**: the card never takes pointer events, so it pans like a picture | no; it is part of the canvas and is deleted with it | the canvas, `/c/<compositionId>` |
|
|
27
|
+
| **Note** | `document-create {html}`; `page-write` (in-app chat only) | **never**: it opens in the document editor, which drops `<script>`, `<style>` and inline `<svg>` | n/a | yes, both | `/documents/<id>` |
|
|
28
|
+
|
|
29
|
+
- An interactive page someone opens, clicks through or comes back to is a **site**. This is the
|
|
30
|
+
Claude-artifact equivalent and the default.
|
|
31
|
+
- A page that only needs to be **seen** beside other cards (a looping animation, a live chart in a
|
|
32
|
+
review board) is a **canvas artifact**. Buttons inside it cannot be pressed.
|
|
33
|
+
- Prose the team will read, search and edit is a **note**. Never put a script in a note: it
|
|
34
|
+
is stored, and then nothing ever runs it. Editing the note in the app rewrites its body.
|
|
35
|
+
|
|
36
|
+
No home gives a public link that runs. Every share link (`/r/…`) is served with
|
|
37
|
+
`script-src 'none'`, and a shared composition renders as a static picture. Say so when someone asks to
|
|
38
|
+
publish an interactive page outside the workspace.
|
|
39
|
+
|
|
40
|
+
## 2. Write it self-contained
|
|
41
|
+
|
|
42
|
+
Both running homes inject the page with `srcdoc` into `<iframe sandbox="allow-scripts">`, with no
|
|
43
|
+
`allow-same-origin`. The page gets an opaque origin (`window.origin === "null"`).
|
|
44
|
+
|
|
45
|
+
- **One file.** `<!doctype html>`, `<meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
46
|
+
CSS in `<style>`, JS in `<script>`, art as inline SVG, images and fonts as `data:` URIs,
|
|
47
|
+
system font stacks. Make no network requests and load no CDN scripts.
|
|
48
|
+
- **The sandbox blocks:** `localStorage`, `sessionStorage`, cookies and IndexedDB (accessing them
|
|
49
|
+
throws), `alert`/`confirm`/`prompt`, form submission, popups, `window.open` and top-level
|
|
50
|
+
navigation. Keep state in memory, and wrap any storage access in `try`.
|
|
51
|
+
- **A site's other files are rarely reached.** The preview rewrites only `src="…"`/`href="…"`
|
|
52
|
+
in the entry HTML that name a build file exactly (up to 60 files). It does not rewrite `url()`
|
|
53
|
+
inside CSS, `import`, or `fetch` of a sibling file. Inline them.
|
|
54
|
+
- **Size:** a canvas artifact is at most 512 KB. A site file is at most 6 MB inline. Over the CLI
|
|
55
|
+
WebSocket, keep a single `beehaven call` under about 900 KB, or use `beehaven compose push`,
|
|
56
|
+
which switches to a presigned upload.
|
|
57
|
+
- **Motion:** honour `@media (prefers-reduced-motion: reduce)`. A canvas card lays the page out
|
|
58
|
+
at `designWidth` (default 1280) and grows to the document's full height. A `100vh` layout
|
|
59
|
+
makes it grow again on every fit, so size sections in px or by content.
|
|
60
|
+
|
|
61
|
+
## 3. Make it (CLI)
|
|
62
|
+
|
|
63
|
+
Build JSON payloads with a script and pass them as `@file`. Do not hand-escape HTML into a shell
|
|
64
|
+
argument.
|
|
65
|
+
|
|
66
|
+
**Site, from one file** (one `file-teleport-inline` per file; `entryPoint` and each path are
|
|
67
|
+
relative to `build/`):
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
beehaven call compose-upload-init '{"name":"MCP tokens explained","sourceType":"static","entryPoint":"index.html","projectId":"<projectId>"}'
|
|
71
|
+
# → { "id": "cmp_…", "created": true }
|
|
72
|
+
node -e 'const fs=require("fs"),b=fs.readFileSync("page.html");fs.writeFileSync("up.json",JSON.stringify({key:"compositions/<id>/build/index.html",composition:true,name:"index.html",mimeType:"text/html",size:b.length,data:b.toString("base64")}))'
|
|
73
|
+
beehaven call file-teleport-inline @up.json
|
|
74
|
+
beehaven call compose-upload-finalize '{"compositionId":"<id>","totalBytes":<bytes>,"entryPoint":"index.html"}'
|
|
75
|
+
beehaven call composition-fs '{"compositionId":"<id>","action":"list"}' # one row per file, sizes match
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Both parts of the upload are load-bearing. A `key` outside `compositions/<id>/build/`, or a
|
|
79
|
+
missing `composition:true`, still answers ok, and the site then renders nothing.
|
|
80
|
+
|
|
81
|
+
**Site, from a folder** that holds only the page and its assets:
|
|
82
|
+
`cd <folder> && beehaven compose create --name "<title>" && beehaven compose push`. Every file in
|
|
83
|
+
the folder is uploaded. A Vite, Next, Astro or SvelteKit `package.json` there runs its build first.
|
|
84
|
+
To push to an existing site, run `beehaven compose push --composition <id>` from the folder; there
|
|
85
|
+
is no `--dir` flag. `compose create` cannot file the site, so file it afterwards (§4). Ignore the
|
|
86
|
+
URL that `push` prints, because it does not open the site.
|
|
87
|
+
|
|
88
|
+
**Canvas artifact** (create or reuse the canvas with `composition-create {title, projectId}`):
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
# payload: {"compositionId":"<canvasId>","action":"canvas.add-artifact",
|
|
92
|
+
# "args":{"html":"<!doctype html>…","title":"…","name":"explainer","x":0,"y":0,"w":1000,"designWidth":1000}}
|
|
93
|
+
beehaven call canvas-act @artifact.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`w` is the card's width on the board; `designWidth` is the viewport the page lays out at. Use 390
|
|
97
|
+
for a phone design. `canvas.place {kind:"site", id}` puts an existing site on a canvas as an
|
|
98
|
+
artifact card. It copies the entry HTML at that moment: later re-uploads do not reach the card,
|
|
99
|
+
and the site's other files do not load.
|
|
100
|
+
|
|
101
|
+
**Connector only (MCP):** no tool makes a site or an artifact. `clearly_file_upload` cannot choose
|
|
102
|
+
a site path, and `clearly_create` stores a document body as markdown. `clearly_canvas_place
|
|
103
|
+
{target, kind:"site", id}` can place a site that already exists. Otherwise, tell the person the
|
|
104
|
+
running page needs the CLI.
|
|
105
|
+
|
|
106
|
+
## 4. Update, file, link
|
|
107
|
+
|
|
108
|
+
- **Update a site:** upload to the same `key`, then `compose-upload-finalize` again. Only someone
|
|
109
|
+
who may manage the site (its creator) can overwrite it. A file you stop shipping stays in the
|
|
110
|
+
build. Agents are refused `composition-fs … "action":"delete"`, so ask the person to remove it,
|
|
111
|
+
or stop referencing it. There is no history, so keep the source in the repository or a note.
|
|
112
|
+
- **Update a canvas artifact:** there is no edit verb. Add the new version at the old card's
|
|
113
|
+
`x`/`y`/`w`, then remove the old card with `canvas.delete {ids:[…]}`.
|
|
114
|
+
- **File:** `projectId` on `compose-upload-init` (to file later, call it again with
|
|
115
|
+
`{"compositionId","projectId"}`). For a canvas, `composition-create {projectId}`. For a note,
|
|
116
|
+
`document-create {projectId}` or `document-update {id, projectId}`. Unfiled agent work is
|
|
117
|
+
private to its author.
|
|
118
|
+
- **Link it to the ticket:** `beehaven call work-link '{"ticketId":"…","kind":"design","refKind":"composition","refId":"<id>"}'`.
|
|
119
|
+
|
|
120
|
+
## 5. Hand it over
|
|
121
|
+
|
|
122
|
+
Give the person the place to click, not just an id.
|
|
123
|
+
|
|
124
|
+
- A site: "Recents (or the project) → the card", which opens `/compositions/detail?id=<id>`.
|
|
125
|
+
Do not hand over `/c/<siteId>`. That is what `beehaven open`, ticket links and workspace-ref
|
|
126
|
+
chips build for a site, and it opens an empty canvas instead of the page.
|
|
127
|
+
- A canvas artifact: `/c/<canvasId>`. Double-clicking the card opens its stored file in a new tab,
|
|
128
|
+
which shows source text or an access error, never the running page.
|
|
129
|
+
|
|
130
|
+
## 6. QA — open it, read the console, check mobile
|
|
131
|
+
|
|
132
|
+
1. **Render it in the real frame model** before uploading. Use Playwright: an
|
|
133
|
+
`<iframe sandbox="allow-scripts" srcdoc>` at 1280 and 390 wide. Confirm there are no console
|
|
134
|
+
errors and no requests from the frame, that `scrollWidth === clientWidth`, that the animation
|
|
135
|
+
advances by itself, and that its controls respond.
|
|
136
|
+
2. **Read the stored copy back:** check that `composition-fs list` shows the size you sent.
|
|
137
|
+
3. **See the server render it.** For a canvas artifact, run
|
|
138
|
+
`canvas-act {"compositionId":"<canvasId>","action":"canvas.review","args":{"ids":["<artifactId>"]}}`.
|
|
139
|
+
It returns a filmstrip of four frames across about 5.4 s; inspect the PNG. For a site, place it on a scratch canvas
|
|
140
|
+
(`canvas.place {kind:"site", id}`), review that card the same way, and then archive the scratch
|
|
141
|
+
canvas (`composition-set-archived {id, archived:true}`).
|
|
142
|
+
4. Report what you checked and what you could not check. Opening the signed-in page itself needs a
|
|
143
|
+
person's session.
|