@clearlysh/beehaven 0.8.29 → 0.8.30

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.
@@ -93,10 +93,15 @@ async function warnIfLocalDevDown() {
93
93
  * Linux; start on Windows. Failure is SURFACED — silent failure was the bug — so the person can
94
94
  * paste the URL themselves.
95
95
  */
96
- function openInBrowser(url) {
96
+ export function openInBrowser(url) {
97
97
  try {
98
- const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
99
- const proc = spawn(opener, [url], { stdio: 'ignore', detached: true });
98
+ // ⚠ Windows: `start` is a cmd.exe BUILTIN, not a program, so spawning it always failed (ENOENT)
99
+ // and login fell back to "paste this URL". The URL protocol handler is a real executable, and
100
+ // passing the URL as an argv entry keeps cmd from splitting it at `&` (CAN2-56).
101
+ const [opener, args] = process.platform === 'darwin' ? ['open', [url]]
102
+ : process.platform === 'win32' ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
103
+ : ['xdg-open', [url]];
104
+ const proc = spawn(opener, args, { stdio: 'ignore', detached: true, windowsHide: true });
100
105
  proc.unref();
101
106
  proc.on('error', (err) => {
102
107
  console.log(` ${color.yellow('!')} Couldn't auto-open browser: ${err.message}`);
@@ -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 = 'd733c0004e58-dirty';
56
+ export const BUILD_SHA = '2c7d78e7c945';
57
57
  /** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
58
- export const BUILD_TIME = '2026-10-10T21:21Z';
58
+ export const BUILD_TIME = '2026-10-13T06:00Z';
59
59
  /**
60
60
  * The build identity as one short string, for `--version`.
61
61
  *
@@ -15,12 +15,13 @@
15
15
  * await bridge.callApp('recorder', 'record', { source: 'mic' });
16
16
  */
17
17
  import { createConnection } from 'net';
18
+ import { request as httpRequest } from 'http';
18
19
  import { existsSync, readFileSync, readdirSync } from 'fs';
19
20
  import { join } from 'path';
20
21
  import { homedir } from 'os';
21
22
  import { EventEmitter } from 'events';
22
23
  import { activeAgentWrap } from './agent-attribution.js';
23
- const IPC_SOCKET_PATH = '/tmp/beehaven-ipc.sock';
24
+ import { ipcSocketPath, watcherSocketPath, socketMayExist } from './ipc-path.js';
24
25
  const CONFIG_DIR = join(homedir(), '.beehaven');
25
26
  export class ClaudeBridge extends EventEmitter {
26
27
  socket = null;
@@ -39,11 +40,12 @@ export class ClaudeBridge extends EventEmitter {
39
40
  async connect() {
40
41
  if (this.connected)
41
42
  return true;
42
- if (!existsSync(IPC_SOCKET_PATH))
43
+ const ipcPath = ipcSocketPath();
44
+ if (!socketMayExist(ipcPath))
43
45
  return false;
44
46
  return new Promise((resolve) => {
45
47
  const timeout = setTimeout(() => resolve(false), 5_000);
46
- const socket = createConnection({ path: IPC_SOCKET_PATH });
48
+ const socket = createConnection({ path: ipcPath });
47
49
  this.socket = socket;
48
50
  socket.on('connect', () => {
49
51
  this.connected = true;
@@ -198,11 +200,12 @@ export class ClaudeBridge extends EventEmitter {
198
200
  }
199
201
  // ── Internal ───────────────────────────────────────────────────────
200
202
  sendEvent(hookEvent, extra = {}) {
201
- // Send via the watcher socket (HTTP POST to /tmp/beehaven.sock)
202
- // This is the same protocol mwah uses.
203
- if (!existsSync('/tmp/beehaven.sock'))
203
+ // Send via the watcher socket (HTTP POST to /event) — the same protocol mwah uses.
204
+ // ⚠ `http` is IMPORTED: this was `require('http')`, which is undefined in this ESM package,
205
+ // so every event threw a ReferenceError once the socket existed (CAN2-56).
206
+ const watcherPath = watcherSocketPath();
207
+ if (!socketMayExist(watcherPath))
204
208
  return;
205
- const http = require('http');
206
209
  const body = JSON.stringify({
207
210
  hook_event_name: hookEvent,
208
211
  session_id: this.sessionId,
@@ -211,8 +214,8 @@ export class ClaudeBridge extends EventEmitter {
211
214
  source: 'claude-code',
212
215
  ...extra,
213
216
  });
214
- const req = http.request({
215
- socketPath: '/tmp/beehaven.sock',
217
+ const req = httpRequest({
218
+ socketPath: watcherPath,
216
219
  path: '/event',
217
220
  method: 'POST',
218
221
  headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
package/dist/clearly.js CHANGED
@@ -26,6 +26,7 @@
26
26
  * product promises. GitHub / Linear / Slack ingestion is a separate project; nothing here
27
27
  * pretends otherwise, and `status` says so out loud.
28
28
  */
29
+ import './windows-hide.js'; // FIRST: no console windows flashing on Windows (CAN2-56)
29
30
  import { execFileSync, spawnSync } from 'node:child_process';
30
31
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
31
32
  import { dirname, join, resolve } from 'node:path';
@@ -47,12 +48,12 @@ const c = {
47
48
  red: (s) => `\x1b[31m${s}\x1b[0m`,
48
49
  };
49
50
  /**
50
- * ⚠ THE AGENT TURN IS THE SLOW PART. The server's `RESPOND_TIMEOUT_MS` is 300s (clearly CDS-90, was
51
- * 180s); this waits that plus the report's tail, so a turn that finishes on time is never abandoned
51
+ * ⚠ THE AGENT JOB IS THE SLOW PART. The server's `RESPOND_TIMEOUT_MS` is 900s (WDP3-62);
52
+ * this waits that plus the report's tail, so a turn that finishes on time is never abandoned
52
53
  * by its caller and a timeout here still means the turn itself ran out. The relay-forward window
53
54
  * follows the `_timeout` sent with the call.
54
55
  */
55
- export const TURN_MS = 330_000;
56
+ export const TURN_MS = 930_000;
56
57
  /**
57
58
  * ⚠ STDERR IS CAPTURED, NOT INHERITED. A failing probe is normal here — `HEAD~30` does not exist in
58
59
  * a repo with two commits — and with inherited stderr git printed
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@
16
16
  if (!process.env.BEEHAVEN_IPC_SCOPE) {
17
17
  process.env.BEEHAVEN_IPC_SCOPE = 'developer';
18
18
  }
19
+ import './windows-hide.js'; // FIRST: no console windows flashing on Windows (CAN2-56)
19
20
  import { parseArgs } from 'node:util';
20
21
  import { devModeAvailable, buildLabel, PUBLIC_BUILD } from './build-channel.js';
21
22
  import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, } from 'fs';
@@ -448,8 +449,9 @@ ${DEV_ONLY_HELP ? `
448
449
 
449
450
  Note: if you also run the Hive menu-bar app, the tray is the
450
451
  primary owner of the daemon. CLI start/stop only act when the tray
451
- isn't running its own daemon. The same socket (/tmp/beehaven-ipc.sock)
452
- is shared in both modes — never two daemons at once.
452
+ isn't running its own daemon. The same socket (/tmp/beehaven-ipc.sock;
453
+ a per-user named pipe on Windows) is shared in both modes — never two
454
+ daemons at once.
453
455
 
454
456
  ─────────────────────────────────────────────────────────────────────
455
457
  OPTIONS
@@ -3171,11 +3173,10 @@ async function run() {
3171
3173
  console.log(` Sign up at ${color.cyan(signupUrl)}.`);
3172
3174
  console.log(' Then run "beehaven login" to wire the CLI to your account.');
3173
3175
  console.log('');
3174
- try {
3175
- const { spawn } = await import('child_process');
3176
- spawn('open', [signupUrl], { stdio: 'ignore', detached: true }).unref();
3177
- }
3178
- catch { }
3176
+ // The shared opener: per-platform (Windows has no `open`), and a spawn failure prints the
3177
+ // URL instead of crashing on an unhandled child 'error' event.
3178
+ const { openInBrowser } = await import('./browser-login.js');
3179
+ openInBrowser(signupUrl);
3179
3180
  break;
3180
3181
  }
3181
3182
  case 'treehouse': {
@@ -42,6 +42,8 @@ export function spawnStandaloneDaemon(values) {
42
42
  const child = spawn(process.execPath, [entry], {
43
43
  detached: true,
44
44
  stdio: 'ignore',
45
+ // Windows: a detached child gets its OWN console window — closing it kills the daemon. Hide it.
46
+ windowsHide: true,
45
47
  // BEEHAVEN_HEADLESS=1 is REQUIRED, not optional: daemon-entry.ts runs a
46
48
  // parent-death watchdog (polls process.ppid, self-exits ~3s after the parent
47
49
  // is gone) to enforce the tray-coupled lifecycle. A detached CLI-spawned
@@ -26,8 +26,8 @@ import { resolveActiveAgent } from '../agent-state.js';
26
26
  import { isAgentRuntime } from '../agent-scope.js';
27
27
  import { getTarget } from '../cli-state.js';
28
28
  import { attachShellCwd, absorbShellCwd, shellTargetKey } from '../shell-session.js';
29
- /** Workspace-agent turns wait for the server's turn ceiling (300s) plus the report's tail. */
30
- export const AGENT_TURN_TIMEOUT_MS = 330_000;
29
+ /** Workspace-agent jobs wait for the server's ceiling (900s) plus the report's tail. */
30
+ export const AGENT_TURN_TIMEOUT_MS = 930_000;
31
31
  const AGENT_TURN_ACTIONS = new Set(['agent-prompt', 'agent-turn']);
32
32
  export function getTimeout(values, action) {
33
33
  // Precedence: explicit --timeout flag → BEEHAVEN_RPC_TIMEOUT_MS env → default.
@@ -40,7 +40,7 @@ export function getTimeout(values, action) {
40
40
  const env = process.env.BEEHAVEN_RPC_TIMEOUT_MS;
41
41
  if (env && Number.isFinite(Number(env)) && Number(env) > 0)
42
42
  return parseInt(env, 10);
43
- // An agent turn may run to the server's 300s ceiling (clearly CDS-90); waiting less abandons it.
43
+ // An agent job may run to the server's 900s ceiling (WDP3-62); waiting less abandons it.
44
44
  return action && AGENT_TURN_ACTIONS.has(action) ? AGENT_TURN_TIMEOUT_MS : 180_000;
45
45
  }
46
46
  /**
@@ -15,17 +15,16 @@
15
15
  * }
16
16
  */
17
17
  import net from 'net';
18
- import { existsSync } from 'fs';
19
18
  import { resolveClientAuthToken } from './ipc-auth.js';
20
19
  import { activeAgentWrap } from './agent-attribution.js';
21
- const SOCKET_PATH = '/tmp/beehaven-ipc.sock';
20
+ import { ipcSocketPath, socketMayExist } from './ipc-path.js';
22
21
  const TIMEOUT_MS = 60_000;
23
22
  const AUTH_TIMEOUT_MS = 3_000;
24
23
  /** One attempt. Resolves, never rejects. */
25
24
  function probeOnce(deadlineMs) {
26
25
  return new Promise((resolve) => {
27
26
  let connected = false;
28
- const sock = net.createConnection(SOCKET_PATH);
27
+ const sock = net.createConnection(ipcSocketPath());
29
28
  const timer = setTimeout(() => {
30
29
  sock.destroy();
31
30
  // ⚠ CONNECTED-BUT-SILENT IS NOT "DOWN". The kernel accepted us, so a listener exists.
@@ -65,7 +64,7 @@ const PROBE_BACKOFF_MS = 150;
65
64
  * that "down" is the original defect.
66
65
  */
67
66
  export async function probeDaemon() {
68
- if (!existsSync(SOCKET_PATH))
67
+ if (!socketMayExist(ipcSocketPath()))
69
68
  return { state: 'down', attempts: 0 };
70
69
  let sawListener = false;
71
70
  let last = { state: 'down', attempts: 0 };
@@ -74,6 +73,11 @@ export async function probeDaemon() {
74
73
  last = { ...r, attempts: i };
75
74
  if (r.state === 'up')
76
75
  return last;
76
+ // Nothing at the address (no socket file, or no such Windows pipe): permanent, like the
77
+ // missing-file check above — a named pipe cannot be checked before connecting, so this is
78
+ // where "nothing is running" is learned on Windows. Retrying would only add ~300 ms.
79
+ if (r.code === 'ENOENT' && !sawListener)
80
+ return last;
77
81
  if (r.state === 'silent')
78
82
  sawListener = true;
79
83
  if (i < PROBE_ATTEMPTS)
@@ -165,7 +169,7 @@ function handshake(socket) {
165
169
  * ⚠ `start --headless` NOW WAITS for the socket (see `daemon-cmd.ts`), so a script
166
170
  * that uses it should no longer reach this at all.
167
171
  */
168
- (existsSync(SOCKET_PATH)
172
+ (socketMayExist(ipcSocketPath())
169
173
  ? 'The daemon is still starting — its socket is up but it has not minted an IPC token yet. ' +
170
174
  'Wait a second and retry. (Do NOT restart: `stop` scrubs the token, which re-enters this window.)'
171
175
  : 'No IPC auth token and no daemon socket — nothing is running. ' +
@@ -192,7 +196,7 @@ function handshake(socket) {
192
196
  }
193
197
  /** Open a socket, complete auth handshake, run a single request, close. */
194
198
  async function withSocket(fn) {
195
- const socket = net.createConnection(SOCKET_PATH);
199
+ const socket = net.createConnection(ipcSocketPath());
196
200
  await new Promise((resolve, reject) => {
197
201
  socket.once('connect', () => resolve());
198
202
  socket.once('error', reject);
@@ -23,6 +23,7 @@
23
23
  * startup errors (those usually need user intervention — expired token,
24
24
  * missing config, etc).
25
25
  */
26
+ import './windows-hide.js'; // FIRST: no console windows flashing on Windows (CAN2-56)
26
27
  import { startDaemon } from './daemon.js';
27
28
  // ── Parent-watcher (tray-coupled enforcement) ──
28
29
  // The daemon is documented as tray-coupled: it must not outlive the
@@ -0,0 +1,125 @@
1
+ /**
2
+ * daemon-process — "is this process a beehaven daemon?", on every platform (CAN2-56).
3
+ *
4
+ * Split out of `daemon.ts` so the rule is testable without importing the daemon, and so Windows has
5
+ * an answer at all: `findLiveDaemons` read a command line with `ps` (macOS) or `/proc/<pid>/cmdline`
6
+ * (Linux) and found orphans with `pgrep`. Windows has none of those, so every lookup threw, was
7
+ * swallowed as "not ours", and `stop` / `restart` / the single-instance guard never saw a running
8
+ * daemon. Windows reads command lines from one CIM query instead.
9
+ */
10
+ import { execFileSync } from 'child_process';
11
+ /**
12
+ * Whether a full command line is a beehaven daemon or CLI process.
13
+ *
14
+ * Strict two-condition test (unchanged on macOS/Linux):
15
+ * 1. argv[0] is a node/tsx/beehaven binary — not a shell, an editor, a grep, etc.;
16
+ * 2. a later argument is a beehaven cli/daemon-entry script (under a beehaven* directory OR a
17
+ * Clearly*.app bundle).
18
+ *
19
+ * ⚠ WINDOWS COMMAND LINES ARE NORMALISED FIRST: argv[0] arrives quoted with backslashes, spaces and
20
+ * an extension (`"C:\Program Files\nodejs\node.exe" C:\…\daemon-entry.js`). It is reduced to the
21
+ * Unix shape (`C:/Program_Files/nodejs/node …`) so the same two conditions apply, unquoted.
22
+ */
23
+ export function isBeehavenCommandLine(raw, platform = process.platform) {
24
+ let cmd = (raw ?? '').trim();
25
+ if (!cmd)
26
+ return false;
27
+ if (platform === 'win32') {
28
+ const m = /^"([^"]*)"\s*(.*)$/s.exec(cmd) ?? /^(\S+)\s*(.*)$/s.exec(cmd);
29
+ if (!m)
30
+ return false;
31
+ const argv0 = m[1].replace(/\\/g, '/').replace(/\.(exe|cmd)$/i, '').replace(/\s+/g, '_').toLowerCase();
32
+ cmd = `${argv0} ${m[2].replace(/"/g, '')}`.trim();
33
+ }
34
+ // Condition 1: argv[0] must be a node/tsx/beehaven-wrapper binary. Anchored with ^ so a shell
35
+ // running a heredoc that contains "node .../cli.js" deeper in its argv doesn't false-positive.
36
+ const argv0Ok = /^(\S*\/)?node(\s|$)/.test(cmd) ||
37
+ /^(\S*\/)?tsx(\s|$)/.test(cmd) ||
38
+ /^(\S*\/)?beehaven(\s|$)/.test(cmd);
39
+ if (!argv0Ok)
40
+ return false;
41
+ // ⚠⚠ ON WINDOWS ONLY THE DAEMON ENTRY COUNTS. The `cli.js` patterns below exist for LEGACY daemons
42
+ // (`node cli.js start`, before daemon-entry.js) — and on macOS `pgrep` never surfaced ordinary CLI
43
+ // runs anyway (it reads its pattern as a BASIC regex, where `(js|ts)` matches nothing). Windows
44
+ // lists every node process, so with them a `start --headless` parent waiting for its child was
45
+ // "another daemon" (the child refused to start — the second Windows CI run), and `stop` would
46
+ // have killed an agent's in-flight `beehaven call`. There are no legacy Windows daemons to find.
47
+ if (platform === 'win32')
48
+ return /[/\\]\.?beehaven([-_]sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd);
49
+ // Condition 2: a subsequent argument must reference a beehaven script.
50
+ //
51
+ // NOTE THE `\.?` — the directory may be `beehaven/` OR the DOTFILE `.beehaven/`. And `[-_]sdk`:
52
+ // the monorepo checkout is `beehaven_sdk/`, but a clone of the GitHub repo is `beehaven-sdk/` —
53
+ // the first Windows CI run could not find its own daemon for exactly that reason (CAN2-56).
54
+ // `public/install.sh` installs to `~/.beehaven/cli/`, the canonical location for every
55
+ // `curl … | sh` user; requiring exactly "beehaven" once made the CLI blind to its own standard
56
+ // install (`stop` said "No daemon running" while one was).
57
+ return (
58
+ // Dedicated daemon entry — what the Mac app tray and `start --headless` spawn.
59
+ /[/\\]\.?beehaven([-_]sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd) ||
60
+ /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]daemon-entry\.js\b/.test(cmd) ||
61
+ // Legacy `cli.js start` — for old bundles pre-dating daemon-entry.js.
62
+ /[/\\]\.?beehaven([-_]sdk)?[/\\].*cli\.(js|ts)\b/.test(cmd) ||
63
+ /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js\b/.test(cmd) ||
64
+ // Bare `beehaven start` — wrapper already matched argv[0].
65
+ /^(\S*\/)?beehaven\s+start\b/.test(cmd));
66
+ }
67
+ /** Parse `ConvertTo-Json` output of `{ProcessId, CommandLine}` rows (one object, or an array). */
68
+ export function parseWindowsProcessJson(out) {
69
+ const text = (out ?? '').trim();
70
+ if (!text)
71
+ return [];
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(text);
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
80
+ const result = [];
81
+ for (const r of rows) {
82
+ const pid = Number(r?.ProcessId);
83
+ if (Number.isFinite(pid) && pid > 0)
84
+ result.push([pid, typeof r?.CommandLine === 'string' ? r.CommandLine : '']);
85
+ }
86
+ return result;
87
+ }
88
+ /**
89
+ * Node processes on Windows with their command lines — one `Get-CimInstance` call (~0.5–1 s, so it
90
+ * runs only on start/stop/status paths, never per RPC). `pid` narrows it to one process.
91
+ * Returns `null` when the query itself failed (no PowerShell, blocked by policy, timed out) — which
92
+ * is NOT the same as "no node processes", and `selectWindowsDaemons` treats it differently.
93
+ */
94
+ export function windowsNodeProcesses(pid) {
95
+ const filter = pid ? `ProcessId=${Math.trunc(pid)}` : "Name='node.exe'";
96
+ const script = `Get-CimInstance Win32_Process -Filter "${filter}" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress`;
97
+ try {
98
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
99
+ encoding: 'utf8',
100
+ stdio: ['ignore', 'pipe', 'ignore'],
101
+ timeout: 8000,
102
+ windowsHide: true,
103
+ });
104
+ return parseWindowsProcessJson(out);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Which of these Windows processes are beehaven daemons (never this process).
112
+ *
113
+ * ⚠ WHEN THE PROCESS QUERY FAILED (`rows === null`), THE PID FILE IS THE ONLY WITNESS LEFT. Seeing
114
+ * nothing would make `stop` say "No daemon running" over a live one and let `start` spawn a second
115
+ * daemon beside it — the failure the first Windows CI run showed. A live process at the recorded
116
+ * PID is trusted then, and only then: with a working query, the command line decides, so a PID
117
+ * recycled by an unrelated program is never taken for the daemon.
118
+ */
119
+ export function selectWindowsDaemons(rows, opts) {
120
+ if (rows === null) {
121
+ const p = opts.pidFilePid;
122
+ return p && p > 0 && p !== opts.selfPid && opts.isAlive(p) ? [p] : [];
123
+ }
124
+ return rows.filter(([pid, cmd]) => pid !== opts.selfPid && isBeehavenCommandLine(cmd, 'win32')).map(([pid]) => pid);
125
+ }
package/dist/daemon.js CHANGED
@@ -13,6 +13,8 @@ import { DaemonRepl } from './repl.js';
13
13
  import { browsePath } from './file-browser.js';
14
14
  import { CloudBeeProxy } from './bee/cloud-proxy.js';
15
15
  import { IpcServer, probeIpcSocket } from './ipc-server.js';
16
+ import { ipcSocketPath } from './ipc-path.js';
17
+ import { isBeehavenCommandLine, windowsNodeProcesses, selectWindowsDaemons } from './daemon-process.js';
16
18
  import { mintIpcAuthTokens, clearIpcAuthToken } from './ipc-auth.js';
17
19
  /**
18
20
  * What THIS daemon minted, so shutdown can avoid scrubbing a successor's token.
@@ -1104,41 +1106,17 @@ function isBeehavenProcess(pid) {
1104
1106
  timeout: 3000,
1105
1107
  }).trim();
1106
1108
  }
1109
+ else if (process.platform === 'win32') {
1110
+ // No `ps` and no /proc on Windows: read the command line from CIM (CAN2-56).
1111
+ cmd = windowsNodeProcesses(pid)?.find(([p]) => p === pid)?.[1] ?? '';
1112
+ }
1107
1113
  else {
1108
1114
  // Linux: /proc/PID/cmdline uses NULs; normalize to spaces
1109
1115
  cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
1110
1116
  }
1111
- if (!cmd)
1112
- return false;
1113
- // Condition 1: argv[0] must be a node/tsx/beehaven-wrapper binary.
1114
- // Anchoring with ^ so a shell running a heredoc that contains
1115
- // "node .../cli.js" deeper in its argv doesn't false-positive.
1116
- const argv0Ok = /^(\S*\/)?node(\s|$)/.test(cmd) ||
1117
- /^(\S*\/)?tsx(\s|$)/.test(cmd) ||
1118
- /^(\S*\/)?beehaven(\s|$)/.test(cmd);
1119
- if (!argv0Ok)
1120
- return false;
1121
- // Condition 2: a subsequent argument must reference a beehaven script.
1122
- //
1123
- // NOTE THE `\.?` — the directory may be `beehaven/` OR the DOTFILE
1124
- // `.beehaven/`. `public/install.sh` installs to `~/.beehaven/cli/`, which is
1125
- // the canonical location for every `curl … | sh` user, and these patterns
1126
- // used to require a path segment of exactly "beehaven". So the CLI could not
1127
- // recognise its OWN standard install: `stop` reported "No daemon running"
1128
- // while one was running, `restart` printed a fresh PID whose process then
1129
- // died with "Refusing to start — IPC socket already serving connections",
1130
- // and the real daemon was effectively unkillable from the CLI. Only
1131
- // monorepo (`beehaven_sdk/`) and Mac-app installs were ever detected.
1132
- const scriptOk =
1133
- // Dedicated daemon entry — what the Mac app tray spawns.
1134
- /[/\\]\.?beehaven(_sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd) ||
1135
- /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]daemon-entry\.js\b/.test(cmd) ||
1136
- // Legacy `cli.js start` — for old bundles pre-dating daemon-entry.js.
1137
- /[/\\]\.?beehaven(_sdk)?[/\\].*cli\.(js|ts)\b/.test(cmd) ||
1138
- /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js\b/.test(cmd) ||
1139
- // Bare `beehaven start` — wrapper already matched argv[0].
1140
- /^(\S*\/)?beehaven\s+start\b/.test(cmd);
1141
- return scriptOk;
1117
+ // The two-condition rule (argv[0] is node/tsx/beehaven AND an argument is a beehaven script)
1118
+ // lives in daemon-process.ts, where it also understands Windows command lines.
1119
+ return isBeehavenCommandLine(cmd);
1142
1120
  }
1143
1121
  catch {
1144
1122
  return false; // Can't read → not our process or already dead
@@ -1151,6 +1129,23 @@ function isBeehavenProcess(pid) {
1151
1129
  */
1152
1130
  export function findLiveDaemons() {
1153
1131
  const found = new Set();
1132
+ // Windows: no PID-file trust without a command line, and no pgrep — ONE CIM query of node
1133
+ // processes answers both "is the PID file's process ours" and "are there orphans" (CAN2-56).
1134
+ if (process.platform === 'win32') {
1135
+ let pidFilePid = null;
1136
+ try {
1137
+ pidFilePid = existsSync(PID_FILE) ? parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10) || null : null;
1138
+ }
1139
+ catch { }
1140
+ const alive = (pid) => { try {
1141
+ process.kill(pid, 0);
1142
+ return true;
1143
+ }
1144
+ catch {
1145
+ return false;
1146
+ } };
1147
+ return selectWindowsDaemons(windowsNodeProcesses(), { selfPid: process.pid, pidFilePid, isAlive: alive });
1148
+ }
1154
1149
  // 1. Check PID file first — the canonical source.
1155
1150
  if (existsSync(PID_FILE)) {
1156
1151
  try {
@@ -1294,7 +1289,7 @@ export async function startDaemon(opts) {
1294
1289
  // live server answering here means some daemon is alive that findLiveDaemons
1295
1290
  // missed (e.g. different binary path, permissions edge case).
1296
1291
  if (await probeIpcSocket()) {
1297
- console.error(`\n ✗ Another beehaven daemon is listening on /tmp/beehaven-ipc.sock.`);
1292
+ console.error(`\n ✗ Another beehaven daemon is listening on ${ipcSocketPath()}.`);
1298
1293
  console.error(` ✗ Quit the Clearly Dev Mac app tray to stop the existing daemon, then restart.\n`);
1299
1294
  try {
1300
1295
  logger.error('daemon', 'Refusing to start — IPC socket already serving connections');
package/dist/ipc-auth.js CHANGED
@@ -2,7 +2,9 @@
2
2
  * IPC auth — scope-aware shared secrets between daemon and local clients.
3
3
  *
4
4
  * Threat model: any process running as the same user can connect to
5
- * /tmp/beehaven-ipc.sock (it's chmod 0600, which only restricts other users).
5
+ * /tmp/beehaven-ipc.sock (it's chmod 0600, which only restricts other users). On
6
+ * Windows the daemon listens on a per-user named pipe, which has no file mode at all — so on
7
+ * that platform these tokens ARE the boundary for same-user callers (ipc-path.ts).
6
8
  * Without per-scope auth, any same-user process can invoke privileged
7
9
  * verbs — extract the Firebase ID token via `get-token`, run arbitrary
8
10
  * SQL, install marketplace packages with code execution.
@@ -4,14 +4,15 @@
4
4
  * Used by CLI commands (actions, call, pipe, watch, ls, cat) to communicate
5
5
  * with the running daemon, which forwards requests to the cloud DO.
6
6
  *
7
- * Protocol: newline-delimited JSON over /tmp/beehaven-ipc.sock
7
+ * Protocol: newline-delimited JSON over /tmp/beehaven-ipc.sock (a named pipe on Windows — ipc-path.ts)
8
8
  */
9
- import { existsSync } from 'node:fs';
10
9
  import { createConnection } from 'net';
11
10
  import { EventEmitter } from 'events';
12
11
  import { resolveClientAuthToken } from './ipc-auth.js';
12
+ import { ipcSocketPath, socketMayExist } from './ipc-path.js';
13
13
  import { activeAgentWrap } from './agent-attribution.js';
14
- const IPC_SOCKET_PATH = '/tmp/beehaven-ipc.sock';
14
+ /** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
15
+ const IPC_SOCKET_PATH = ipcSocketPath();
15
16
  /** Default timeout for RPC calls (ms) */
16
17
  const DEFAULT_TIMEOUT = 30_000;
17
18
  /**
@@ -49,7 +50,7 @@ export class IpcClient extends EventEmitter {
49
50
  * — the only way to guard the CALL SITE rather than the helper it calls, which is the exact
50
51
  * distinction that let 28 verbs ship un-attributed. Unset, nothing changes.
51
52
  */
52
- this.socketPath = opts?.socketPath ?? process.env.BEEHAVEN_IPC_SOCKET ?? IPC_SOCKET_PATH;
53
+ this.socketPath = opts?.socketPath ?? ipcSocketPath(); // honours $BEEHAVEN_IPC_SOCKET
53
54
  this.defaultTimeout = opts?.timeout ?? DEFAULT_TIMEOUT;
54
55
  this.explicitAuthToken = opts?.authToken;
55
56
  }
@@ -113,7 +114,7 @@ export class IpcClient extends EventEmitter {
113
114
  ? resolveClientAuthToken()
114
115
  : this.explicitAuthToken;
115
116
  let token = readToken();
116
- if (!token && this.explicitAuthToken === undefined && existsSync(this.socketPath)) {
117
+ if (!token && this.explicitAuthToken === undefined && socketMayExist(this.socketPath)) {
117
118
  const deadline = Date.now() + TOKEN_MINT_WAIT_MS;
118
119
  while (!token && Date.now() < deadline) {
119
120
  /**
@@ -143,7 +144,7 @@ export class IpcClient extends EventEmitter {
143
144
  * ⚠ THE SOCKET IS THE DISCRIMINATOR: up means "still starting, wait"; absent
144
145
  * means "nothing is running, start one". Opposite actions.
145
146
  */
146
- (existsSync(this.socketPath)
147
+ (socketMayExist(this.socketPath)
147
148
  ? 'The daemon is still starting — its socket is up but it has not minted an IPC token yet. ' +
148
149
  'Wait a second and retry. (Do NOT restart: `stop` scrubs the token, which re-enters this window.)'
149
150
  : 'No IPC auth token and no daemon socket — nothing is running. ' +
@@ -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
  /**
@@ -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
  }
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": "@clearlysh/beehaven",
3
- "version": "0.8.29",
3
+ "version": "0.8.30",
4
4
  "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
5
  "private": false,
6
6
  "type": "module",
@@ -64,15 +64,31 @@ handover. A mark of hand-placed béziers does not, and it shows.
64
64
  | **Counter-form** | the shape is the NEGATIVE space between forms | clever marks that survive one colour |
65
65
  | **Modular** | one unit repeated/rotated on a grid | systems, networks, collectives |
66
66
 
67
- **Build it as an `svg` node with a stated viewBox and derived numbers:**
67
+ **Build it from native layers, every number derived from the construction** — the way an
68
+ identity designer works in Figma, so each part of the mark stays a named, editable layer in the
69
+ brand colour (never one generated or hand-written SVG):
68
70
 
69
71
  ```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>"}}
72
+ {"batch":[
73
+ {"action":"frame.create","args":{"name":"Mark","x":120,"y":120,"w":240,"h":240}},
74
+ {"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/ring","parentId":"Mark",
75
+ "x":20,"y":20,"w":200,"h":200,"fill":"#1B4DFF"}},
76
+ {"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/counter","parentId":"Mark",
77
+ "x":70,"y":70,"w":100,"h":100,"fill":"#FFFFFF"}},
78
+ {"action":"vector.create","args":{"name":"mark/blade","parentId":"Mark","closed":true,"fill":"#1B4DFF",
79
+ "points":[{"x":120,"y":20},{"x":220,"y":120},{"x":120,"y":120}]}},
80
+ {"action":"canvas.group","args":{"ids":["mark/ring","mark/counter","mark/blade"],"name":"mark"}}
81
+ ]}
72
82
  ```
73
83
 
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.
84
+ Here R = 100: the counter is R/2, the blade's corners sit on the circle. Curves are
85
+ `vector.create` points with `hIn`/`hOut` control points (absolute coordinates) placed where the
86
+ construction says; a quarter-circle's handle sits 0.552·R from its anchor. A counter-form is a
87
+ ground-colour shape layered on top; `vector.boolean` makes a true cut-out when an editor is open.
88
+ Raw SVG (`type:"svg"`) is only for importing a mark the client already has.
89
+
90
+ ⚠ **Wordmarks are real text nodes, or outlined paths** — and `<text>` inside an imported `svg`
91
+ node does NOT render headlessly (no system fonts are loaded).
76
92
 
77
93
  **Test the mark against these before going further. A mark that fails any of them is not done:**
78
94
 
@@ -110,10 +110,19 @@ can read and edit artifact records, while canvas-specific actions use the CLI.
110
110
  beehaven env
111
111
  beehaven connect home
112
112
  beehaven agent login <name> --label "Design <artifact>" --client cli
113
- beehaven call canvas-perceive '{"compositionId":"<id>","format":"json"}'
113
+ beehaven call canvas-perceive '{"compositionId":"<id>","outline":true}'
114
114
  beehaven call canvas-catalog '{"query":"create-node"}'
115
115
  ```
116
116
 
117
+ Read a canvas the way you read a repository: `canvas-perceive {outline:true}` is `tree` (artboards,
118
+ sections, layer counts, first copy, EMPTY sections — a few hundred bytes); `{frame:"<id or name>"}`
119
+ opens one frame like a file (its layers indented, positions relative to their parent);
120
+ `{find:"Start free"}` is `grep` (copy, layer names or `#hex`, with each match's frame path). Use
121
+ `format:"json"` only when you need every field of every node. Every `canvas-act` write returns
122
+ `diagnostics` — a lint of the artboards it touched (text overflow, collisions, covered text,
123
+ contrast) — treat them like compiler errors and fix them in the next batch. `canvas.review` is still
124
+ the pixel check and the ticket gate.
125
+
117
126
  A canvas is planned like a git repository: its own plan board, a WORK TICKET for the request, and
118
127
  COMMITS — one ticket per coherent layer or section of a larger change.
119
128
  `design-start {title, brandId?}` makes a PRIVATE DRAFT canvas, bound to the brand, with its private
@@ -133,7 +142,7 @@ builds on the last and can be reviewed alone. Build ONE commit per canvas-act ba
133
142
  `canvas.review` of the artboard and carries
134
143
  `"ticket":{"id":"<key>","status":"done","resolution":"<what changed>"}` — it lands only if that
135
144
  review is clean or look; `result.ticket` says whether it did, and the ticket logs the canvas
136
- revision and the layers the commit changed. Auto-layout, gradients, shadows and SVG placeholders:
145
+ revision and the layers the commit changed. Auto-layout, gradients, shadows, drawing and placeholders:
137
146
  see "Technique" in [authoring.md](authoring.md).
138
147
 
139
148
  `composition-board-get {compositionId}` reads the plan back. `design-start` with a project, ticket or
@@ -195,8 +204,11 @@ hand-author hundreds of coordinates in a chat message.
195
204
  Check contrast on the actual ground. Use the user's real brand assets.
196
205
  - Choose relevant imagery, a meaningful diagram, a product view or a deliberate
197
206
  typographic treatment. Decorative blobs and random stock photos do not prove a
198
- product claim. Native SVG/paths suit diagrams and vector art; raster generation
199
- suits imagery when an image tool is available.
207
+ product claim. Draw marks, icons, illustrations and diagrams yourself from native
208
+ layers (shapes, vector.create pen paths, text, groups), the way a designer works in
209
+ Figma, so every part stays named and editable. Raster generation suits photographic
210
+ imagery when an image tool is available. Raw SVG (an svg node, or a generated SVG)
211
+ is for importing an existing file or a requested SVG, never the default.
200
212
  - Define repeated components once. Vary composition where the content changes.
201
213
  A deck of identical cards has little rhythm even when every card aligns.
202
214
  - Keep important copy and layers editable. Do not flatten the whole composition
@@ -123,14 +123,15 @@ deliverable requires them, with content order and hierarchy reconsidered for the
123
123
 
124
124
  The same rules the in-app assistant works to (canvas-catalog returns them as `technique`).
125
125
 
126
- - AUTO-LAYOUT: rows and stacks (nav links, button groups, card rows, form fields) are a frame whose children you create in reading order, then canvas.auto-layout { id, direction, gap, padding, align, justify, hug }. hug:false keeps the frame size, which a full-width bar needs with justify:"space-between"; set a child to layoutSizingH/V "fill" | "hug" | "fixed" with canvas.update-nodes.
126
+ - AUTO-LAYOUT: rows and stacks (nav links, button groups, card rows, form fields) are a frame whose children you create in reading order, then canvas.auto-layout { id, direction, gap, padding, align, justify, hug }. hug:false keeps the frame size, which a full-width bar needs with justify:"space-between"; set a child to layoutSizingH/V "fill" | "hug" | "fixed" with canvas.update-nodes. The frame stays live: create more children into it (parentId) and they flow in; layoutPositioning:"absolute" takes one out of the flow.
127
127
  - GRADIENTS: gradient { kind, angle, stops: [{ offset, color }] } replaces the solid fill. angle is the direction the colour travels: 0 = left to right, 90 = top to bottom, 45 = top-left to bottom-right (not CSS degrees). Use two or three stops from the brand palette, and keep text off a busy gradient or check its contrast in canvas.review.
128
128
  - SHADOWS AND BLUR: effects on any node. A soft card shadow is { type: "drop-shadow", x: 0, y: 4, blur: 16, spread: 0, color: "#0000001F" }; frosted glass is { type: "background-blur", blur: 16 }. Never fake a shadow with an extra rectangle.
129
- - SVG PLACEHOLDERS: until real imagery exists, draw it as an svg node at its final size in a muted tone from the brand palette, never a flat accent block, e.g. for a 400×240 slot svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 240"><rect width="400" height="240" rx="12" fill="#EDE6DF"/><circle cx="170" cy="96" r="14" fill="#CDBFB2"/><path d="M150 168l40-48 30 34 22-20 40 34z" fill="#CDBFB2"/></svg>' — <svg viewBox="0 0 W H"> always matches the node w and h. Put a label in its own text node and name the svg "<section> image placeholder". Hand-write only simple marks like this (placeholders, dividers, UI icons); logos, illustrations and finished artwork come from svg-make.
129
+ - DRAWING: build every mark, icon, illustration and diagram shape from native layers, as a designer does in Figma, never as one generated SVG file. Primitives are canvas.create-node rect and ellipse; curves are pen paths, vector.create { name, parentId, points: [{ x, y, hIn?: { x, y }, hOut?: { x, y } }], closed, fill or stroke, strokeWidth }, where hIn/hOut are absolute control points: use few anchors with smooth handles, and refine with vector.read, vector.vertex.move and vector.handle.set. Round caps and joins: canvas.update-nodes patch { strokeCap: "round", strokeJoin: "round" }. Work back to front: ground, big silhouettes in flat brand colours, then features, then details and highlights; check the silhouette in canvas.review before detailing. Name layers by part ("fox/ear-left"), group each figure with canvas.group { ids, name } and set overlap with canvas.order { ids, op }. Make a cut-out by layering a ground-colour shape on top; vector.boolean { op } on selected paths needs an open editor. Keep one stroke weight per drawing. Photographic or painterly imagery is a placed raster. Raw SVG stays supported but is never the default: when an SVG is asked for, write the markup yourself as an svg node, canvas.create-node { type: "svg", name, w, h, svg } with <svg viewBox="0 0 W H"> matching w and h, or import existing markup the same way; never an SVG generation pipeline.
130
+ - PLACEHOLDERS: until real imagery exists, draw a native placeholder at the final size in a muted brand tone, never a flat accent block: a rect with the slot radius plus a simple motif (an ellipse sun, a vector.create mountain), grouped with canvas.group and named "<section> image placeholder", with any label in its own text node.
130
131
  - SECTIONS: one frame per page section, named exactly as its ticket says, inside the page artboard. Build a section in one canvas-act batch that ends with canvas.review of the artboard and carries ticket: { id: "<its key>", status: "done", resolution: "<what changed>" }. The ticket moves only when that review is clean or look; result.ticket says whether it did. Then start the next section.
131
132
 
132
133
  Use `arrow.create {from,to,routing,label}` for connected diagrams, so arrows remain bound
133
- when nodes move. Use native shapes and SVG for custom vector details. Keep labels as
134
+ when nodes move. Draw custom vector details natively (shapes, vector.create pen paths); raw SVG only imports an existing file. Keep labels as
134
135
  native text so they remain editable and measurable.
135
136
 
136
137
  Use `canvas.place {kind,id,x,y,w,h,name,parentId?}` to put a workspace object (document,
package/skills/index.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "project-plans.md",
18
18
  "review.mjs"
19
19
  ],
20
- "sha": "7d79406b37f611a8"
20
+ "sha": "b942eebe6f0397fb"
21
21
  },
22
22
  {
23
23
  "name": "agent-home",
@@ -53,7 +53,7 @@
53
53
  "files": [
54
54
  "SKILL.md"
55
55
  ],
56
- "sha": "cd891499599ccb7b"
56
+ "sha": "755db2d1ab8c759f"
57
57
  },
58
58
  {
59
59
  "name": "pair-on-canvas",