beehaven 0.8.29 → 0.8.34

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.
Files changed (53) hide show
  1. package/dist/agent-attribution.js +20 -2
  2. package/dist/agent-scope.js +55 -10
  3. package/dist/agent-state.js +44 -0
  4. package/dist/agents-md-text.js +64 -0
  5. package/dist/browser-login.js +14 -4
  6. package/dist/build-channel.js +2 -2
  7. package/dist/claude-bridge.js +12 -9
  8. package/dist/clearly.js +4 -3
  9. package/dist/cli-state.js +45 -3
  10. package/dist/cli.js +82 -53
  11. package/dist/commands/agent-skills.js +12 -1
  12. package/dist/commands/agent.js +235 -28
  13. package/dist/commands/agents-md.js +118 -0
  14. package/dist/commands/daemon-cmd.js +2 -0
  15. package/dist/commands/help.js +21 -0
  16. package/dist/commands/self-update.js +32 -5
  17. package/dist/commands/workspace.js +22 -6
  18. package/dist/daemon-client.js +10 -6
  19. package/dist/daemon-entry.js +1 -0
  20. package/dist/daemon-process.js +125 -0
  21. package/dist/daemon.js +131 -62
  22. package/dist/dashboard.js +118 -6
  23. package/dist/ipc-auth.js +3 -1
  24. package/dist/ipc-client.js +7 -6
  25. package/dist/ipc-path.js +92 -0
  26. package/dist/ipc-server.js +33 -33
  27. package/dist/npm-update.js +138 -0
  28. package/dist/session-binding.js +135 -0
  29. package/dist/session-target.js +91 -0
  30. package/dist/setup-service.js +9 -4
  31. package/dist/verb-surface.js +1 -0
  32. package/dist/watcher.js +9 -23
  33. package/dist/windows-hide.js +77 -0
  34. package/package.json +2 -2
  35. package/scripts/sync-skills.mjs +6 -0
  36. package/skills/agent-doodle-space/SKILL.md +1 -1
  37. package/skills/agent-home/SKILL.md +1 -1
  38. package/skills/brand-identity/SKILL.md +22 -5
  39. package/skills/clearly-agent/SKILL.md +38 -14
  40. package/skills/clearly-canvas/SKILL.md +31 -5
  41. package/skills/clearly-canvas/authoring.md +41 -12
  42. package/skills/clearly-canvas/example.mjs +72 -69
  43. package/skills/clearly-docs/SKILL.md +151 -0
  44. package/skills/clearly-init/SKILL.md +135 -0
  45. package/skills/clearly-workflows/SKILL.md +90 -0
  46. package/skills/clearly-workspace/SKILL.md +1 -1
  47. package/skills/codebase-map/SKILL.md +1 -1
  48. package/skills/design-craft/SKILL.md +9 -6
  49. package/skills/index.json +45 -10
  50. package/skills/layout-systems/SKILL.md +14 -11
  51. package/skills/pair-on-canvas/SKILL.md +1 -1
  52. package/skills/sticker-pack/SKILL.md +132 -0
  53. package/skills/visualize/SKILL.md +188 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * beehaven agents-md — put the Clearly block in THIS project's AGENTS.md (CAN2-66).
3
+ *
4
+ * ⚠⚠ TWO FILES, TWO AUDIENCES. `beehaven login` already writes a GLOBAL block into
5
+ * ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md and ~/.gemini/GEMINI.md (agent-instructions.ts), so every
6
+ * agent on THIS machine knows Clearly. A project's AGENTS.md is for everyone else who opens the
7
+ * repo — a teammate's Codex, a cloud agent, CI — and it is committed and shared. That is why this
8
+ * is its own command the person (or their agent, asked first) runs, not a side effect of install.
9
+ *
10
+ * ⚠ THE MARKERS ARE THE UPDATE MECHANISM, as on every other surface (lib/connections/agentsMd.ts):
11
+ * re-running replaces only what lies between our markers and leaves the rest byte for byte. The
12
+ * CLI and MCP blocks have their own markers, so a repo used both ways keeps both.
13
+ *
14
+ * ⚠ AGENTS.md IS THE FILE; CLAUDE.md POINTS AT IT with one `@AGENTS.md` line — one copy to keep
15
+ * current. An existing CLAUDE.md gains the line only if it lacks it; nothing else in it is touched.
16
+ */
17
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
18
+ import { join, resolve } from 'path';
19
+ import { execFileSync } from 'child_process';
20
+ import { color } from '../spinner.js';
21
+ import { CLI_BLOCK_START, CLI_BLOCK_END, MCP_BLOCK_START, MCP_BLOCK_END, CLAUDE_MD_IMPORT, agentsMdCliBlock, agentsMdMcpBlock, } from '../agents-md-text.js';
22
+ /** Our block replaced between its markers, or appended after one blank line. Keeps CRLF files CRLF. */
23
+ export function spliceBlock(text, block, start, end) {
24
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
25
+ const b = block.split('\n').join(eol);
26
+ const i = text.indexOf(start);
27
+ const j = i >= 0 ? text.indexOf(end, i) : -1;
28
+ if (i >= 0 && j >= 0)
29
+ return text.slice(0, i) + b + text.slice(j + end.length);
30
+ if (!text.trim())
31
+ return b + eol;
32
+ return text.replace(/\s*$/, '') + eol + eol + b + eol;
33
+ }
34
+ /** Our block taken out, with the blank line that separated it. Everything else stays. */
35
+ export function removeBlock(text, start, end) {
36
+ const i = text.indexOf(start);
37
+ const j = i >= 0 ? text.indexOf(end, i) : -1;
38
+ if (i < 0 || j < 0)
39
+ return text;
40
+ const before = text.slice(0, i).replace(/(\r?\n)*$/, '');
41
+ const after = text.slice(j + end.length).replace(/^(\r?\n)*/, '');
42
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
43
+ if (!before)
44
+ return after;
45
+ return after ? before + eol + eol + after : before + eol;
46
+ }
47
+ /** The repository root when `dir` is inside a git work tree, else `dir` itself. */
48
+ export function projectRoot(dir) {
49
+ try {
50
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], {
51
+ cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5_000,
52
+ }).trim();
53
+ return top || dir;
54
+ }
55
+ catch {
56
+ return dir;
57
+ }
58
+ }
59
+ /** Write (or remove) the block in `<root>/AGENTS.md` and make CLAUDE.md import it. */
60
+ export function writeProjectAgentsMd(opts) {
61
+ const root = projectRoot(resolve(opts.dir));
62
+ const [start, end] = opts.mcp ? [MCP_BLOCK_START, MCP_BLOCK_END] : [CLI_BLOCK_START, CLI_BLOCK_END];
63
+ const agentsPath = join(root, 'AGENTS.md');
64
+ const claudePath = join(root, 'CLAUDE.md');
65
+ const files = [];
66
+ const had = existsSync(agentsPath);
67
+ const before = had ? readFileSync(agentsPath, 'utf8') : '';
68
+ if (opts.remove) {
69
+ const next = removeBlock(before, start, end);
70
+ if (next !== before)
71
+ writeFileSync(agentsPath, next);
72
+ files.push({ path: agentsPath, change: next !== before ? 'removed' : 'absent' });
73
+ return { root, files };
74
+ }
75
+ const next = spliceBlock(before, opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock(), start, end);
76
+ if (next !== before)
77
+ writeFileSync(agentsPath, next);
78
+ files.push({ path: agentsPath, change: !had ? 'created' : next !== before ? 'updated' : 'current' });
79
+ const claude = existsSync(claudePath) ? readFileSync(claudePath, 'utf8') : null;
80
+ if (claude === null) {
81
+ writeFileSync(claudePath, CLAUDE_MD_IMPORT + '\n');
82
+ files.push({ path: claudePath, change: 'created' });
83
+ }
84
+ else if (!claude.split(/\r?\n/).some((l) => l.trim() === CLAUDE_MD_IMPORT)) {
85
+ const eol = claude.includes('\r\n') ? '\r\n' : '\n';
86
+ writeFileSync(claudePath, claude.replace(/\s*$/, '') + (claude.trim() ? eol + eol : '') + CLAUDE_MD_IMPORT + eol);
87
+ files.push({ path: claudePath, change: 'updated' });
88
+ }
89
+ else {
90
+ files.push({ path: claudePath, change: 'current' });
91
+ }
92
+ return { root, files };
93
+ }
94
+ export function cmdAgentsMd(dir, opts) {
95
+ if (opts.print) {
96
+ process.stdout.write((opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock()) + '\n');
97
+ return 0;
98
+ }
99
+ let r;
100
+ try {
101
+ r = writeProjectAgentsMd({ dir: dir ?? process.cwd(), mcp: opts.mcp, remove: opts.remove });
102
+ }
103
+ catch (err) {
104
+ console.error(` ${color.red('✗')} ${err?.message ?? err}`);
105
+ return 1;
106
+ }
107
+ console.log('');
108
+ for (const f of r.files) {
109
+ const mark = f.change === 'current' || f.change === 'absent' ? color.dim('·') : color.green('✓');
110
+ console.log(` ${mark} ${f.path} ${color.dim(f.change)}`);
111
+ }
112
+ const changed = r.files.some((f) => f.change !== 'current' && f.change !== 'absent');
113
+ if (changed && !opts.remove) {
114
+ console.log(color.dim(`\n Commit ${r.files.map((f) => f.path.slice(r.root.length + 1)).join(' and ')} so every agent that opens this project knows the workspace.`));
115
+ }
116
+ console.log('');
117
+ return 0;
118
+ }
@@ -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
@@ -413,6 +413,27 @@ const PAGES = {
413
413
  \`beehaven login\` to connect the CLI to it.`,
414
414
  related: ['login', 'guide'],
415
415
  },
416
+ 'agents-md': {
417
+ synopsis: 'beehaven agents-md [dir] [--mcp] [--print] [--remove]',
418
+ description: `Add the Clearly block to this project's AGENTS.md, so every agent that opens the
419
+ repository — a teammate's Codex or Claude Code, a cloud agent, CI — knows the workspace and how
420
+ to reach it. Writes at the git root (or the directory given), and makes CLAUDE.md import it with
421
+ one \`@AGENTS.md\` line. Needs no login.
422
+
423
+ The block sits between Clearly's markers: run it again to update the block, and everything
424
+ else in the file stays as it was. \`beehaven login\` already teaches the agents on THIS machine
425
+ (their global instruction files); this is the shared, committed copy for everyone else.`,
426
+ flags: [
427
+ { name: '--mcp', desc: 'Write the block for agents connected over MCP instead of the CLI' },
428
+ { name: '--print', desc: 'Print the block and write nothing' },
429
+ { name: '--remove', desc: 'Take the block out again' },
430
+ ],
431
+ examples: [
432
+ { cmd: 'beehaven agents-md', note: 'AGENTS.md + CLAUDE.md at the repository root' },
433
+ { cmd: 'beehaven agents-md --mcp', note: 'for a team that connects over MCP' },
434
+ ],
435
+ related: ['skills', 'login'],
436
+ },
416
437
  handoff: {
417
438
  synopsis: 'beehaven handoff [file.md|-] [--tasks tasks.md|json] [--csv data.csv]… [--title "…"] [--wait] [--json]',
418
439
  description: `Give a person your work when they have no Clearly account yet. Needs no login.
@@ -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
  /**
@@ -725,8 +725,18 @@ export { activeAgentWrap, setCliDoor, cliDoor, alreadyAttributed, BOOTSTRAP_ACTI
725
725
  * ambient identity and is correct for every verb that does not offer that flag.
726
726
  */
727
727
  export async function agentRpc(action, input, timeout, values = {}) {
728
- const wrapped = activeAgentWrap(action, input, values);
729
- return rpcOnce(wrapped.action, wrapped.input, timeout);
728
+ /**
729
+ * ⚠⚠ THE TYPED VERBS FOLLOW THE SESSION'S WORKSPACE, LIKE `call` (AWO-393). This sent a bare
730
+ * `rpcOnce` to the daemon's default workspace: after `connect team/<id>`, `beehaven call
731
+ * ticket-create` filed in the team and `beehaven ticket create` filed at home. Same resolver,
732
+ * same order as `cmdCall` — wrap as the agent first, then the building envelope around it.
733
+ */
734
+ const routed = { ...values };
735
+ const { resolveTargetBuilding } = await import('../session-target.js');
736
+ await resolveTargetBuilding(routed);
737
+ const wrapped = activeAgentWrap(action, input, routed);
738
+ const resolved = resolveBuilding(wrapped.action, wrapped.input, routed.building);
739
+ return rpcOnce(resolved.action, resolved.input, timeout);
730
740
  }
731
741
  /**
732
742
  * Does this result payload represent a failure? Mirrors `isFailurePayload` in the worker's
@@ -1260,7 +1270,13 @@ function printCallErrorHints(msg, action, values) {
1260
1270
  if (!values.json) {
1261
1271
  console.error('');
1262
1272
  console.error('\x1b[2m Try:\x1b[0m');
1263
- if (s.includes('unknown action') || s.includes('404')) {
1273
+ if (/admin:(store|team|workspace)-rpc/.test(s) && s.includes('not allowed')) {
1274
+ // A store/team target is reached through an admin:*-rpc proxy, which the workspace refuses
1275
+ // to members over the daemon. Platform admins have the HTTP door instead (PPO-444).
1276
+ console.error(`\x1b[2m beehaven admin do shopifyStore/<id> ${action} '<json>' # platform admins: the operator door\x1b[0m`);
1277
+ console.error(`\x1b[2m beehaven admin dashboard shopifyStore/<id> # state, logs, snapshots\x1b[0m`);
1278
+ }
1279
+ else if (s.includes('unknown action') || s.includes('404')) {
1264
1280
  console.error(`\x1b[2m beehaven actions --search <name> # find the mounted action\x1b[0m`);
1265
1281
  console.error(`\x1b[2m beehaven pwd # confirm you\'re on the right DO\x1b[0m`);
1266
1282
  }
@@ -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
+ }