@clearlysh/beehaven 0.8.27 → 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 = 'db2b3eaeb90b';
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-07T02:00Z';
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,11 +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 AND THE CLI RELAY CAPS AT 180s. `RESPOND_TIMEOUT_MS` on the
51
- * server is the same 180s, so a turn that outlives this was going to be killed anyway — matching
52
- * them means a timeout here always means the same thing it means there.
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
53
+ * by its caller and a timeout here still means the turn itself ran out. The relay-forward window
54
+ * follows the `_timeout` sent with the call.
53
55
  */
54
- const TURN_MS = 180_000;
56
+ export const TURN_MS = 930_000;
55
57
  /**
56
58
  * ⚠ STDERR IS CAPTURED, NOT INHERITED. A failing probe is normal here — `HEAD~30` does not exist in
57
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': {
@@ -3917,7 +3918,7 @@ complete -F _beehaven_completions beehaven`);
3917
3918
  console.log(` ${r.url}`);
3918
3919
  // "reached" not "navigated": the frame goes to every connected surface but only the
3919
3920
  // VISIBLE one acts on it, so a count of sockets is not a count of things that moved.
3920
- console.log(color.dim(` ${r.delivered > 0 ? `on screen (reached ${r.delivered} connected surface(s))` : 'no tab connected'}`));
3921
+ console.log(color.dim(` ${r.delivered > 0 ? `navigation requested (${r.delivered} connected surface(s)); editor display is not yet verified` : 'no navigation recipient connected'}`));
3921
3922
  // Only reach for the local browser when asked, or when nothing was
3922
3923
  // delivered — opening a window under someone who already has the tab
3923
3924
  // focused is worse than doing nothing.
@@ -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,7 +26,10 @@ 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
- export function getTimeout(values) {
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
+ const AGENT_TURN_ACTIONS = new Set(['agent-prompt', 'agent-turn']);
32
+ export function getTimeout(values, action) {
30
33
  // Precedence: explicit --timeout flag → BEEHAVEN_RPC_TIMEOUT_MS env → default.
31
34
  // The default is generous because real jobs (sticker-sheet gen, renders,
32
35
  // long agent turns) routinely run well past the old 30s and shouldn't need a
@@ -37,7 +40,8 @@ export function getTimeout(values) {
37
40
  const env = process.env.BEEHAVEN_RPC_TIMEOUT_MS;
38
41
  if (env && Number.isFinite(Number(env)) && Number(env) > 0)
39
42
  return parseInt(env, 10);
40
- return 180_000;
43
+ // An agent job may run to the server's 900s ceiling (WDP3-62); waiting less abandons it.
44
+ return action && AGENT_TURN_ACTIONS.has(action) ? AGENT_TURN_TIMEOUT_MS : 180_000;
41
45
  }
42
46
  /**
43
47
  * True when `msg` says `action` isn't available on the target DO.
@@ -618,11 +622,24 @@ export async function cmdActions(values, positional) {
618
622
  grouped.set(cat, []);
619
623
  grouped.get(cat).push(action);
620
624
  }
625
+ if (!values.verbose && !values.category && !values.search) {
626
+ for (const [category, entries] of grouped) {
627
+ console.log(` ${category.padEnd(18)} ${String(entries.length).padStart(3)} actions · ${entries.slice(0, 3).map(a => a.name).join(', ')}${entries.length > 3 ? ', …' : ''}`);
628
+ }
629
+ console.log('');
630
+ console.log(' Find an action: beehaven actions --search <term>');
631
+ console.log(' List a category: beehaven actions --category <name>');
632
+ console.log(' Exact schema: beehaven explain <action>');
633
+ console.log(' Complete inventory: beehaven actions --verbose or --format full --json');
634
+ return;
635
+ }
621
636
  for (const [category, catActions] of grouped) {
622
637
  const catLabel = category.charAt(0).toUpperCase() + category.slice(1);
623
638
  console.log(` \x1b[36m${catLabel}\x1b[0m (${catActions.length})`);
624
639
  for (const a of catActions) {
625
- const desc = a.description ? ` \x1b[90m${a.description}\x1b[0m` : '';
640
+ const description = String(a.description || '');
641
+ const display = values.verbose || description.length <= 160 ? description : `${description.slice(0, 157)}…`;
642
+ const desc = display ? ` \x1b[90m${display}\x1b[0m` : '';
626
643
  const aliasLabel = Array.isArray(a.aliases) && a.aliases.length > 0
627
644
  ? ` \x1b[2m(also: ${a.aliases.join(', ')})\x1b[0m`
628
645
  : '';
@@ -1088,7 +1105,7 @@ export async function cmdCall(action, rawInput, values) {
1088
1105
  console.error(`\x1b[2m→ ${target} ${action}${who}\x1b[0m`);
1089
1106
  }
1090
1107
  try {
1091
- const raw = await rpcOnce(resolved.action, resolved.input, getTimeout(values));
1108
+ const raw = await rpcOnce(resolved.action, resolved.input, getTimeout(values, action));
1092
1109
  // Unwrap proxy envelope: admin:*-rpc wraps results in { type: 'ack', result: ... }
1093
1110
  const result = raw?.type === 'ack' && raw?.result !== undefined
1094
1111
  ? raw.result
@@ -3090,7 +3107,7 @@ export async function cmdAgent(prompt, values) {
3090
3107
  : {}),
3091
3108
  },
3092
3109
  // Omni runs tools; the CLI default is far too short for a real turn.
3093
- getTimeout(values) || 180_000);
3110
+ getTimeout(values, 'agent-turn'));
3094
3111
  /**
3095
3112
  * Print the REPLY, not the envelope. The whole response is still available
3096
3113
  * with `--json` — but a verb whose entire purpose is "ask a question and read
@@ -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. ' +