@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.
- 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 +6 -4
- package/dist/cli.js +9 -8
- package/dist/commands/daemon-cmd.js +2 -0
- package/dist/commands/workspace.js +22 -5
- 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 +27 -32
- 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/relay-presets.js +3 -4
- 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/skills/beehaven-cli/SKILL.md +12 -0
- package/skills/brand-identity/SKILL.md +36 -14
- package/skills/clearly-agent/SKILL.md +13 -0
- package/skills/clearly-canvas/SKILL.md +73 -10
- package/skills/clearly-canvas/authoring.md +25 -1
- package/skills/clearly-canvas/review.mjs +106 -12
- package/skills/design-craft/SKILL.md +5 -0
- package/skills/design-craft/references.md +95 -0
- package/skills/index.json +7 -6
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 = '2c7d78e7c945';
|
|
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:00Z';
|
|
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,11 +48,12 @@ const c = {
|
|
|
47
48
|
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
48
49
|
};
|
|
49
50
|
/**
|
|
50
|
-
* ⚠ THE AGENT
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 =
|
|
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
|
|
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': {
|
|
@@ -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 ? `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
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
|
+
}
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
|
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.
|
package/dist/ipc-client.js
CHANGED
|
@@ -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
|
-
|
|
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 ??
|
|
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 &&
|
|
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
|
-
(
|
|
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. ' +
|