beehaven 0.8.29 → 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.
Files changed (45) hide show
  1. package/dist/agents-md-text.js +64 -0
  2. package/dist/browser-login.js +14 -4
  3. package/dist/build-channel.js +2 -2
  4. package/dist/claude-bridge.js +12 -9
  5. package/dist/clearly.js +4 -3
  6. package/dist/cli.js +26 -7
  7. package/dist/commands/agents-md.js +118 -0
  8. package/dist/commands/daemon-cmd.js +2 -0
  9. package/dist/commands/help.js +21 -0
  10. package/dist/commands/self-update.js +32 -5
  11. package/dist/commands/workspace.js +3 -3
  12. package/dist/daemon-client.js +10 -6
  13. package/dist/daemon-entry.js +1 -0
  14. package/dist/daemon-process.js +125 -0
  15. package/dist/daemon.js +131 -62
  16. package/dist/dashboard.js +118 -6
  17. package/dist/ipc-auth.js +3 -1
  18. package/dist/ipc-client.js +7 -6
  19. package/dist/ipc-path.js +92 -0
  20. package/dist/ipc-server.js +33 -33
  21. package/dist/npm-update.js +138 -0
  22. package/dist/setup-service.js +9 -4
  23. package/dist/verb-surface.js +1 -0
  24. package/dist/watcher.js +9 -23
  25. package/dist/windows-hide.js +77 -0
  26. package/package.json +1 -1
  27. package/scripts/sync-skills.mjs +6 -0
  28. package/skills/agent-doodle-space/SKILL.md +1 -1
  29. package/skills/agent-home/SKILL.md +1 -1
  30. package/skills/brand-identity/SKILL.md +22 -5
  31. package/skills/clearly-agent/SKILL.md +11 -4
  32. package/skills/clearly-canvas/SKILL.md +31 -5
  33. package/skills/clearly-canvas/authoring.md +40 -12
  34. package/skills/clearly-canvas/example.mjs +72 -69
  35. package/skills/clearly-docs/SKILL.md +151 -0
  36. package/skills/clearly-init/SKILL.md +135 -0
  37. package/skills/clearly-workflows/SKILL.md +90 -0
  38. package/skills/clearly-workspace/SKILL.md +1 -1
  39. package/skills/codebase-map/SKILL.md +1 -1
  40. package/skills/design-craft/SKILL.md +9 -6
  41. package/skills/index.json +45 -10
  42. package/skills/layout-systems/SKILL.md +14 -11
  43. package/skills/pair-on-canvas/SKILL.md +1 -1
  44. package/skills/sticker-pack/SKILL.md +132 -0
  45. package/skills/visualize/SKILL.md +188 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ipc-path — where the daemon's local sockets live, on every platform (CAN2-56).
3
+ *
4
+ * ⚠⚠ THE ONE PLACE A SOCKET ADDRESS IS DECIDED. `/tmp/beehaven-ipc.sock` was typed out in five CLI
5
+ * files (server, three clients, the claude bridge) and `/tmp/beehaven.sock` in two more, each
6
+ * gating its connect on `existsSync`, unlinking stale files and chmod-ing to 0600. On Windows none
7
+ * of that works: Node's `net` listens on a NAMED PIPE (`\\.\pipe\…`), there is no `/tmp`, a pipe is
8
+ * not a file you can stat, unlink or chmod — so the daemon could not bind and no client could
9
+ * reach it. Every caller now asks this module, and the Unix addresses are byte-for-byte unchanged
10
+ * (the Mac app, the mwah CLI and the Electron daemon client still dial `/tmp/beehaven-ipc.sock`).
11
+ *
12
+ * ⚠ A PIPE NAME IS MACHINE-GLOBAL, so it carries the user name: two people signed in to one
13
+ * Windows machine get two daemons, as two Unix users do. Same-user is still the boundary that
14
+ * matters, and the IPC auth token (`ipc-auth.ts`) is what gates verbs on both platforms — the
15
+ * Unix 0600 mode is defense in depth that a pipe does not have a file-mode equivalent for.
16
+ *
17
+ * ⚠ `$BEEHAVEN_IPC_SOCKET` / `$BEEHAVEN_WATCHER_SOCKET` override the address for BOTH ends (server
18
+ * and clients), so a test can stand a real daemon and a real client on a private path. Read at call
19
+ * time, not import time, for the same reason.
20
+ */
21
+ import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
22
+ import { tmpdir, userInfo } from 'os';
23
+ import { join } from 'path';
24
+ export const UNIX_IPC_SOCKET = '/tmp/beehaven-ipc.sock';
25
+ export const UNIX_WATCHER_SOCKET = '/tmp/beehaven.sock';
26
+ export const UNIX_EVENTS_FILE = '/tmp/beehaven-events.jsonl';
27
+ /** `\\.\pipe\…` or `\\?\pipe\…` — the forms Node accepts for a Windows named pipe. */
28
+ export function isNamedPipe(p) {
29
+ return /^\\\\[.?]\\pipe\\/i.test(p);
30
+ }
31
+ /** The signed-in user's name, made safe for a pipe name. */
32
+ function pipeUser(env) {
33
+ let name = '';
34
+ try {
35
+ name = userInfo().username;
36
+ }
37
+ catch { /* no passwd entry / sandbox */ }
38
+ if (!name)
39
+ name = env.USERNAME || env.USER || 'user';
40
+ return name.toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'user';
41
+ }
42
+ const pipe = (name, env) => `\\\\.\\pipe\\${name}-${pipeUser(env)}`;
43
+ /** The daemon's IPC address: the Unix socket, or this user's named pipe on Windows. */
44
+ export function ipcSocketPath(platform = process.platform, env = process.env) {
45
+ if (env.BEEHAVEN_IPC_SOCKET)
46
+ return env.BEEHAVEN_IPC_SOCKET;
47
+ return platform === 'win32' ? pipe('beehaven-ipc', env) : UNIX_IPC_SOCKET;
48
+ }
49
+ /** The Claude Code event watcher's HTTP-over-socket address. */
50
+ export function watcherSocketPath(platform = process.platform, env = process.env) {
51
+ if (env.BEEHAVEN_WATCHER_SOCKET)
52
+ return env.BEEHAVEN_WATCHER_SOCKET;
53
+ return platform === 'win32' ? pipe('beehaven-watcher', env) : UNIX_WATCHER_SOCKET;
54
+ }
55
+ /** The watcher's JSONL fallback file. Windows has no `/tmp`; its temp dir is per-user already. */
56
+ export function eventsFilePath(platform = process.platform) {
57
+ return platform === 'win32' ? join(tmpdir(), 'beehaven-events.jsonl') : UNIX_EVENTS_FILE;
58
+ }
59
+ /**
60
+ * Could a listener be at `p`? A socket FILE can be checked without touching it. A named pipe
61
+ * cannot: it exists only while a server holds it, and probing it with a filesystem call opens an
62
+ * instance of it. So a pipe answers `true` and the connect attempt decides (`ENOENT` = nothing).
63
+ */
64
+ export function socketMayExist(p) {
65
+ return isNamedPipe(p) || existsSync(p);
66
+ }
67
+ /** Remove a stale socket file before binding. A named pipe leaves nothing behind to remove. */
68
+ export function clearStaleSocket(p) {
69
+ if (isNamedPipe(p))
70
+ return;
71
+ try {
72
+ if (existsSync(p))
73
+ unlinkSync(p);
74
+ }
75
+ catch { /* already gone */ }
76
+ }
77
+ /**
78
+ * Restrict a freshly bound socket file to its owner (0600) and report the mode it really has.
79
+ * `applicable: false` for a named pipe, which has no file mode — its boundary is the per-user name
80
+ * plus the IPC token.
81
+ */
82
+ export function restrictSocketToOwner(p) {
83
+ if (isNamedPipe(p))
84
+ return { applicable: false, mode: null };
85
+ try {
86
+ chmodSync(p, 0o600);
87
+ return { applicable: true, mode: statSync(p).mode & 0o777 };
88
+ }
89
+ catch (err) {
90
+ return { applicable: true, mode: null, error: String(err?.code ?? err?.message ?? err) };
91
+ }
92
+ }
@@ -2,7 +2,7 @@
2
2
  * IPC Server — bidirectional Unix socket for local clients (Electron, CLI tools).
3
3
  *
4
4
  * Provides request-response RPCs and broadcast subscriptions over a line-based
5
- * JSON protocol on /tmp/beehaven-ipc.sock.
5
+ * JSON protocol on /tmp/beehaven-ipc.sock (a per-user named pipe on Windows — see ipc-path.ts).
6
6
  *
7
7
  * Local clients send messages to the daemon, which forwards them to the DO
8
8
  * via the relay WebSocket. DO responses and broadcasts are forwarded back
@@ -24,10 +24,11 @@
24
24
  * { "type": "status", "connected": true, "daemonPid": 1234 }
25
25
  */
26
26
  import { createConnection, createServer } from 'net';
27
- import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
28
27
  import { EventEmitter } from 'events';
29
28
  import { resolveAuthScope } from './ipc-auth.js';
30
- const IPC_SOCKET_PATH = '/tmp/beehaven-ipc.sock';
29
+ import { ipcSocketPath, socketMayExist, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
30
+ /** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
31
+ const IPC_SOCKET_PATH = ipcSocketPath();
31
32
  /**
32
33
  * Message types the daemon will process without authentication.
33
34
  *
@@ -114,9 +115,10 @@ export function actionChain(action, input, depth = 0) {
114
115
  */
115
116
  function probeIpcSocket(timeoutMs = 400) {
116
117
  return new Promise((resolve) => {
117
- if (!existsSync(IPC_SOCKET_PATH))
118
+ const path = ipcSocketPath();
119
+ if (!socketMayExist(path))
118
120
  return resolve(false);
119
- const client = createConnection(IPC_SOCKET_PATH);
121
+ const client = createConnection(path);
120
122
  const done = (alive) => {
121
123
  try {
122
124
  client.destroy();
@@ -138,6 +140,8 @@ function probeIpcSocket(timeoutMs = 400) {
138
140
  export { probeIpcSocket };
139
141
  export class IpcServer extends EventEmitter {
140
142
  server = null;
143
+ /** The address this server bound (resolved at `start`, so an env override set before start applies). */
144
+ path = null;
141
145
  clients = new Map();
142
146
  clientCounter = 0;
143
147
  virtualClientCounter = 0;
@@ -157,12 +161,10 @@ export class IpcServer extends EventEmitter {
157
161
  * at this point; a live server would have been caught upstream.
158
162
  */
159
163
  start() {
160
- // Clean up stale socket
161
- try {
162
- if (existsSync(IPC_SOCKET_PATH))
163
- unlinkSync(IPC_SOCKET_PATH);
164
- }
165
- catch { }
164
+ // Clean up a stale socket file (a named pipe leaves none).
165
+ const path = ipcSocketPath();
166
+ this.path = path;
167
+ clearStaleSocket(path);
166
168
  this.server = createServer((socket) => {
167
169
  const clientId = `ipc_${++this.clientCounter}`;
168
170
  // Connections start unauthenticated and scope=null. The client must
@@ -235,7 +237,7 @@ export class IpcServer extends EventEmitter {
235
237
  // process-wide umask — umask changes are global and race with any
236
238
  // other mkdirSync / writeFileSync happening in parallel (e.g.
237
239
  // LocalHive pre-creating building data dirs during startup).
238
- this.server.listen(IPC_SOCKET_PATH, () => {
240
+ this.server.listen(path, () => {
239
241
  /**
240
242
  * ⚠⚠ CHMOD, THEN **VERIFY**. This was `chmodSync(…, 0o600)` inside an empty `catch {}`, and
241
243
  * the mode it claims has been wrong on every start measured: the live socket comes up
@@ -266,19 +268,18 @@ export class IpcServer extends EventEmitter {
266
268
  * was learned once and not applied here. Import at the top; never reach for `require` in this
267
269
  * package.
268
270
  */
269
- let mode = null;
270
- try {
271
- chmodSync(IPC_SOCKET_PATH, 0o600);
272
- mode = statSync(IPC_SOCKET_PATH).mode & 0o777;
273
- }
274
- catch (err) {
275
- console.warn(`[ipc] SECURITY: could not set 0600 on ${IPC_SOCKET_PATH} — ${err?.code ?? err?.message ?? err}`);
271
+ // A Windows named pipe has no file mode (`applicable: false`): its boundary is the per-user
272
+ // pipe name plus the IPC token — see `ipc-path.ts`.
273
+ const restricted = restrictSocketToOwner(path);
274
+ const mode = restricted.mode;
275
+ if (restricted.error) {
276
+ console.warn(`[ipc] SECURITY: could not set 0600 on ${path} — ${restricted.error}`);
276
277
  }
277
278
  if (mode !== null && mode !== 0o600) {
278
279
  console.warn(`[ipc] SECURITY: socket mode is 0${mode.toString(8)}, expected 0600. Other-user connect is still `
279
280
  + `blocked (no write bit), but the documented guarantee does not hold — do not weaken it further.`);
280
281
  }
281
- console.log(`[ipc] Listening on ${IPC_SOCKET_PATH}${mode !== null ? ` (mode 0${mode.toString(8)})` : ''}`);
282
+ console.log(`[ipc] Listening on ${path}${mode !== null ? ` (mode 0${mode.toString(8)})` : ''}`);
282
283
  });
283
284
  this.server.on('error', (err) => {
284
285
  console.warn(`[ipc] Server error: ${err.message}`);
@@ -295,11 +296,7 @@ export class IpcServer extends EventEmitter {
295
296
  this.server.close();
296
297
  this.server = null;
297
298
  }
298
- try {
299
- if (existsSync(IPC_SOCKET_PATH))
300
- unlinkSync(IPC_SOCKET_PATH);
301
- }
302
- catch { }
299
+ clearStaleSocket(this.path ?? ipcSocketPath());
303
300
  }
304
301
  /** Broadcast a message from the DO to all subscribed local clients */
305
302
  /**
@@ -605,20 +602,23 @@ export class IpcServer extends EventEmitter {
605
602
  // IPC bus so the same pipeline/app-rpc handlers serve both Claude and
606
603
  // the human UI.
607
604
  /** Register a virtual client and return its id. */
608
- createVirtualClient(virtualSend, initialSubscriptions = ['*']) {
605
+ createVirtualClient(virtualSend, initialSubscriptions = ['*'], scope = null) {
609
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
+ */
610
615
  const client = {
611
616
  id,
612
617
  virtualSend,
613
618
  subscriptions: initialSubscriptions,
614
619
  buffer: '',
615
- // Virtual clients are in-process — they're already inside the trust
616
- // boundary. The dashboard /apps/ws bridge enforces its own auth
617
- // (session cookie, origin check) before creating the virtual client,
618
- // so we hand them developer scope to mirror what a logged-in tray
619
- // user can already do.
620
- authenticated: true,
621
- scope: 'developer',
620
+ authenticated: scope !== null,
621
+ scope,
622
622
  };
623
623
  this.clients.set(id, client);
624
624
  // Mimic the on-connect status frame real clients receive so the bridge
@@ -0,0 +1,138 @@
1
+ /**
2
+ * npm-update — updating a CLI that was installed with `npm install -g` (CAN2-57).
3
+ *
4
+ * ⚠⚠ npm IS THE ONLY WINDOWS INSTALL, AND IT NEVER UPDATED. The daemon's hourly updater re-runs the
5
+ * bash `install.sh`, so it served only `~/.beehaven/cli` installs; `beehaven update` refused npm
6
+ * installs and printed the npm command instead. A Windows user stayed on whatever version they
7
+ * first installed until they noticed the notice. Both paths now call this module.
8
+ *
9
+ * ⚠ IT UPDATES THE COPY THAT IS RUNNING, OR NOTHING. The npm that sits beside the running Node is
10
+ * asked for its global root; if this package is not the one installed there (an npx run, a second
11
+ * Node, a copied folder), installing would create a SECOND copy that shadows or is shadowed by
12
+ * this one — the failure `self-update.ts` documents for curl installs. So it refuses and says why.
13
+ *
14
+ * ⚠ A GLOBAL PREFIX THE USER CANNOT WRITE IS NOT "TRY sudo". Windows (`%AppData%\npm`) and
15
+ * Homebrew/nvm prefixes are user-writable; a system `/usr/local` usually is not, and an unattended
16
+ * daemon must never escalate. Not writable → the one-line manual command, nothing else.
17
+ *
18
+ * ⚠ THE EXACT VERSION, NOT `@latest`. The caller passes the version from the release manifest
19
+ * (`latest.json`, stable only), so the channel is decided by our publish script, never by a dist
20
+ * tag someone moved by hand.
21
+ */
22
+ import { execFile, execFileSync } from 'child_process';
23
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from 'fs';
24
+ import { homedir } from 'os';
25
+ import { dirname, join, resolve } from 'path';
26
+ export const PACKAGE = '@clearlysh/beehaven';
27
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
28
+ /** The npm that belongs to the Node running this process, else whatever `npm` is on PATH. */
29
+ export function npmCommand(platform = process.platform, execPath = process.execPath) {
30
+ const local = join(dirname(execPath), platform === 'win32' ? 'npm.cmd' : 'npm');
31
+ return existsSync(local) ? local : platform === 'win32' ? 'npm.cmd' : 'npm';
32
+ }
33
+ /** Windows `.cmd` shims cannot be spawned without a shell (Node refuses with EINVAL since 18.20/20.12). */
34
+ const needsShell = (cmd) => /\.cmd$/i.test(cmd);
35
+ const quoteForShell = (s) => (/[\s"&|<>^]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
36
+ /** Run npm synchronously; returns stdout. Never inside the package directory (Windows rename locks). */
37
+ function npmSync(npm, args, timeoutMs) {
38
+ const shell = needsShell(npm);
39
+ return execFileSync(shell ? quoteForShell(npm) : npm, args, {
40
+ encoding: 'utf8',
41
+ cwd: homedir(),
42
+ timeout: timeoutMs,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ shell,
45
+ windowsHide: true,
46
+ });
47
+ }
48
+ /**
49
+ * Can this install update itself with npm? `pkgRoot` is the running package's root (the folder
50
+ * holding its package.json).
51
+ */
52
+ export function npmUpdatePlan(pkgRoot, platform = process.platform) {
53
+ const npm = npmCommand(platform);
54
+ let globalRoot;
55
+ try {
56
+ globalRoot = npmSync(npm, ['root', '-g'], 30_000).trim();
57
+ }
58
+ catch (e) {
59
+ return { ok: false, npm, reason: `npm is not available (${String(e?.code ?? e?.message ?? e).slice(0, 80)})` };
60
+ }
61
+ const real = (p) => { try {
62
+ return realpathSync(p);
63
+ }
64
+ catch {
65
+ return resolve(p);
66
+ } };
67
+ const installed = join(globalRoot, ...PACKAGE.split('/'));
68
+ if (real(installed).toLowerCase() !== real(pkgRoot).toLowerCase()) {
69
+ return { ok: false, npm, globalRoot, reason: `this copy (${pkgRoot}) is not npm's global install (${installed})` };
70
+ }
71
+ try {
72
+ accessSync(globalRoot, constants.W_OK);
73
+ accessSync(installed, constants.W_OK);
74
+ }
75
+ catch {
76
+ return { ok: false, npm, globalRoot, reason: `npm's global folder is not writable by you (${globalRoot})` };
77
+ }
78
+ return { ok: true, npm, globalRoot };
79
+ }
80
+ /** The version npm's global install of this package reports, read from disk. */
81
+ export function installedNpmVersion(globalRoot) {
82
+ try {
83
+ return JSON.parse(readFileSync(join(globalRoot, ...PACKAGE.split('/'), 'package.json'), 'utf8')).version ?? null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** The newest published version, from npm (for `beehaven update`; the daemon uses the release manifest). */
90
+ export function latestNpmVersion(npm) {
91
+ try {
92
+ const v = npmSync(npm, ['view', PACKAGE, 'version', '--registry=https://registry.npmjs.org'], 30_000).trim();
93
+ return VERSION_RE.test(v) ? v : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /** The arguments that install exactly `version` globally — exported so the shape is pinned. */
100
+ export function npmInstallArgs(version) {
101
+ if (!VERSION_RE.test(version))
102
+ throw new Error(`not a release version: ${version}`);
103
+ return ['install', '-g', `${PACKAGE}@${version}`, '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org'];
104
+ }
105
+ /** Install `version` (blocking — the CLI's `update`). Verifies what landed on disk. */
106
+ export function installNpmVersionSync(plan, version) {
107
+ if (!plan.ok || !plan.globalRoot)
108
+ return { ok: false, message: plan.reason ?? 'npm update not possible here' };
109
+ try {
110
+ npmSync(plan.npm, npmInstallArgs(version), 5 * 60_000);
111
+ }
112
+ catch (e) {
113
+ const detail = String(e?.stderr || e?.message || e).trim().split('\n').slice(-3).join(' ').slice(0, 240);
114
+ return { ok: false, message: `npm install failed: ${detail}` };
115
+ }
116
+ const got = installedNpmVersion(plan.globalRoot);
117
+ return got === version
118
+ ? { ok: true, message: `Updated to ${version}.` }
119
+ : { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` };
120
+ }
121
+ /** Install `version` without blocking the event loop (the daemon's hourly updater). */
122
+ export function installNpmVersion(plan, version) {
123
+ return new Promise((resolveP) => {
124
+ if (!plan.ok || !plan.globalRoot)
125
+ return resolveP({ ok: false, message: plan.reason ?? 'npm update not possible here' });
126
+ const shell = needsShell(plan.npm);
127
+ execFile(shell ? quoteForShell(plan.npm) : plan.npm, npmInstallArgs(version), {
128
+ cwd: homedir(), timeout: 5 * 60_000, shell, windowsHide: true,
129
+ }, (err, _out, stderr) => {
130
+ if (err)
131
+ return resolveP({ ok: false, message: `npm install failed: ${String(stderr || err.message).trim().split('\n').slice(-3).join(' ').slice(0, 240)}` });
132
+ const got = installedNpmVersion(plan.globalRoot);
133
+ resolveP(got === version
134
+ ? { ok: true, message: `Updated to ${version}.` }
135
+ : { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` });
136
+ });
137
+ });
138
+ }
@@ -391,8 +391,12 @@ export async function cleanupAll() {
391
391
  }
392
392
  // 3. Socket + event log
393
393
  const { existsSync, unlinkSync, readdirSync, rmSync, readFileSync, writeFileSync } = await import('node:fs');
394
- const { join } = await import('node:path');
395
- for (const p of ['/tmp/beehaven.sock', '/tmp/beehaven-ipc.sock', '/tmp/beehaven-events.jsonl']) {
394
+ const { join, dirname } = await import('node:path');
395
+ const { watcherSocketPath, ipcSocketPath, eventsFilePath, isNamedPipe } = await import('./ipc-path.js');
396
+ const eventsFile = eventsFilePath();
397
+ for (const p of [watcherSocketPath(), ipcSocketPath(), eventsFile]) {
398
+ if (isNamedPipe(p))
399
+ continue; // a Windows pipe disappears with the daemon that held it
396
400
  try {
397
401
  if (existsSync(p)) {
398
402
  unlinkSync(p);
@@ -402,10 +406,11 @@ export async function cleanupAll() {
402
406
  catch { }
403
407
  }
404
408
  try {
405
- for (const f of readdirSync('/tmp')) {
409
+ const eventsDir = dirname(eventsFile);
410
+ for (const f of readdirSync(eventsDir)) {
406
411
  if (f.startsWith('beehaven-events.jsonl.') && f.endsWith('.old')) {
407
412
  try {
408
- unlinkSync(join('/tmp', f));
413
+ unlinkSync(join(eventsDir, f));
409
414
  }
410
415
  catch { }
411
416
  }
@@ -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/dist/watcher.js CHANGED
@@ -1,18 +1,19 @@
1
1
  // ============================================================================
2
2
  // Beehaven Office - Claude Code Event Watcher
3
- // Primary: Unix domain socket HTTP server at /tmp/beehaven.sock
3
+ // Primary: HTTP server on a local socket — /tmp/beehaven.sock, or a named pipe on Windows (ipc-path.ts)
4
4
  // Fallback: Watches the JSONL events file written by the hook script
5
5
  // ============================================================================
6
6
  // chokidar is imported lazily so a missing bundle (e.g. when the daemon ships
7
7
  // inside an Electron app whose node_modules layout doesn't include chokidar)
8
8
  // degrades the file-watcher to a no-op rather than crashing the whole daemon.
9
9
  // The primary Unix-socket path does not depend on chokidar.
10
- import { createReadStream, existsSync, writeFileSync, statSync, renameSync, unlinkSync, chmodSync, watch as fsWatch, } from 'fs';
10
+ import { createReadStream, existsSync, writeFileSync, statSync, renameSync, chmodSync, watch as fsWatch, } from 'fs';
11
11
  import { createInterface } from 'readline';
12
12
  import { createServer } from 'http';
13
13
  import { EventEmitter } from 'events';
14
- const EVENTS_FILE = '/tmp/beehaven-events.jsonl';
15
- const SOCKET_PATH = '/tmp/beehaven.sock';
14
+ import { watcherSocketPath, eventsFilePath, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
15
+ const EVENTS_FILE = eventsFilePath();
16
+ const SOCKET_PATH = watcherSocketPath();
16
17
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
17
18
  export { SOCKET_PATH };
18
19
  export class ClaudeWatcher extends EventEmitter {
@@ -32,14 +33,8 @@ export class ClaudeWatcher extends EventEmitter {
32
33
  // ── Unix Domain Socket ───────────────────────────────────────────────
33
34
  /** Start HTTP server on Unix domain socket for direct event delivery */
34
35
  startSocket() {
35
- // Remove stale socket file
36
- try {
37
- if (existsSync(SOCKET_PATH))
38
- unlinkSync(SOCKET_PATH);
39
- }
40
- catch {
41
- /* may not exist */
42
- }
36
+ // Remove a stale socket file (a Windows named pipe leaves none)
37
+ clearStaleSocket(SOCKET_PATH);
43
38
  this.socketServer = createServer((req, res) => {
44
39
  if (req.method !== 'POST' || req.url !== '/event') {
45
40
  res.writeHead(404);
@@ -71,10 +66,7 @@ export class ClaudeWatcher extends EventEmitter {
71
66
  // during startup). A post-bind chmod achieves the same security
72
67
  // without the race.
73
68
  this.socketServer.listen(SOCKET_PATH, () => {
74
- try {
75
- chmodSync(SOCKET_PATH, 0o600);
76
- }
77
- catch { }
69
+ restrictSocketToOwner(SOCKET_PATH); // no-op for a named pipe
78
70
  console.log(`[watcher] Socket listening on ${SOCKET_PATH}`);
79
71
  });
80
72
  }
@@ -84,13 +76,7 @@ export class ClaudeWatcher extends EventEmitter {
84
76
  this.socketServer.close();
85
77
  this.socketServer = null;
86
78
  }
87
- try {
88
- if (existsSync(SOCKET_PATH))
89
- unlinkSync(SOCKET_PATH);
90
- }
91
- catch {
92
- /* may already be gone */
93
- }
79
+ clearStaleSocket(SOCKET_PATH);
94
80
  }
95
81
  // ── File Watcher (fallback) ──────────────────────────────────────────
96
82
  /** Start file watcher as fallback for when socket isn't available */
@@ -0,0 +1,77 @@
1
+ /**
2
+ * windows-hide — no console windows flashing open on Windows (CAN2-56).
3
+ *
4
+ * ⚠⚠ WHY WINDOWS FLASHED. A Windows process with no console of its own — the detached daemon,
5
+ * or `beehaven` run by an agent or an app rather than in a terminal — that starts a CONSOLE
6
+ * program (cmd.exe for every `exec`/`execSync`, powershell, git, node, …) makes Windows create a
7
+ * brand-new console window for that child. It pops up and closes as the child exits: one flash per
8
+ * call, and the daemon makes calls on timers. Node prevents it per call with `windowsHide: true`;
9
+ * the CLI had ~140 child-process calls and one of them set it.
10
+ *
11
+ * ⚠ ONE CHOKEPOINT, NOT 140 CALL SITES. Imported FIRST by every entry point (`cli.ts`,
12
+ * `clearly.ts`, `daemon-entry.ts`), this wraps the `child_process` functions so `windowsHide`
13
+ * defaults to true for every caller in the process — this package, `@beehaven/core`, and any
14
+ * dependency — and `syncBuiltinESMExports()` makes ESM named imports (`import { spawn } …`) see
15
+ * the wrappers too. An explicit `windowsHide: false` is respected. Off Windows it does nothing.
16
+ *
17
+ * ⚠ `exec` / `execFile` carry `util.promisify.custom` (so `promisify(exec)` resolves
18
+ * `{ stdout, stderr }`). The wrappers forward it; without that, every `promisify(exec)` caller
19
+ * would silently start receiving a bare string.
20
+ */
21
+ import { createRequire, syncBuiltinESMExports } from 'node:module';
22
+ import { promisify } from 'node:util';
23
+ const require = createRequire(import.meta.url);
24
+ const MARK = Symbol.for('beehaven.windowsHide');
25
+ /**
26
+ * A child_process call's arguments with `windowsHide: true` set, unless the caller chose a value.
27
+ * `takesArgv` = the second parameter may be an argv array (spawn, spawnSync, execFile,
28
+ * execFileSync, fork). Pure — exported for tests.
29
+ */
30
+ export function withWindowsHide(args, takesArgv) {
31
+ const a = [...args];
32
+ for (let i = 1; i < a.length; i++) {
33
+ const v = a[i];
34
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
35
+ if (v.windowsHide === undefined)
36
+ a[i] = { ...v, windowsHide: true };
37
+ return a;
38
+ }
39
+ }
40
+ // No options object: it goes after the command (and its argv array), before any callback. An
41
+ // explicit `undefined`/`null` options slot is filled rather than shifted.
42
+ const at = takesArgv && Array.isArray(a[1]) ? 2 : 1;
43
+ if (at < a.length && (a[at] === undefined || a[at] === null))
44
+ a[at] = { windowsHide: true };
45
+ else
46
+ a.splice(at, 0, { windowsHide: true });
47
+ return a;
48
+ }
49
+ const WRAPPED = [
50
+ ['spawn', true], ['spawnSync', true], ['execFile', true], ['execFileSync', true], ['fork', true],
51
+ ['exec', false], ['execSync', false],
52
+ ];
53
+ /** Wrap `child_process` once per process. Returns whether it is installed. `force` is for tests. */
54
+ export function installWindowsHide(opts = {}) {
55
+ if (process.platform !== 'win32' && !opts.force)
56
+ return false;
57
+ const cp = require('node:child_process');
58
+ if (cp[MARK])
59
+ return true;
60
+ for (const [name, takesArgv] of WRAPPED) {
61
+ const orig = cp[name];
62
+ if (typeof orig !== 'function')
63
+ continue;
64
+ const wrapper = function (...args) {
65
+ return orig.apply(this, withWindowsHide(args, takesArgv));
66
+ };
67
+ const custom = orig[promisify.custom];
68
+ if (typeof custom === 'function') {
69
+ wrapper[promisify.custom] = (...args) => custom.apply(orig, withWindowsHide(args, takesArgv));
70
+ }
71
+ cp[name] = wrapper;
72
+ }
73
+ cp[MARK] = true;
74
+ syncBuiltinESMExports();
75
+ return true;
76
+ }
77
+ installWindowsHide();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beehaven",
3
- "version": "0.8.29",
3
+ "version": "0.8.33",
4
4
  "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
5
  "private": false,
6
6
  "type": "module",
@@ -40,6 +40,12 @@ const SOURCES = {
40
40
  'codebase-map': 'apps/mcp-server/plugin/skills/codebase-map/SKILL.md',
41
41
  'clearly-workspace': 'apps/mcp-server/plugin/skills/clearly-workspace/SKILL.md',
42
42
  'clearly-agent': 'apps/mcp-server/plugin/skills/clearly-agent/SKILL.md',
43
+ // CAN2-57 — the rest of the plugin's skills: one set across plugin, CLI and Mac app.
44
+ 'visualize': 'apps/mcp-server/plugin/skills/visualize/SKILL.md',
45
+ 'sticker-pack': 'apps/mcp-server/plugin/skills/sticker-pack/SKILL.md',
46
+ 'clearly-docs': 'apps/mcp-server/plugin/skills/clearly-docs/SKILL.md',
47
+ 'clearly-init': 'apps/mcp-server/plugin/skills/clearly-init/SKILL.md',
48
+ 'clearly-workflows': 'apps/mcp-server/plugin/skills/clearly-workflows/SKILL.md',
43
49
  };
44
50
 
45
51
  let missing = 0;
@@ -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
@@ -64,15 +65,31 @@ handover. A mark of hand-placed béziers does not, and it shows.
64
65
  | **Counter-form** | the shape is the NEGATIVE space between forms | clever marks that survive one colour |
65
66
  | **Modular** | one unit repeated/rotated on a grid | systems, networks, collectives |
66
67
 
67
- **Build it as an `svg` node with a stated viewBox and derived numbers:**
68
+ **Build it from native layers, every number derived from the construction** — the way an
69
+ identity designer works in Figma, so each part of the mark stays a named, editable layer in the
70
+ brand colour (never one generated or hand-written SVG):
68
71
 
69
72
  ```jsonc
70
- {"action":"canvas.create-node","args":{"name":"mark","type":"svg","x":120,"y":120,"w":240,"h":240,
71
- "svg":"<svg viewBox='0 0 240 240' xmlns='http://www.w3.org/2000/svg'>…</svg>"}}
73
+ {"batch":[
74
+ {"action":"frame.create","args":{"name":"Mark","x":120,"y":120,"w":240,"h":240}},
75
+ {"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/ring","parentId":"Mark",
76
+ "x":20,"y":20,"w":200,"h":200,"fill":"#1B4DFF"}},
77
+ {"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/counter","parentId":"Mark",
78
+ "x":70,"y":70,"w":100,"h":100,"fill":"#FFFFFF"}},
79
+ {"action":"vector.create","args":{"name":"mark/blade","parentId":"Mark","closed":true,"fill":"#1B4DFF",
80
+ "points":[{"x":120,"y":20},{"x":220,"y":120},{"x":120,"y":120}]}},
81
+ {"action":"canvas.group","args":{"ids":["mark/ring","mark/counter","mark/blade"],"name":"mark"}}
82
+ ]}
72
83
  ```
73
84
 
74
- ⚠ **`<text>` inside an `svg` node does NOT render headlessly** — no system fonts are loaded.
75
- Wordmarks are real text nodes, or outlined paths. Never live `<text>` in the mark.
85
+ Here R = 100: the counter is R/2, the blade's corners sit on the circle. Curves are
86
+ `vector.create` points with `hIn`/`hOut` control points (absolute coordinates) placed where the
87
+ construction says; a quarter-circle's handle sits 0.552·R from its anchor. A counter-form is a
88
+ ground-colour shape layered on top; `vector.boolean` makes a true cut-out when an editor is open.
89
+ Raw SVG (`type:"svg"`) is only for importing a mark the client already has.
90
+
91
+ ⚠ **Wordmarks are real text nodes, or outlined paths** — and `<text>` inside an imported `svg`
92
+ node does NOT render headlessly (no system fonts are loaded).
76
93
 
77
94
  **Test the mark against these before going further. A mark that fails any of them is not done:**
78
95