@clearlysh/beehaven 0.8.29 → 0.8.31

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 = '3767a788c0ee';
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:40Z';
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
@@ -43,9 +43,10 @@ import { execFileSync } from 'node:child_process';
43
43
  import { createHash } from 'node:crypto';
44
44
  import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
45
45
  import { homedir, tmpdir } from 'node:os';
46
- import { join, sep } from 'node:path';
46
+ import { dirname, join, sep } from 'node:path';
47
47
  import { fileURLToPath } from 'node:url';
48
- import { usableUpdateDigest } from '../version-utils.js';
48
+ import { compareVersions, usableUpdateDigest } from '../version-utils.js';
49
+ import { npmUpdatePlan, latestNpmVersion, installedNpmVersion, installNpmVersionSync } from '../npm-update.js';
49
50
  /**
50
51
  * Which install this process is.
51
52
  *
@@ -83,8 +84,9 @@ export function updateHint(kind = installKind()) {
83
84
  return 'Run \x1b[1mbeehaven update\x1b[0m';
84
85
  if (kind === 'npx')
85
86
  return 'Run `npx @clearlysh/beehaven@latest` for the current npm release.';
87
+ // npm installs update themselves since CAN2-57 (the daemon hourly, `beehaven update` on demand).
86
88
  if (kind === 'npm')
87
- return 'Run `npm install -g @clearlysh/beehaven@latest` to update this npm install.';
89
+ return 'Run \x1b[1mbeehaven update\x1b[0m (or `npm install -g @clearlysh/beehaven@latest`).';
88
90
  return 'This is a linked dev build — `npm run build` in beehaven_sdk/packages/cli.';
89
91
  }
90
92
  /**
@@ -113,9 +115,11 @@ export function runSelfUpdate(kind = installKind()) {
113
115
  if (kind === 'linked') {
114
116
  return { ok: false, message: 'This is a linked development build — refusing to overwrite it.\n cd beehaven_sdk/packages/cli && npm run build' };
115
117
  }
116
- if (kind === 'npm' || kind === 'npx') {
118
+ if (kind === 'npx') {
117
119
  return { ok: false, message: updateHint(kind) };
118
120
  }
121
+ if (kind === 'npm')
122
+ return runNpmSelfUpdate();
119
123
  const feed = (process.env.BEEHAVEN_CLI_FEED || 'https://downloads.clearly.sh/cli/').replace(/\/+$/, '');
120
124
  const installerUrl = process.env.BEEHAVEN_INSTALL_URL || 'https://www.clearly.sh/install.sh';
121
125
  const temp = mkdtempSync(join(tmpdir(), 'beehaven-self-update-'));
@@ -154,9 +158,32 @@ export function runSelfUpdate(kind = installKind()) {
154
158
  rmSync(temp, { recursive: true, force: true });
155
159
  }
156
160
  }
161
+ /**
162
+ * ⚠ npm installs (the only Windows install) update with npm itself — see `npm-update.ts` for why
163
+ * it refuses rather than guessing when this copy is not npm's global install or the prefix is not
164
+ * writable. The daemon restarts itself after ITS hourly update; after this manual one the running
165
+ * daemon keeps the old code until restarted, so the message says so.
166
+ */
167
+ function runNpmSelfUpdate() {
168
+ const manual = 'Update it by hand: npm install -g @clearlysh/beehaven@latest';
169
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
170
+ const plan = npmUpdatePlan(pkgRoot);
171
+ if (!plan.ok)
172
+ return { ok: false, message: `Can't update this npm install here: ${plan.reason}.\n ${manual}` };
173
+ const latest = latestNpmVersion(plan.npm);
174
+ if (!latest)
175
+ return { ok: false, message: `Update failed: could not read the latest version from npm.\n ${manual}` };
176
+ const current = installedNpmVersion(plan.globalRoot) ?? '0.0.0';
177
+ if (compareVersions(latest, current) <= 0)
178
+ return { ok: true, message: `Already up to date (${current}).` };
179
+ const r = installNpmVersionSync(plan, latest);
180
+ return r.ok
181
+ ? { ok: true, message: `${r.message} A daemon that is already running keeps the old version until: beehaven restart` }
182
+ : { ok: false, message: `${r.message}\n ${manual}` };
183
+ }
157
184
  /** True when this install can update itself at all — what the notifier uses to pick its wording. */
158
185
  export function canSelfUpdate(kind = installKind()) {
159
- return kind === 'user';
186
+ return kind === 'user' || kind === 'npm';
160
187
  }
161
188
  /**
162
189
  * Is a bundled CLI present that the shell can NEVER reach, whatever its version?
@@ -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
+ }