@clearlysh/beehaven 0.8.31 → 0.8.33
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/agents-md-text.js +64 -0
- package/dist/browser-login.js +6 -1
- package/dist/build-channel.js +2 -2
- package/dist/cli.js +18 -0
- package/dist/commands/agents-md.js +118 -0
- package/dist/commands/help.js +21 -0
- package/dist/daemon.js +24 -10
- package/dist/dashboard.js +118 -6
- package/dist/ipc-server.js +11 -8
- package/dist/verb-surface.js +1 -0
- package/package.json +1 -1
- package/skills/agent-doodle-space/SKILL.md +1 -1
- package/skills/agent-home/SKILL.md +1 -1
- package/skills/brand-identity/SKILL.md +1 -0
- package/skills/clearly-agent/SKILL.md +11 -4
- package/skills/clearly-canvas/SKILL.md +15 -1
- package/skills/clearly-canvas/authoring.md +38 -11
- package/skills/clearly-canvas/example.mjs +72 -69
- package/skills/clearly-workflows/SKILL.md +1 -0
- package/skills/clearly-workspace/SKILL.md +1 -1
- package/skills/codebase-map/SKILL.md +1 -1
- package/skills/design-craft/SKILL.md +9 -6
- package/skills/index.json +13 -13
- package/skills/layout-systems/SKILL.md +14 -11
- package/skills/pair-on-canvas/SKILL.md +1 -1
- package/skills/sticker-pack/SKILL.md +1 -1
- package/skills/visualize/SKILL.md +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-md-text — the Clearly block for a PROJECT's AGENTS.md, as `beehaven agents-md` writes it
|
|
3
|
+
* (CAN2-66).
|
|
4
|
+
*
|
|
5
|
+
* ⚠⚠ A COPY, PINNED. The source of truth is the monorepo's `lib/connections/agentsMd.ts`, which the
|
|
6
|
+
* Integrations page, each agent's details and the MCP `clearly-agents-md` prompt render. This
|
|
7
|
+
* package cannot import it (it ships on its own), so the text is repeated here and the monorepo's
|
|
8
|
+
* `lib/__tests__/agentsMdCliParity.test.mjs` compares the two OUTPUTS, name argument included.
|
|
9
|
+
* Change both or the test fails — a stale copy does not render wrong, it instructs wrong.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ NO IMPORTS, so that test can load this file directly.
|
|
12
|
+
*/
|
|
13
|
+
export const CLI_BLOCK_START = '<!-- clearly:cli:start -->';
|
|
14
|
+
export const CLI_BLOCK_END = '<!-- clearly:cli:end -->';
|
|
15
|
+
export const MCP_BLOCK_START = '<!-- clearly:mcp:start -->';
|
|
16
|
+
export const MCP_BLOCK_END = '<!-- clearly:mcp:end -->';
|
|
17
|
+
/** What goes in CLAUDE.md so Claude Code reads the AGENTS.md block too. */
|
|
18
|
+
export const CLAUDE_MD_IMPORT = '@AGENTS.md';
|
|
19
|
+
/** The block for agents that use the `beehaven` CLI. */
|
|
20
|
+
export function agentsMdCliBlock(agentName) {
|
|
21
|
+
const name = (agentName ?? '').trim();
|
|
22
|
+
const login = name
|
|
23
|
+
? `\`beehaven agent login ${name} --label "<what you are doing>"\` — you are ${name} in this workspace.`
|
|
24
|
+
: '`beehaven agent login <name> --label "<what you are doing>"`, using your own name (claude, codex, gemini).';
|
|
25
|
+
return [
|
|
26
|
+
CLI_BLOCK_START,
|
|
27
|
+
'<!-- managed-by: clearly. Run `beehaven agents-md` to update; edit outside this block. -->',
|
|
28
|
+
'',
|
|
29
|
+
'## Clearly workspace',
|
|
30
|
+
'',
|
|
31
|
+
'This project keeps its documents, plans, boards and tickets in Clearly. Use the `beehaven` CLI',
|
|
32
|
+
'for anything the team should see, not files in this repo.',
|
|
33
|
+
'',
|
|
34
|
+
`- Sign in once per session: ${login}`,
|
|
35
|
+
' If it says the daemon is not running: `beehaven start --headless`.',
|
|
36
|
+
'- Read the `clearly-agent` skill before anything else, and `clearly-canvas` before design work',
|
|
37
|
+
' (`beehaven skills install` puts them in place).',
|
|
38
|
+
'- `beehaven actions` lists what you can call; `beehaven call <action> \'<json>\'` calls it.',
|
|
39
|
+
'- Search before you create, file work in the project it belongs to, and end the session with',
|
|
40
|
+
' `beehaven agent handoff "what you did · what is next"` so the next one starts there.',
|
|
41
|
+
CLI_BLOCK_END,
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
/** The block for agents connected over MCP (the `clearly` server). */
|
|
45
|
+
export function agentsMdMcpBlock() {
|
|
46
|
+
return [
|
|
47
|
+
MCP_BLOCK_START,
|
|
48
|
+
'<!-- managed-by: clearly-mcp. Re-run the `clearly-agents-md` prompt to update; edit outside this block. -->',
|
|
49
|
+
'',
|
|
50
|
+
'## Clearly workspace (connected over MCP as `clearly`)',
|
|
51
|
+
'',
|
|
52
|
+
'This project keeps its documents, plans, boards and tickets in Clearly. Use the `clearly_*`',
|
|
53
|
+
'tools, not files in this repo, for anything the team should see.',
|
|
54
|
+
'',
|
|
55
|
+
'- Start with `clearly_catalog` to see what exists; search with `clearly_grep` / `clearly_glob`.',
|
|
56
|
+
'- Read with `clearly_read`, make new work with `clearly_create`, change it with `clearly_edit`,',
|
|
57
|
+
' and discuss it with `clearly_comment`. They take a `target`: a path (`~/Q1/Plan.md`), a key',
|
|
58
|
+
' (`CLR-42`), an id or an exact title. The extension names the type (`.md` document, `.csv`',
|
|
59
|
+
' sheet, `.board.json` board); a trailing slash means a project.',
|
|
60
|
+
'- Search before you write, and write decisions back so the next session inherits them.',
|
|
61
|
+
'- If a job is not covered by a listed tool, say so rather than guessing an action name.',
|
|
62
|
+
MCP_BLOCK_END,
|
|
63
|
+
].join('\n');
|
|
64
|
+
}
|
package/dist/browser-login.js
CHANGED
|
@@ -265,7 +265,12 @@ export function persistBrowserLogin(captured) {
|
|
|
265
265
|
userId: captured.userId,
|
|
266
266
|
email: captured.email ?? null,
|
|
267
267
|
displayName: captured.displayName ?? null,
|
|
268
|
-
|
|
268
|
+
/**
|
|
269
|
+
* ⚠ SAY WHERE THE TOKEN REALLY IS (CAN2-65). This was always 'keychain', so on Windows —
|
|
270
|
+
* which has no keychain here — `whoami` reported a keychain that did not exist, and
|
|
271
|
+
* `Relay.loadConfig` / the refresh path shelled out to macOS `security` on every load.
|
|
272
|
+
*/
|
|
273
|
+
tokenStorage: keychain.available ? 'keychain' : 'file',
|
|
269
274
|
refreshToken: captured.refreshToken,
|
|
270
275
|
token: captured.idToken,
|
|
271
276
|
endpoint: dialUrl,
|
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 = '8f7eef9e12ce';
|
|
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-15T07:24Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
package/dist/cli.js
CHANGED
|
@@ -143,6 +143,7 @@ function printHelp() {
|
|
|
143
143
|
|
|
144
144
|
beehaven guide Agent-first setup and signup handoff
|
|
145
145
|
beehaven signup Create a free Clearly account
|
|
146
|
+
beehaven agents-md Add Clearly to this project's AGENTS.md
|
|
146
147
|
|
|
147
148
|
Personal workspace:
|
|
148
149
|
beehaven cd home
|
|
@@ -956,6 +957,11 @@ const AGENT_LOGIN_EXEMPT = new Set([
|
|
|
956
957
|
* would refuse the one person it is for — the agent whose user has not signed up yet (SGS-73).
|
|
957
958
|
*/
|
|
958
959
|
'handoff',
|
|
960
|
+
/**
|
|
961
|
+
* ⚠ `agents-md` WRITES A FILE IN THIS REPO AND NOTHING ELSE — it never opens a workspace, and it is
|
|
962
|
+
* part of setting a project up, which must not require the login it helps a teammate do (CAN2-66).
|
|
963
|
+
*/
|
|
964
|
+
'agents-md',
|
|
959
965
|
]);
|
|
960
966
|
/**
|
|
961
967
|
* ⚠ ALIAS → CANONICAL VERB, for the activity log's `source` column only.
|
|
@@ -1371,6 +1377,9 @@ async function run() {
|
|
|
1371
1377
|
state: { type: 'string' },
|
|
1372
1378
|
goal: { type: 'string' },
|
|
1373
1379
|
remove: { type: 'boolean', default: false },
|
|
1380
|
+
// `agents-md`: the MCP block instead of the CLI one; print it instead of writing it (CAN2-66).
|
|
1381
|
+
mcp: { type: 'boolean', default: false },
|
|
1382
|
+
print: { type: 'boolean', default: false },
|
|
1374
1383
|
/**
|
|
1375
1384
|
* ⚠⚠ `beehaven sheet` / `beehaven slides` — AND AN UNDECLARED FLAG IS A FATAL, NOT A NO-OP.
|
|
1376
1385
|
*
|
|
@@ -3145,6 +3154,15 @@ async function run() {
|
|
|
3145
3154
|
console.log(' (or quit and relaunch the Hive Mac app if it owns the daemon).');
|
|
3146
3155
|
break;
|
|
3147
3156
|
}
|
|
3157
|
+
case 'agents-md': {
|
|
3158
|
+
const { cmdAgentsMd } = await import('./commands/agents-md.js');
|
|
3159
|
+
process.exitCode = cmdAgentsMd(positionals[1], {
|
|
3160
|
+
mcp: !!values.mcp,
|
|
3161
|
+
print: !!values.print,
|
|
3162
|
+
remove: !!values.remove,
|
|
3163
|
+
});
|
|
3164
|
+
break;
|
|
3165
|
+
}
|
|
3148
3166
|
case 'handoff': {
|
|
3149
3167
|
const { cmdHandoff } = await import('./commands/handoff.js');
|
|
3150
3168
|
process.exitCode = await cmdHandoff(positionals[1], {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* beehaven agents-md — put the Clearly block in THIS project's AGENTS.md (CAN2-66).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ TWO FILES, TWO AUDIENCES. `beehaven login` already writes a GLOBAL block into
|
|
5
|
+
* ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md and ~/.gemini/GEMINI.md (agent-instructions.ts), so every
|
|
6
|
+
* agent on THIS machine knows Clearly. A project's AGENTS.md is for everyone else who opens the
|
|
7
|
+
* repo — a teammate's Codex, a cloud agent, CI — and it is committed and shared. That is why this
|
|
8
|
+
* is its own command the person (or their agent, asked first) runs, not a side effect of install.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ THE MARKERS ARE THE UPDATE MECHANISM, as on every other surface (lib/connections/agentsMd.ts):
|
|
11
|
+
* re-running replaces only what lies between our markers and leaves the rest byte for byte. The
|
|
12
|
+
* CLI and MCP blocks have their own markers, so a repo used both ways keeps both.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ AGENTS.md IS THE FILE; CLAUDE.md POINTS AT IT with one `@AGENTS.md` line — one copy to keep
|
|
15
|
+
* current. An existing CLAUDE.md gains the line only if it lacks it; nothing else in it is touched.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
18
|
+
import { join, resolve } from 'path';
|
|
19
|
+
import { execFileSync } from 'child_process';
|
|
20
|
+
import { color } from '../spinner.js';
|
|
21
|
+
import { CLI_BLOCK_START, CLI_BLOCK_END, MCP_BLOCK_START, MCP_BLOCK_END, CLAUDE_MD_IMPORT, agentsMdCliBlock, agentsMdMcpBlock, } from '../agents-md-text.js';
|
|
22
|
+
/** Our block replaced between its markers, or appended after one blank line. Keeps CRLF files CRLF. */
|
|
23
|
+
export function spliceBlock(text, block, start, end) {
|
|
24
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
25
|
+
const b = block.split('\n').join(eol);
|
|
26
|
+
const i = text.indexOf(start);
|
|
27
|
+
const j = i >= 0 ? text.indexOf(end, i) : -1;
|
|
28
|
+
if (i >= 0 && j >= 0)
|
|
29
|
+
return text.slice(0, i) + b + text.slice(j + end.length);
|
|
30
|
+
if (!text.trim())
|
|
31
|
+
return b + eol;
|
|
32
|
+
return text.replace(/\s*$/, '') + eol + eol + b + eol;
|
|
33
|
+
}
|
|
34
|
+
/** Our block taken out, with the blank line that separated it. Everything else stays. */
|
|
35
|
+
export function removeBlock(text, start, end) {
|
|
36
|
+
const i = text.indexOf(start);
|
|
37
|
+
const j = i >= 0 ? text.indexOf(end, i) : -1;
|
|
38
|
+
if (i < 0 || j < 0)
|
|
39
|
+
return text;
|
|
40
|
+
const before = text.slice(0, i).replace(/(\r?\n)*$/, '');
|
|
41
|
+
const after = text.slice(j + end.length).replace(/^(\r?\n)*/, '');
|
|
42
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
43
|
+
if (!before)
|
|
44
|
+
return after;
|
|
45
|
+
return after ? before + eol + eol + after : before + eol;
|
|
46
|
+
}
|
|
47
|
+
/** The repository root when `dir` is inside a git work tree, else `dir` itself. */
|
|
48
|
+
export function projectRoot(dir) {
|
|
49
|
+
try {
|
|
50
|
+
const top = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
51
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5_000,
|
|
52
|
+
}).trim();
|
|
53
|
+
return top || dir;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Write (or remove) the block in `<root>/AGENTS.md` and make CLAUDE.md import it. */
|
|
60
|
+
export function writeProjectAgentsMd(opts) {
|
|
61
|
+
const root = projectRoot(resolve(opts.dir));
|
|
62
|
+
const [start, end] = opts.mcp ? [MCP_BLOCK_START, MCP_BLOCK_END] : [CLI_BLOCK_START, CLI_BLOCK_END];
|
|
63
|
+
const agentsPath = join(root, 'AGENTS.md');
|
|
64
|
+
const claudePath = join(root, 'CLAUDE.md');
|
|
65
|
+
const files = [];
|
|
66
|
+
const had = existsSync(agentsPath);
|
|
67
|
+
const before = had ? readFileSync(agentsPath, 'utf8') : '';
|
|
68
|
+
if (opts.remove) {
|
|
69
|
+
const next = removeBlock(before, start, end);
|
|
70
|
+
if (next !== before)
|
|
71
|
+
writeFileSync(agentsPath, next);
|
|
72
|
+
files.push({ path: agentsPath, change: next !== before ? 'removed' : 'absent' });
|
|
73
|
+
return { root, files };
|
|
74
|
+
}
|
|
75
|
+
const next = spliceBlock(before, opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock(), start, end);
|
|
76
|
+
if (next !== before)
|
|
77
|
+
writeFileSync(agentsPath, next);
|
|
78
|
+
files.push({ path: agentsPath, change: !had ? 'created' : next !== before ? 'updated' : 'current' });
|
|
79
|
+
const claude = existsSync(claudePath) ? readFileSync(claudePath, 'utf8') : null;
|
|
80
|
+
if (claude === null) {
|
|
81
|
+
writeFileSync(claudePath, CLAUDE_MD_IMPORT + '\n');
|
|
82
|
+
files.push({ path: claudePath, change: 'created' });
|
|
83
|
+
}
|
|
84
|
+
else if (!claude.split(/\r?\n/).some((l) => l.trim() === CLAUDE_MD_IMPORT)) {
|
|
85
|
+
const eol = claude.includes('\r\n') ? '\r\n' : '\n';
|
|
86
|
+
writeFileSync(claudePath, claude.replace(/\s*$/, '') + (claude.trim() ? eol + eol : '') + CLAUDE_MD_IMPORT + eol);
|
|
87
|
+
files.push({ path: claudePath, change: 'updated' });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
files.push({ path: claudePath, change: 'current' });
|
|
91
|
+
}
|
|
92
|
+
return { root, files };
|
|
93
|
+
}
|
|
94
|
+
export function cmdAgentsMd(dir, opts) {
|
|
95
|
+
if (opts.print) {
|
|
96
|
+
process.stdout.write((opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock()) + '\n');
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
let r;
|
|
100
|
+
try {
|
|
101
|
+
r = writeProjectAgentsMd({ dir: dir ?? process.cwd(), mcp: opts.mcp, remove: opts.remove });
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.error(` ${color.red('✗')} ${err?.message ?? err}`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
console.log('');
|
|
108
|
+
for (const f of r.files) {
|
|
109
|
+
const mark = f.change === 'current' || f.change === 'absent' ? color.dim('·') : color.green('✓');
|
|
110
|
+
console.log(` ${mark} ${f.path} ${color.dim(f.change)}`);
|
|
111
|
+
}
|
|
112
|
+
const changed = r.files.some((f) => f.change !== 'current' && f.change !== 'absent');
|
|
113
|
+
if (changed && !opts.remove) {
|
|
114
|
+
console.log(color.dim(`\n Commit ${r.files.map((f) => f.path.slice(r.root.length + 1)).join(' and ')} so every agent that opens this project knows the workspace.`));
|
|
115
|
+
}
|
|
116
|
+
console.log('');
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
package/dist/commands/help.js
CHANGED
|
@@ -413,6 +413,27 @@ const PAGES = {
|
|
|
413
413
|
\`beehaven login\` to connect the CLI to it.`,
|
|
414
414
|
related: ['login', 'guide'],
|
|
415
415
|
},
|
|
416
|
+
'agents-md': {
|
|
417
|
+
synopsis: 'beehaven agents-md [dir] [--mcp] [--print] [--remove]',
|
|
418
|
+
description: `Add the Clearly block to this project's AGENTS.md, so every agent that opens the
|
|
419
|
+
repository — a teammate's Codex or Claude Code, a cloud agent, CI — knows the workspace and how
|
|
420
|
+
to reach it. Writes at the git root (or the directory given), and makes CLAUDE.md import it with
|
|
421
|
+
one \`@AGENTS.md\` line. Needs no login.
|
|
422
|
+
|
|
423
|
+
The block sits between Clearly's markers: run it again to update the block, and everything
|
|
424
|
+
else in the file stays as it was. \`beehaven login\` already teaches the agents on THIS machine
|
|
425
|
+
(their global instruction files); this is the shared, committed copy for everyone else.`,
|
|
426
|
+
flags: [
|
|
427
|
+
{ name: '--mcp', desc: 'Write the block for agents connected over MCP instead of the CLI' },
|
|
428
|
+
{ name: '--print', desc: 'Print the block and write nothing' },
|
|
429
|
+
{ name: '--remove', desc: 'Take the block out again' },
|
|
430
|
+
],
|
|
431
|
+
examples: [
|
|
432
|
+
{ cmd: 'beehaven agents-md', note: 'AGENTS.md + CLAUDE.md at the repository root' },
|
|
433
|
+
{ cmd: 'beehaven agents-md --mcp', note: 'for a team that connects over MCP' },
|
|
434
|
+
],
|
|
435
|
+
related: ['skills', 'login'],
|
|
436
|
+
},
|
|
416
437
|
handoff: {
|
|
417
438
|
synopsis: 'beehaven handoff [file.md|-] [--tasks tasks.md|json] [--csv data.csv]… [--title "…"] [--wait] [--json]',
|
|
418
439
|
description: `Give a person your work when they have no Clearly account yet. Needs no login.
|
package/dist/daemon.js
CHANGED
|
@@ -4116,7 +4116,7 @@ export async function startDaemon(opts) {
|
|
|
4116
4116
|
// so browser frontends become virtual IPC clients — same
|
|
4117
4117
|
// pipeline, app-rpc, broadcast fan-out as Claude's path.
|
|
4118
4118
|
dashboard.setAppBridge({
|
|
4119
|
-
createVirtualClient: (send, subs) => ipc.createVirtualClient(send, subs),
|
|
4119
|
+
createVirtualClient: (send, subs, scope) => ipc.createVirtualClient(send, subs, scope),
|
|
4120
4120
|
injectMessage: (id, msg) => ipc.injectMessage(id, msg),
|
|
4121
4121
|
releaseVirtualClient: (id) => ipc.releaseVirtualClient(id),
|
|
4122
4122
|
});
|
|
@@ -4141,15 +4141,29 @@ export async function startDaemon(opts) {
|
|
|
4141
4141
|
}
|
|
4142
4142
|
})();
|
|
4143
4143
|
// ── Dashboard (local observability UI) ──────────────────────────────
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4144
|
+
/**
|
|
4145
|
+
* ⚠⚠ A LOOPBACK HTTP SERVER IS OPEN TO EVERY USER AND PROGRAM ON THE MACHINE (CAN2-64), so a
|
|
4146
|
+
* daemon opens one only when something will use it. The Mac app's daemon does (the avatar UI
|
|
4147
|
+
* talks over /apps/ws); a HEADLESS daemon — the CLI on Windows and Linux, CI, servers — has no
|
|
4148
|
+
* window to show it in and, on a shared Windows machine, would hand every other signed-in user
|
|
4149
|
+
* a port into this one's daemon. It starts there only on request: BEEHAVEN_DASHBOARD=1,
|
|
4150
|
+
* `"dashboard": true` in ~/.beehaven/config.json, or developer mode.
|
|
4151
|
+
*/
|
|
4152
|
+
const headlessDaemon = process.env.BEEHAVEN_HEADLESS === '1' || process.env.BEEHAVEN_HEADLESS === 'true';
|
|
4153
|
+
const dashboardWanted = !headlessDaemon || developerMode || config.dashboard === true || process.env.BEEHAVEN_DASHBOARD === '1';
|
|
4154
|
+
if (!dashboardWanted) {
|
|
4155
|
+
console.log(' [dashboard] off in a headless daemon (set BEEHAVEN_DASHBOARD=1 to open it on 127.0.0.1)');
|
|
4156
|
+
}
|
|
4157
|
+
if (dashboardWanted)
|
|
4158
|
+
dashboard = new Dashboard({
|
|
4159
|
+
port: config.dashboardPort ?? 9800,
|
|
4160
|
+
host: '127.0.0.1',
|
|
4161
|
+
explorerTarget: `http://127.0.0.1:${config.devPort ?? 8787}`,
|
|
4162
|
+
explorerSecret: config.explorerSecret ?? config.adminSecret ?? '',
|
|
4163
|
+
projectDir: config.linkedProject ?? '',
|
|
4164
|
+
});
|
|
4151
4165
|
dashboard
|
|
4152
|
-
|
|
4166
|
+
?.start()
|
|
4153
4167
|
.then(() => {
|
|
4154
4168
|
console.log(` [dashboard] Beehaven Dashboard at ${dashboard.url}`);
|
|
4155
4169
|
})
|
|
@@ -4158,7 +4172,7 @@ export async function startDaemon(opts) {
|
|
|
4158
4172
|
});
|
|
4159
4173
|
// Forward daemon events to dashboard
|
|
4160
4174
|
watcher.on('event', (event) => {
|
|
4161
|
-
dashboard
|
|
4175
|
+
dashboard?.pushEvent({
|
|
4162
4176
|
type: event.hook_event_name || event.type || 'unknown',
|
|
4163
4177
|
timestamp: event.timestamp || Date.now(),
|
|
4164
4178
|
data: {
|
package/dist/dashboard.js
CHANGED
|
@@ -17,6 +17,59 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
17
17
|
import { join, dirname } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { WebSocketServer } from 'ws';
|
|
20
|
+
// ── Who may talk to this server (CAN2-64) ───────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* ⚠⚠ A LOOPBACK PORT IS NOT A PRIVATE CHANNEL. Every web page the person opens can dial
|
|
23
|
+
* `ws://127.0.0.1:9800` — WebSockets ignore CORS — and so can every program on the machine,
|
|
24
|
+
* another Windows or macOS user's included. `/apps/ws` used to hand EVERY connection a
|
|
25
|
+
* developer-scope IPC client with no check at all (a comment in ipc-server.ts claimed a session
|
|
26
|
+
* cookie and an origin check that did not exist), so any website could ask the daemon for the
|
|
27
|
+
* signed-in Firebase token, run `sql`/`shell`, or install a package. Proven against a live
|
|
28
|
+
* daemon on 2026-10-13 with `Origin: https://evil.example`. The rules now:
|
|
29
|
+
*
|
|
30
|
+
* 1. The Host header must name this loopback server — a DNS-rebound hostname is refused.
|
|
31
|
+
* 2. `/apps/ws`: a PAGE must come from this server (an installed hive's frontend) or be a
|
|
32
|
+
* `file://` page (the Mac app loads the avatar UI with `loadFile`; a web page cannot claim
|
|
33
|
+
* `file://`), and it gets CONSUMER scope. A PROGRAM (no Origin) starts unauthenticated and
|
|
34
|
+
* must present the daemon's IPC token with `{type:'auth'}` — the pipe's own boundary. Any
|
|
35
|
+
* other origin, `null` included (sandboxed iframes send it), is refused at the upgrade.
|
|
36
|
+
* 3. JSON routes answer CORS only to loopback origins (a dev server on localhost). Browser
|
|
37
|
+
* building modules stay `*`: clearly.sh imports that public code cross-origin.
|
|
38
|
+
*/
|
|
39
|
+
export function isLoopbackHostHeader(host, port) {
|
|
40
|
+
const h = (host ?? '').toLowerCase();
|
|
41
|
+
return h === `127.0.0.1:${port}` || h === `localhost:${port}` || h === `[::1]:${port}`;
|
|
42
|
+
}
|
|
43
|
+
/** `http://localhost:N`, `http://127.0.0.1:N` or `http://[::1]:N` — a page served on this machine. */
|
|
44
|
+
export function isLoopbackOrigin(origin) {
|
|
45
|
+
if (!origin)
|
|
46
|
+
return false;
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(origin);
|
|
49
|
+
return u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A page served by THIS server. */
|
|
56
|
+
export function isSelfOrigin(origin, port) {
|
|
57
|
+
return (origin === `http://127.0.0.1:${port}` ||
|
|
58
|
+
origin === `http://localhost:${port}` ||
|
|
59
|
+
origin === `http://[::1]:${port}`);
|
|
60
|
+
}
|
|
61
|
+
/** How a `/apps/ws` connection starts: `consumer` scope, unauthenticated (`token`), or refused (null). */
|
|
62
|
+
export function appBridgeTrust(origin, port) {
|
|
63
|
+
if (!origin)
|
|
64
|
+
return 'token';
|
|
65
|
+
if (origin === 'file://' || isSelfOrigin(origin, port))
|
|
66
|
+
return 'consumer';
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** `/ws` streams events and accepts console logs: loopback or file pages, or programs. */
|
|
70
|
+
export function metricsSocketAllowed(origin) {
|
|
71
|
+
return !origin || origin === 'file://' || isLoopbackOrigin(origin);
|
|
72
|
+
}
|
|
20
73
|
// ── Fallback HTML ───────────────────────────────────────────────────────────
|
|
21
74
|
const FALLBACK_HTML = `<!DOCTYPE html>
|
|
22
75
|
<html lang="en">
|
|
@@ -150,11 +203,28 @@ export class Dashboard {
|
|
|
150
203
|
// first WSS's listener — both listeners fire on every upgrade event.
|
|
151
204
|
// Instead we accept all upgrades and route by req.url in the
|
|
152
205
|
// connection handler.
|
|
153
|
-
|
|
206
|
+
//
|
|
207
|
+
// Every upgrade is judged BEFORE it is accepted (see "Who may talk to this server").
|
|
208
|
+
this._wss = new WebSocketServer({
|
|
209
|
+
server: this._server,
|
|
210
|
+
verifyClient: (info, done) => {
|
|
211
|
+
const req = info.req;
|
|
212
|
+
const url = req.url ?? '';
|
|
213
|
+
const origin = req.headers.origin;
|
|
214
|
+
if (!isLoopbackHostHeader(req.headers.host, this.port))
|
|
215
|
+
return done(false, 403, 'Forbidden');
|
|
216
|
+
if (url.startsWith('/apps/ws')) {
|
|
217
|
+
return appBridgeTrust(origin, this.port) ? done(true) : done(false, 403, 'Forbidden');
|
|
218
|
+
}
|
|
219
|
+
if (url.startsWith('/ws'))
|
|
220
|
+
return metricsSocketAllowed(origin) ? done(true) : done(false, 403, 'Forbidden');
|
|
221
|
+
return done(false, 404, 'Not Found');
|
|
222
|
+
},
|
|
223
|
+
});
|
|
154
224
|
this._wss.on('connection', (ws, req) => {
|
|
155
225
|
const url = req.url ?? '';
|
|
156
226
|
if (url.startsWith('/apps/ws')) {
|
|
157
|
-
this._handleAppWsConnection(ws);
|
|
227
|
+
this._handleAppWsConnection(ws, appBridgeTrust(req.headers.origin, this.port));
|
|
158
228
|
}
|
|
159
229
|
else if (url.startsWith('/ws')) {
|
|
160
230
|
this._handleWSConnection(ws, req);
|
|
@@ -255,8 +325,10 @@ export class Dashboard {
|
|
|
255
325
|
get url() {
|
|
256
326
|
return `http://${this._config.host}:${this._config.port}`;
|
|
257
327
|
}
|
|
328
|
+
/** The port actually bound (config 0 picks one), which the Host and Origin checks compare to. */
|
|
258
329
|
get port() {
|
|
259
|
-
|
|
330
|
+
const addr = this._server?.address();
|
|
331
|
+
return addr && typeof addr === 'object' ? addr.port : this._config.port;
|
|
260
332
|
}
|
|
261
333
|
get running() {
|
|
262
334
|
return this._server !== null;
|
|
@@ -268,6 +340,30 @@ export class Dashboard {
|
|
|
268
340
|
_handleHTTP(req, res) {
|
|
269
341
|
const url = req.url ?? '/';
|
|
270
342
|
const method = req.method ?? 'GET';
|
|
343
|
+
const origin = req.headers.origin;
|
|
344
|
+
// A DNS-rebound name pointing at 127.0.0.1 carries its own Host — refuse it on every route.
|
|
345
|
+
if (!isLoopbackHostHeader(req.headers.host, this.port)) {
|
|
346
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
347
|
+
res.end('Forbidden');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* ⚠ ONE CHOKEPOINT FOR CORS. The handlers below were written to answer `*`; every response now
|
|
352
|
+
* passes through here, which keeps `*` only for browser building modules (public code that
|
|
353
|
+
* clearly.sh imports cross-origin) and otherwise reflects a loopback origin or sends none.
|
|
354
|
+
*/
|
|
355
|
+
const publicModule = /^\/apps\/[^/]+\/buildings\/[^/]+\.js$/.test(url);
|
|
356
|
+
const allowOrigin = publicModule ? '*' : isLoopbackOrigin(origin) ? origin : null;
|
|
357
|
+
const writeHead = res.writeHead.bind(res);
|
|
358
|
+
res.writeHead = (status, ...rest) => {
|
|
359
|
+
const headers = rest.find((r) => r && typeof r === 'object');
|
|
360
|
+
if (headers && 'Access-Control-Allow-Origin' in headers) {
|
|
361
|
+
delete headers['Access-Control-Allow-Origin'];
|
|
362
|
+
if (allowOrigin)
|
|
363
|
+
Object.assign(headers, { 'Access-Control-Allow-Origin': allowOrigin, Vary: 'Origin' });
|
|
364
|
+
}
|
|
365
|
+
return writeHead(status, ...rest);
|
|
366
|
+
};
|
|
271
367
|
// Handle CORS preflight
|
|
272
368
|
if (method === 'OPTIONS') {
|
|
273
369
|
res.writeHead(204, {
|
|
@@ -280,8 +376,14 @@ export class Dashboard {
|
|
|
280
376
|
return;
|
|
281
377
|
}
|
|
282
378
|
try {
|
|
283
|
-
// Explorer API proxy — forward /api/explorer/* to the Worker
|
|
379
|
+
// Explorer API proxy — forward /api/explorer/* to the Worker. It adds the admin secret, so
|
|
380
|
+
// only this server's own explorer page (or a program, which sends no Origin) may use it.
|
|
284
381
|
if (url.startsWith('/api/explorer')) {
|
|
382
|
+
if (origin && !isSelfOrigin(origin, this.port)) {
|
|
383
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
384
|
+
res.end('Forbidden');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
285
387
|
this._proxyExplorer(req, res, url);
|
|
286
388
|
return;
|
|
287
389
|
}
|
|
@@ -350,6 +452,11 @@ export class Dashboard {
|
|
|
350
452
|
}
|
|
351
453
|
}
|
|
352
454
|
if (method === 'POST' && url === '/api/console') {
|
|
455
|
+
if (!metricsSocketAllowed(origin)) {
|
|
456
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
457
|
+
res.end('Forbidden');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
353
460
|
this._handleConsolePost(req, res);
|
|
354
461
|
return;
|
|
355
462
|
}
|
|
@@ -702,7 +809,11 @@ export class Dashboard {
|
|
|
702
809
|
: { installed: [], available: [] };
|
|
703
810
|
this._serveJSON(res, payload);
|
|
704
811
|
}
|
|
705
|
-
_handleAppWsConnection(ws) {
|
|
812
|
+
_handleAppWsConnection(ws, trust) {
|
|
813
|
+
if (!trust) {
|
|
814
|
+
ws.close(1008, 'forbidden');
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
706
817
|
if (!this._appBridge) {
|
|
707
818
|
ws.close(1011, 'app bridge not configured');
|
|
708
819
|
return;
|
|
@@ -720,7 +831,8 @@ export class Dashboard {
|
|
|
720
831
|
// Subscribe broadly by default — browser UIs usually want to see
|
|
721
832
|
// everything (pipeline:*, broadcast:*, etc.). Clients can tighten this
|
|
722
833
|
// by sending their own { type: 'subscribe', patterns: [...] } message.
|
|
723
|
-
|
|
834
|
+
// Consumer scope at most: a page never gets developer verbs (get-token, shell, sql, installs).
|
|
835
|
+
const virtId = bridge.createVirtualClient(send, ['*'], trust === 'consumer' ? 'consumer' : null);
|
|
724
836
|
ws.on('message', (raw) => {
|
|
725
837
|
let msg;
|
|
726
838
|
try {
|
package/dist/ipc-server.js
CHANGED
|
@@ -602,20 +602,23 @@ export class IpcServer extends EventEmitter {
|
|
|
602
602
|
// IPC bus so the same pipeline/app-rpc handlers serve both Claude and
|
|
603
603
|
// the human UI.
|
|
604
604
|
/** Register a virtual client and return its id. */
|
|
605
|
-
createVirtualClient(virtualSend, initialSubscriptions = ['*']) {
|
|
605
|
+
createVirtualClient(virtualSend, initialSubscriptions = ['*'], scope = null) {
|
|
606
606
|
const id = `virt_${++this.virtualClientCounter}`;
|
|
607
|
+
/**
|
|
608
|
+
* ⚠⚠ A VIRTUAL CLIENT IS ONLY AS TRUSTED AS WHOEVER IS ON THE OTHER END OF IT (CAN2-64).
|
|
609
|
+
* This used to be `authenticated: true, scope: 'developer'` for everyone, justified by "the
|
|
610
|
+
* dashboard bridge enforces its own auth (session cookie, origin check)". It enforced none,
|
|
611
|
+
* so any web page that opened ws://127.0.0.1:9800/apps/ws could call get-token. The bridge
|
|
612
|
+
* now judges the connection (dashboard.ts `appBridgeTrust`) and passes 'consumer' or null;
|
|
613
|
+
* a null client must authenticate with the IPC token, exactly like a socket client.
|
|
614
|
+
*/
|
|
607
615
|
const client = {
|
|
608
616
|
id,
|
|
609
617
|
virtualSend,
|
|
610
618
|
subscriptions: initialSubscriptions,
|
|
611
619
|
buffer: '',
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
// (session cookie, origin check) before creating the virtual client,
|
|
615
|
-
// so we hand them developer scope to mirror what a logged-in tray
|
|
616
|
-
// user can already do.
|
|
617
|
-
authenticated: true,
|
|
618
|
-
scope: 'developer',
|
|
620
|
+
authenticated: scope !== null,
|
|
621
|
+
scope,
|
|
619
622
|
};
|
|
620
623
|
this.clients.set(id, client);
|
|
621
624
|
// Mimic the on-connect status frame real clients receive so the bridge
|
package/dist/verb-surface.js
CHANGED
|
@@ -60,6 +60,7 @@ export const VERB_SURFACE = {
|
|
|
60
60
|
logout: { cls: 'STRUCTURAL', why: 'clears local credentials' },
|
|
61
61
|
signup: { cls: 'STRUCTURAL', why: 'opens a browser' },
|
|
62
62
|
handoff: { cls: 'STRUCTURAL', why: 'posts a local file to the anonymous door; no account, no workspace' },
|
|
63
|
+
'agents-md': { cls: 'STRUCTURAL', why: "writes the Clearly block into this repo's AGENTS.md; no workspace" },
|
|
63
64
|
whoami: { cls: 'STRUCTURAL', why: 'reads local credentials' },
|
|
64
65
|
start: { cls: 'STRUCTURAL', why: 'spawns the daemon `call` needs' },
|
|
65
66
|
stop: { cls: 'STRUCTURAL', why: 'daemon lifecycle' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-doodle-space
|
|
3
|
-
description: Use an agent identity's Avatar Soul and durable Doodle Space. Trigger when someone says "draw my avatar", "change my expression", "make a doodle", or wants to inspect an agent's visual identity. Read the minted appearance before drawing; save durable art to a session composition. Requires AGENT_SOUL_SPACE and is separate from avatar-shop.
|
|
3
|
+
description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead. Use an agent identity's Avatar Soul and durable Doodle Space. Trigger when someone says "draw my avatar", "change my expression", "make a doodle", or wants to inspect an agent's visual identity. Read the minted appearance before drawing; save durable art to a session composition. Requires AGENT_SOUL_SPACE and is separate from avatar-shop.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Agent Soul + Doodle Space
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-home
|
|
3
|
-
description: Design and inhabit an agent-owned memory apartment on Clearly's canvas. Use when someone says "build my agent home", "make a room", "save a home memory", or "move the avatar". Arrange rooms, doors, windows, furniture, avatar position, memories, journals, and Home ads on a fixed 24px grid. Requires AGENT_SOUL_SPACE.
|
|
3
|
+
description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead. Design and inhabit an agent-owned memory apartment on Clearly's canvas. Use when someone says "build my agent home", "make a room", "save a home memory", or "move the avatar". Arrange rooms, doors, windows, furniture, avatar position, memories, journals, and Home ads on a fixed 24px grid. Requires AGENT_SOUL_SPACE.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Agent Home memory apartment
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: brand-identity
|
|
3
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 and use the clearly_* tools instead.
|
|
4
5
|
Design the requested logo or brand identity on a Clearly canvas —
|
|
5
6
|
brief, mark, wordmark, lockups and clear space, palette, type system,
|
|
6
7
|
applications, and a spec board someone else can build from. Use when asked
|