@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
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 = '7674ddaca41b';
|
|
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-29T21:35Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beehaven call <action> … --brief` — a receipt a person (or an agent's context window) can read.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ WHY THIS EXISTS. A `canvas-act` batch answers with every step's full value plus the lint of
|
|
5
|
+
* every touched artboard — routinely tens of kilobytes. Agents learned to pipe it into a summariser
|
|
6
|
+
* they wrote on the spot (`… 2>&1 | node summ.mjs`), and in the first recorded canvas take (MCP2-20)
|
|
7
|
+
* that improvisation cost four failed tool calls: shell pipes and `/tmp` redirects need approval in
|
|
8
|
+
* a headless agent, and the summariser crashed on the stderr breadcrumb it was fed. The receipt the
|
|
9
|
+
* agent wanted is printed here instead, from the same result, with the exit code unchanged.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ IT DROPS NOTHING THAT DECIDES THE NEXT STEP: failed steps (with their error), rollbacks and
|
|
12
|
+
* skips, lint findings that need fixing, a review's verdict and blocking count. What it leaves out
|
|
13
|
+
* is bulk — per-step echoes, previews, font metadata. `--json` still prints the whole payload.
|
|
14
|
+
*/
|
|
15
|
+
const clip = (s, n) => {
|
|
16
|
+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
18
|
+
};
|
|
19
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
20
|
+
/** `canvas.create-node` → `create-node`; `frame.create` stays as written. */
|
|
21
|
+
const verb = (a) => (a.startsWith('canvas.') ? a.slice(7) : a);
|
|
22
|
+
export function briefReceipt(action, result) {
|
|
23
|
+
const r = (result && typeof result === 'object' ? result : { value: result });
|
|
24
|
+
const error = typeof r.error === 'string' && r.error ? r.error : null;
|
|
25
|
+
/**
|
|
26
|
+
* MCP2-28 (codex review) — A FAILED BATCH IS STILL A BATCH. This returned the one-line refusal for
|
|
27
|
+
* any `ok:false`, before looking at `results`, so a rolled-back canvas-act (step 2 of 2 refused)
|
|
28
|
+
* printed a truncated error with no step number and no "rolled back". The batch receipt is read
|
|
29
|
+
* first; the top-level failure is kept on it, so the exit code still says it failed.
|
|
30
|
+
*/
|
|
31
|
+
if (Array.isArray(r.results) && r.results.length) {
|
|
32
|
+
const b = batchReceipt(action, r);
|
|
33
|
+
if (error || r.ok === false) {
|
|
34
|
+
b.ok = false;
|
|
35
|
+
b.lines[0] = b.lines[0].replace(/^✓/, '✗');
|
|
36
|
+
const why = clip(error ?? r.message ?? r.code ?? '', 200);
|
|
37
|
+
// Not repeated when a failed step already says it (the step line clips at 140, this at 200).
|
|
38
|
+
if (why && !b.lines.some((l) => l.includes(why.slice(0, 60))))
|
|
39
|
+
b.lines.splice(1, 0, ` ${why}`);
|
|
40
|
+
}
|
|
41
|
+
return b;
|
|
42
|
+
}
|
|
43
|
+
if (error || r.ok === false) {
|
|
44
|
+
return { ok: false, lines: [`✗ ${action} · ${clip(error ?? r.message ?? r.code ?? 'refused', 160)}`] };
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(r.results))
|
|
47
|
+
return batchReceipt(action, r);
|
|
48
|
+
return { ok: true, lines: [`✓ ${action}${describeValue(r)}`] };
|
|
49
|
+
}
|
|
50
|
+
function batchReceipt(action, r) {
|
|
51
|
+
const steps = r.results;
|
|
52
|
+
const failed = steps.map((s, i) => ({ s, i })).filter(({ s }) => s?.ok === false);
|
|
53
|
+
const lines = [];
|
|
54
|
+
// What ran: a review or a set of measurements reads better as its result than as a step count.
|
|
55
|
+
const reviews = steps.filter((s) => /canvas\.review/.test(s?.action) && s?.ok !== false);
|
|
56
|
+
const measures = steps.filter((s) => s?.action === 'canvas.measure-text' && s?.ok !== false);
|
|
57
|
+
const counts = new Map();
|
|
58
|
+
for (const s of steps)
|
|
59
|
+
counts.set(verb(s?.action ?? '?'), (counts.get(verb(s?.action ?? '?')) ?? 0) + 1);
|
|
60
|
+
const mix = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([a, n]) => (n > 1 ? `${a} ×${n}` : a)).join(', ');
|
|
61
|
+
let added = 0, changed = 0, removed = 0, sawDelta = false;
|
|
62
|
+
for (const s of steps) {
|
|
63
|
+
const d = s?.value?.canvasChanges;
|
|
64
|
+
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
|
65
|
+
sawDelta = true;
|
|
66
|
+
added += Number(d.added || 0);
|
|
67
|
+
changed += Number(d.changed || 0);
|
|
68
|
+
removed += Number(d.removed || 0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const rev = r.canvasNodesRev ?? [...steps].reverse().find((s) => s?.value?.canvasNodesRev != null)?.value?.canvasNodesRev;
|
|
72
|
+
const head = [`${failed.length ? '✗' : '✓'} ${action}`, `${plural(steps.length, 'step')}${mix ? ` (${mix})` : ''}`];
|
|
73
|
+
if (sawDelta && added + changed + removed)
|
|
74
|
+
head.push(`+${added} ~${changed} −${removed} layers`);
|
|
75
|
+
if (rev != null)
|
|
76
|
+
head.push(`rev ${rev}`);
|
|
77
|
+
if (r.rolledBack)
|
|
78
|
+
head.push('rolled back');
|
|
79
|
+
if (r.partial)
|
|
80
|
+
head.push('partly applied');
|
|
81
|
+
if (Array.isArray(r.skipped) ? r.skipped.length : r.skipped)
|
|
82
|
+
head.push(`${Array.isArray(r.skipped) ? r.skipped.length : r.skipped} skipped`);
|
|
83
|
+
lines.push(head.join(' · '));
|
|
84
|
+
for (const { s, i } of failed.slice(0, 3)) {
|
|
85
|
+
lines.push(` ✗ step ${i + 1} ${verb(s.action ?? '?')}: ${clip(s.error ?? s.value?.error ?? 'failed', 140)}`);
|
|
86
|
+
}
|
|
87
|
+
if (failed.length > 3)
|
|
88
|
+
lines.push(` … ${failed.length - 3} more failed steps`);
|
|
89
|
+
for (const m of measures.slice(0, 6)) {
|
|
90
|
+
const v = m.value ?? {};
|
|
91
|
+
lines.push(` ${Math.round(v.width ?? 0)}×${Math.round(v.height ?? 0)} · ${plural(v.lineCount ?? 1, 'line')}${v.exact === false ? ' · estimated' : ''} — ${v.family ?? ''} ${v.size ?? ''}/${v.fontWeight ?? ''}`.trimEnd());
|
|
92
|
+
}
|
|
93
|
+
for (const rv of reviews) {
|
|
94
|
+
const v = rv.value ?? {};
|
|
95
|
+
const reports = Array.isArray(v.reviews) ? v.reviews : [v];
|
|
96
|
+
for (const rep of reports) {
|
|
97
|
+
const blocking = rep.blocking?.craft ?? 0;
|
|
98
|
+
const where = rep.scope?.name ?? rep.scope?.ids?.[0] ?? '';
|
|
99
|
+
lines.push(` review${where ? ` ${clip(where, 40)}` : ''}: ${rep.verdict ?? 'unknown'}${blocking ? ` · ${plural(blocking, 'blocking finding')}` : ''}`);
|
|
100
|
+
for (const f of (rep.craft?.findings ?? []).filter((x) => x.severity === 'warn').slice(0, 3)) {
|
|
101
|
+
lines.push(` ${f.check}: ${clip(f.detail, 120)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Lint of the touched artboards: what must be fixed next, never the whole report.
|
|
106
|
+
for (const d of (Array.isArray(r.diagnostics) ? r.diagnostics : [])) {
|
|
107
|
+
const errors = d.errors ?? [];
|
|
108
|
+
if (!errors.length && d.verdict !== 'fix')
|
|
109
|
+
continue;
|
|
110
|
+
lines.push(` lint ${clip(d.artboard ?? d.id, 40)}: ${d.verdict ?? 'fix'}`);
|
|
111
|
+
for (const e of errors.slice(0, 3))
|
|
112
|
+
lines.push(` ${clip(e, 140)}`);
|
|
113
|
+
if (errors.length > 3 || d.moreErrors)
|
|
114
|
+
lines.push(` … ${(errors.length - 3 > 0 ? errors.length - 3 : 0) + (d.moreErrors ?? 0)} more`);
|
|
115
|
+
}
|
|
116
|
+
const clean = (Array.isArray(r.diagnostics) ? r.diagnostics : []).filter((d) => d.verdict && d.verdict !== 'fix');
|
|
117
|
+
if (clean.length)
|
|
118
|
+
lines.push(` lint ${[...new Set(clean.map((d) => d.verdict))].join('/')} on ${plural(clean.length, 'artboard')}`);
|
|
119
|
+
/**
|
|
120
|
+
* MCP2-25 — THE CARRIED TICKET'S OUTCOME DECIDES THE NEXT STEP TOO. A batch carrying `ticket` moves
|
|
121
|
+
* it only when its review passes; the receipt said nothing either way, so an agent could not tell a
|
|
122
|
+
* done commit from one the review held back.
|
|
123
|
+
*/
|
|
124
|
+
const t = r.ticket;
|
|
125
|
+
if (t && typeof t === 'object') {
|
|
126
|
+
if (t.applied)
|
|
127
|
+
lines.push(` ticket ${t.key ?? t.id} → ${t.status ?? 'updated'}${t.frame?.url ? ` · frame ${t.frame.url}` : ''}`);
|
|
128
|
+
else
|
|
129
|
+
lines.push(` ✗ ticket ${t.id ?? ''} not moved: ${clip(t.reason ?? t.error ?? 'refused', 200)}`);
|
|
130
|
+
}
|
|
131
|
+
return { ok: failed.length === 0, lines };
|
|
132
|
+
}
|
|
133
|
+
/** A one-line description of an ordinary result: its identity and what it holds. */
|
|
134
|
+
function describeValue(r) {
|
|
135
|
+
const bits = [];
|
|
136
|
+
const id = r.key ?? r.id ?? r.compositionId ?? r.documentId;
|
|
137
|
+
if (id)
|
|
138
|
+
bits.push(String(id).length > 24 ? String(id).slice(0, 8) : String(id));
|
|
139
|
+
const title = r.title ?? r.name;
|
|
140
|
+
if (typeof title === 'string' && title)
|
|
141
|
+
bits.push(`"${clip(title, 60)}"`);
|
|
142
|
+
if (typeof r.status === 'string')
|
|
143
|
+
bits.push(r.status);
|
|
144
|
+
if (r.verdict)
|
|
145
|
+
bits.push(`verdict ${r.verdict}`);
|
|
146
|
+
for (const [k, v] of Object.entries(r)) {
|
|
147
|
+
if (Array.isArray(v) && v.length && bits.length < 4 && !['results', 'diagnostics'].includes(k))
|
|
148
|
+
bits.push(`${v.length} ${k}`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof r.count === 'number' && !bits.some((b) => b.endsWith(' items')))
|
|
151
|
+
bits.push(`count ${r.count}`);
|
|
152
|
+
if (typeof r.url === 'string')
|
|
153
|
+
bits.push(r.url);
|
|
154
|
+
if (r.value !== undefined && typeof r.value !== 'object')
|
|
155
|
+
bits.push(clip(r.value, 120));
|
|
156
|
+
return bits.length ? ` · ${bits.join(' · ')}` : '';
|
|
157
|
+
}
|
package/dist/claude-bridge.js
CHANGED
|
@@ -22,6 +22,7 @@ import { homedir } from 'os';
|
|
|
22
22
|
import { EventEmitter } from 'events';
|
|
23
23
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
24
24
|
import { ipcSocketPath, watcherSocketPath, socketMayExist } from './ipc-path.js';
|
|
25
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
25
26
|
const CONFIG_DIR = join(homedir(), '.beehaven');
|
|
26
27
|
export class ClaudeBridge extends EventEmitter {
|
|
27
28
|
socket = null;
|
|
@@ -56,8 +57,10 @@ export class ClaudeBridge extends EventEmitter {
|
|
|
56
57
|
this.send({ type: 'session:announce', label: 'claude-code', app: 'bridge' });
|
|
57
58
|
resolve(true);
|
|
58
59
|
});
|
|
60
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
61
|
+
const decode = utf8Stream();
|
|
59
62
|
socket.on('data', (data) => {
|
|
60
|
-
this.buffer += data
|
|
63
|
+
this.buffer += decode(data);
|
|
61
64
|
const lines = this.buffer.split('\n');
|
|
62
65
|
this.buffer = lines.pop() || '';
|
|
63
66
|
for (const line of lines) {
|
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:
|
|
@@ -407,7 +408,7 @@ ${DEV_ONLY_HELP ? `
|
|
|
407
408
|
beehaven actions --all …including ones this caller cannot invoke
|
|
408
409
|
beehaven actions --search <q> Find one by substring
|
|
409
410
|
beehaven explain <action> Its exact arguments, from the live schema
|
|
410
|
-
beehaven call <action> [json] Invoke it; returns the raw JSON result
|
|
411
|
+
beehaven call <action> [json] Invoke it; returns the raw JSON result (--brief: a short receipt; --out <file>: the JSON to a file)
|
|
411
412
|
|
|
412
413
|
# discover, then run
|
|
413
414
|
beehaven actions --search ticket
|
|
@@ -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 },
|
|
@@ -1438,6 +1443,8 @@ async function run() {
|
|
|
1438
1443
|
address: { type: 'string' },
|
|
1439
1444
|
protocol: { type: 'string' },
|
|
1440
1445
|
json: { type: 'boolean', default: false },
|
|
1446
|
+
// `beehaven call … --brief`: a short receipt instead of the full JSON (call-brief.ts).
|
|
1447
|
+
brief: { type: 'boolean', default: false },
|
|
1441
1448
|
// `--conversation <id>` threads `beehaven agent "<msg>"` onto an existing
|
|
1442
1449
|
// Omni thread instead of minting a new one per invocation.
|
|
1443
1450
|
conversation: { type: 'string' },
|
|
@@ -1461,6 +1468,8 @@ async function run() {
|
|
|
1461
1468
|
client: { type: 'string' },
|
|
1462
1469
|
/** `beehaven mcp install --rotate` — mint a new token instead of reusing the installed one. */
|
|
1463
1470
|
rotate: { type: 'boolean' },
|
|
1471
|
+
/** `beehaven mcp-config --token-only --expires <days>` — the raw token alone on stdout, for piping. */
|
|
1472
|
+
'token-only': { type: 'boolean' },
|
|
1464
1473
|
password: { type: 'string' },
|
|
1465
1474
|
email: { type: 'string' },
|
|
1466
1475
|
expires: { type: 'string' },
|
|
@@ -2083,6 +2092,8 @@ async function run() {
|
|
|
2083
2092
|
'slides', 'deck',
|
|
2084
2093
|
// `brand tokens -o tokens.css` · `component render -o button.svg`
|
|
2085
2094
|
'brand', 'brands', 'component', 'components',
|
|
2095
|
+
// MCP2-25 — `call <action> … --out result.json`: the whole result to a file, the receipt to the console.
|
|
2096
|
+
'call',
|
|
2086
2097
|
]);
|
|
2087
2098
|
/**
|
|
2088
2099
|
* ⚠ ONE REFUSAL, because there is now one flag. Both spellings are named in the message so a
|
|
@@ -3033,10 +3044,19 @@ async function run() {
|
|
|
3033
3044
|
// own copy aimed at /auth/beehaven, a route the web app no longer serves.
|
|
3034
3045
|
// What follows the sign-in — restarting the daemon, teaching the agents —
|
|
3035
3046
|
// stays here, because the daemon fallback must not restart itself.
|
|
3047
|
+
// AWO-402 — the device-code flow for a machine with no browser of its own: forced with
|
|
3048
|
+
// `--device-code`, chosen automatically over SSH / CI / a display-less Linux, and never when
|
|
3049
|
+
// the person asked for `--browser`.
|
|
3036
3050
|
let captured;
|
|
3037
3051
|
try {
|
|
3038
|
-
const { browserLogin } = await import('./browser-login.js');
|
|
3039
|
-
|
|
3052
|
+
const { browserLogin, deviceLogin, preferDeviceLogin } = await import('./browser-login.js');
|
|
3053
|
+
const useDevice = Boolean(values['device-code']) || (!values.browser && preferDeviceLogin());
|
|
3054
|
+
if (useDevice && !values['device-code']) {
|
|
3055
|
+
console.log(color.dim(' No browser on this machine, so signing in with a code (`beehaven login --browser` to open one here).'));
|
|
3056
|
+
}
|
|
3057
|
+
captured = useDevice
|
|
3058
|
+
? await deviceLogin({ client: `beehaven ${getVersion()} (${process.platform})` })
|
|
3059
|
+
: await browserLogin();
|
|
3040
3060
|
}
|
|
3041
3061
|
catch (err) {
|
|
3042
3062
|
console.error(` ${color.red('✗')} ${err?.message ?? err}`);
|
|
@@ -3170,6 +3190,7 @@ async function run() {
|
|
|
3170
3190
|
mcp: !!values.mcp,
|
|
3171
3191
|
print: !!values.print,
|
|
3172
3192
|
remove: !!values.remove,
|
|
3193
|
+
notTracked: Boolean(values['not-tracked']),
|
|
3173
3194
|
});
|
|
3174
3195
|
break;
|
|
3175
3196
|
}
|
|
@@ -4073,10 +4094,82 @@ complete -F _beehaven_completions beehaven`);
|
|
|
4073
4094
|
const tgtAddress = tgt?.address ?? 'home';
|
|
4074
4095
|
const mcpEntry = entryNameFor(tgtAddress);
|
|
4075
4096
|
const wsId = isHomeAddress(tgtAddress) ? '' : tgtAddress.split('/').slice(1).join('/');
|
|
4076
|
-
const
|
|
4097
|
+
const defaultMcpLabel = `${mcpEntry} · ${new Date().toISOString().slice(0, 10)}`;
|
|
4077
4098
|
const { rpcOnce: _rpc } = await import('./ipc-client.js');
|
|
4078
4099
|
const { resolveActiveAgent } = await import('./agent-state.js');
|
|
4100
|
+
const { mcpMintInput, runMcpTokenOnly } = await import('./commands/mcp-token.js');
|
|
4079
4101
|
const activeMcpAgent = resolveActiveAgent(values);
|
|
4102
|
+
/**
|
|
4103
|
+
* ⚠⚠ DO NOT ECHO THE RAW ERROR — IT SENT PEOPLE TO THE WRONG FIX.
|
|
4104
|
+
*
|
|
4105
|
+
* Observed: this printed "Cannot connect to daemon. Run `beehaven start --headless`" in the
|
|
4106
|
+
* same second that `beehaven status` reported "Daemon: running". The daemon was up and the
|
|
4107
|
+
* RELAY had dropped. Following the advice runs into the single-instance guard, which
|
|
4108
|
+
* refuses — so the message costs a cycle and teaches the wrong model of the failure.
|
|
4109
|
+
*
|
|
4110
|
+
* The CLI already distinguishes these three states carefully (see `status`), precisely
|
|
4111
|
+
* because the fixes are mutually exclusive: a restart will NOT refresh an expired token,
|
|
4112
|
+
* and a re-login will NOT revive a dropped socket. This path bypassed all of it by
|
|
4113
|
+
* forwarding whatever string the transport happened to raise.
|
|
4114
|
+
*
|
|
4115
|
+
* Re-probe and say what is actually true. Stderr only, and it returns the exit code, so the
|
|
4116
|
+
* `--token-only` path can use it without touching stdout.
|
|
4117
|
+
*/
|
|
4118
|
+
const explainMintFailure = async (err) => {
|
|
4119
|
+
const { isDaemonRunning } = await import('./daemon-client.js');
|
|
4120
|
+
let up = false;
|
|
4121
|
+
try {
|
|
4122
|
+
up = await isDaemonRunning();
|
|
4123
|
+
}
|
|
4124
|
+
catch {
|
|
4125
|
+
up = false;
|
|
4126
|
+
}
|
|
4127
|
+
let expired = false;
|
|
4128
|
+
try {
|
|
4129
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), '.beehaven', 'config.json'), 'utf8'));
|
|
4130
|
+
const payload = JSON.parse(Buffer.from(String(cfg.token).split('.')[1], 'base64').toString());
|
|
4131
|
+
expired = !payload.exp || Date.now() / 1000 > payload.exp - 60;
|
|
4132
|
+
}
|
|
4133
|
+
catch {
|
|
4134
|
+
expired = true;
|
|
4135
|
+
}
|
|
4136
|
+
console.error(` ${color.red('✗')} Could not mint a token.`);
|
|
4137
|
+
console.error(!up
|
|
4138
|
+
? ' The daemon is not running — `beehaven start --headless`'
|
|
4139
|
+
: expired
|
|
4140
|
+
? ' The daemon is up but your token has EXPIRED — `beehaven login` (a restart will not refresh it)'
|
|
4141
|
+
: ' The daemon is up and your token is valid; the relay socket dropped — `beehaven restart`');
|
|
4142
|
+
console.error(color.dim(` (${err?.message ?? err})`));
|
|
4143
|
+
return 1;
|
|
4144
|
+
};
|
|
4145
|
+
/**
|
|
4146
|
+
* ⚠⚠ `--token-only`: THE RAW TOKEN ON STDOUT AND NOTHING ELSE, for
|
|
4147
|
+
* `… | gh secret set CLEARLY_MCP_TOKEN`. The rules (stderr for every message, `--expires`
|
|
4148
|
+
* required, the caller's own agent) live in commands/mcp-token.ts, where the tests call them.
|
|
4149
|
+
* It branches off HERE, before the reuse check (print never reuses) and before anything below
|
|
4150
|
+
* writes to stdout.
|
|
4151
|
+
*/
|
|
4152
|
+
if (values['token-only']) {
|
|
4153
|
+
process.exitCode = await runMcpTokenOnly({
|
|
4154
|
+
sub: mcpSub,
|
|
4155
|
+
flags: values,
|
|
4156
|
+
defaultLabel: defaultMcpLabel,
|
|
4157
|
+
agentId: activeMcpAgent?.agentId,
|
|
4158
|
+
mint: (input) => _rpc('create-mcp-token', input, 30_000),
|
|
4159
|
+
out: (s) => { process.stdout.write(s); },
|
|
4160
|
+
err: (s) => { process.stderr.write(s); },
|
|
4161
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
4162
|
+
onMintError: explainMintFailure,
|
|
4163
|
+
});
|
|
4164
|
+
break;
|
|
4165
|
+
}
|
|
4166
|
+
// `--label` (else `--name`), `--expires <days>` and the caller's agent, for every mint below.
|
|
4167
|
+
const mintInput = mcpMintInput(values, { label: defaultMcpLabel, agentId: activeMcpAgent?.agentId });
|
|
4168
|
+
if ('error' in mintInput) {
|
|
4169
|
+
console.error(` ${color.red('✗')} ${mintInput.error}`);
|
|
4170
|
+
process.exit(2);
|
|
4171
|
+
}
|
|
4172
|
+
const label = mintInput.input.label;
|
|
4080
4173
|
let res;
|
|
4081
4174
|
/**
|
|
4082
4175
|
* ⚠⚠ REUSE THE TOKEN ALREADY INSTALLED RATHER THAN MINTING ANOTHER. Minting
|
|
@@ -4121,54 +4214,11 @@ complete -F _beehaven_completions beehaven`);
|
|
|
4121
4214
|
}
|
|
4122
4215
|
}
|
|
4123
4216
|
try {
|
|
4124
|
-
if (!res)
|
|
4125
|
-
res = await _rpc('create-mcp-token',
|
|
4126
|
-
label,
|
|
4127
|
-
...(activeMcpAgent?.agentId ? { agentId: activeMcpAgent.agentId } : {}),
|
|
4128
|
-
}, 30_000);
|
|
4129
|
-
}
|
|
4217
|
+
if (!res)
|
|
4218
|
+
res = await _rpc('create-mcp-token', mintInput.input, 30_000);
|
|
4130
4219
|
}
|
|
4131
4220
|
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);
|
|
4221
|
+
process.exit(await explainMintFailure(err));
|
|
4172
4222
|
}
|
|
4173
4223
|
if (!res?.key) {
|
|
4174
4224
|
console.error(` ${color.red('✗')} ${res?.error ?? 'No token returned.'}`);
|