@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.
- package/dist/browser-login.js +8 -3
- package/dist/build-channel.js +2 -2
- package/dist/claude-bridge.js +12 -9
- package/dist/clearly.js +4 -3
- package/dist/cli.js +8 -7
- package/dist/commands/daemon-cmd.js +2 -0
- package/dist/commands/self-update.js +32 -5
- package/dist/commands/workspace.js +3 -3
- package/dist/daemon-client.js +10 -6
- package/dist/daemon-entry.js +1 -0
- package/dist/daemon-process.js +125 -0
- package/dist/daemon.js +107 -52
- package/dist/ipc-auth.js +3 -1
- package/dist/ipc-client.js +7 -6
- package/dist/ipc-path.js +92 -0
- package/dist/ipc-server.js +22 -25
- package/dist/npm-update.js +138 -0
- package/dist/setup-service.js +9 -4
- package/dist/watcher.js +9 -23
- package/dist/windows-hide.js +77 -0
- package/package.json +1 -1
- package/scripts/sync-skills.mjs +6 -0
- package/skills/brand-identity/SKILL.md +21 -5
- package/skills/clearly-canvas/SKILL.md +16 -4
- package/skills/clearly-canvas/authoring.md +4 -3
- package/skills/clearly-docs/SKILL.md +151 -0
- package/skills/clearly-init/SKILL.md +135 -0
- package/skills/clearly-workflows/SKILL.md +89 -0
- package/skills/index.json +37 -2
- package/skills/sticker-pack/SKILL.md +132 -0
- package/skills/visualize/SKILL.md +188 -0
package/dist/browser-login.js
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
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}`);
|
package/dist/build-channel.js
CHANGED
|
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
|
|
|
53
53
|
* ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
|
|
54
54
|
* carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
|
|
55
55
|
*/
|
|
56
|
-
export const BUILD_SHA = '
|
|
56
|
+
export const BUILD_SHA = '3767a788c0ee';
|
|
57
57
|
/** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
|
|
58
|
-
export const BUILD_TIME = '2026-10-
|
|
58
|
+
export const BUILD_TIME = '2026-10-13T06:40Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
package/dist/claude-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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 /
|
|
202
|
-
//
|
|
203
|
-
|
|
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 =
|
|
215
|
-
socketPath:
|
|
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
|
|
51
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
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`
|
|
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 === '
|
|
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
|
|
30
|
-
export const AGENT_TURN_TIMEOUT_MS =
|
|
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
|
|
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
|
/**
|
package/dist/daemon-client.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 (!
|
|
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
|
-
(
|
|
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(
|
|
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);
|
package/dist/daemon-entry.js
CHANGED
|
@@ -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
|
+
}
|