@clearlysh/beehaven 0.8.29 → 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 +4 -3
- package/dist/cli.js +8 -7
- package/dist/commands/daemon-cmd.js +2 -0
- 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 +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/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/brand-identity/SKILL.md +21 -5
- package/skills/clearly-canvas/SKILL.md +16 -4
- package/skills/clearly-canvas/authoring.md +4 -3
- package/skills/index.json +2 -2
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,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
|
|
@@ -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
|
+
}
|
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. ' +
|
package/dist/ipc-path.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc-path — where the daemon's local sockets live, on every platform (CAN2-56).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ THE ONE PLACE A SOCKET ADDRESS IS DECIDED. `/tmp/beehaven-ipc.sock` was typed out in five CLI
|
|
5
|
+
* files (server, three clients, the claude bridge) and `/tmp/beehaven.sock` in two more, each
|
|
6
|
+
* gating its connect on `existsSync`, unlinking stale files and chmod-ing to 0600. On Windows none
|
|
7
|
+
* of that works: Node's `net` listens on a NAMED PIPE (`\\.\pipe\…`), there is no `/tmp`, a pipe is
|
|
8
|
+
* not a file you can stat, unlink or chmod — so the daemon could not bind and no client could
|
|
9
|
+
* reach it. Every caller now asks this module, and the Unix addresses are byte-for-byte unchanged
|
|
10
|
+
* (the Mac app, the mwah CLI and the Electron daemon client still dial `/tmp/beehaven-ipc.sock`).
|
|
11
|
+
*
|
|
12
|
+
* ⚠ A PIPE NAME IS MACHINE-GLOBAL, so it carries the user name: two people signed in to one
|
|
13
|
+
* Windows machine get two daemons, as two Unix users do. Same-user is still the boundary that
|
|
14
|
+
* matters, and the IPC auth token (`ipc-auth.ts`) is what gates verbs on both platforms — the
|
|
15
|
+
* Unix 0600 mode is defense in depth that a pipe does not have a file-mode equivalent for.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ `$BEEHAVEN_IPC_SOCKET` / `$BEEHAVEN_WATCHER_SOCKET` override the address for BOTH ends (server
|
|
18
|
+
* and clients), so a test can stand a real daemon and a real client on a private path. Read at call
|
|
19
|
+
* time, not import time, for the same reason.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
|
|
22
|
+
import { tmpdir, userInfo } from 'os';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
export const UNIX_IPC_SOCKET = '/tmp/beehaven-ipc.sock';
|
|
25
|
+
export const UNIX_WATCHER_SOCKET = '/tmp/beehaven.sock';
|
|
26
|
+
export const UNIX_EVENTS_FILE = '/tmp/beehaven-events.jsonl';
|
|
27
|
+
/** `\\.\pipe\…` or `\\?\pipe\…` — the forms Node accepts for a Windows named pipe. */
|
|
28
|
+
export function isNamedPipe(p) {
|
|
29
|
+
return /^\\\\[.?]\\pipe\\/i.test(p);
|
|
30
|
+
}
|
|
31
|
+
/** The signed-in user's name, made safe for a pipe name. */
|
|
32
|
+
function pipeUser(env) {
|
|
33
|
+
let name = '';
|
|
34
|
+
try {
|
|
35
|
+
name = userInfo().username;
|
|
36
|
+
}
|
|
37
|
+
catch { /* no passwd entry / sandbox */ }
|
|
38
|
+
if (!name)
|
|
39
|
+
name = env.USERNAME || env.USER || 'user';
|
|
40
|
+
return name.toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'user';
|
|
41
|
+
}
|
|
42
|
+
const pipe = (name, env) => `\\\\.\\pipe\\${name}-${pipeUser(env)}`;
|
|
43
|
+
/** The daemon's IPC address: the Unix socket, or this user's named pipe on Windows. */
|
|
44
|
+
export function ipcSocketPath(platform = process.platform, env = process.env) {
|
|
45
|
+
if (env.BEEHAVEN_IPC_SOCKET)
|
|
46
|
+
return env.BEEHAVEN_IPC_SOCKET;
|
|
47
|
+
return platform === 'win32' ? pipe('beehaven-ipc', env) : UNIX_IPC_SOCKET;
|
|
48
|
+
}
|
|
49
|
+
/** The Claude Code event watcher's HTTP-over-socket address. */
|
|
50
|
+
export function watcherSocketPath(platform = process.platform, env = process.env) {
|
|
51
|
+
if (env.BEEHAVEN_WATCHER_SOCKET)
|
|
52
|
+
return env.BEEHAVEN_WATCHER_SOCKET;
|
|
53
|
+
return platform === 'win32' ? pipe('beehaven-watcher', env) : UNIX_WATCHER_SOCKET;
|
|
54
|
+
}
|
|
55
|
+
/** The watcher's JSONL fallback file. Windows has no `/tmp`; its temp dir is per-user already. */
|
|
56
|
+
export function eventsFilePath(platform = process.platform) {
|
|
57
|
+
return platform === 'win32' ? join(tmpdir(), 'beehaven-events.jsonl') : UNIX_EVENTS_FILE;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Could a listener be at `p`? A socket FILE can be checked without touching it. A named pipe
|
|
61
|
+
* cannot: it exists only while a server holds it, and probing it with a filesystem call opens an
|
|
62
|
+
* instance of it. So a pipe answers `true` and the connect attempt decides (`ENOENT` = nothing).
|
|
63
|
+
*/
|
|
64
|
+
export function socketMayExist(p) {
|
|
65
|
+
return isNamedPipe(p) || existsSync(p);
|
|
66
|
+
}
|
|
67
|
+
/** Remove a stale socket file before binding. A named pipe leaves nothing behind to remove. */
|
|
68
|
+
export function clearStaleSocket(p) {
|
|
69
|
+
if (isNamedPipe(p))
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
if (existsSync(p))
|
|
73
|
+
unlinkSync(p);
|
|
74
|
+
}
|
|
75
|
+
catch { /* already gone */ }
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Restrict a freshly bound socket file to its owner (0600) and report the mode it really has.
|
|
79
|
+
* `applicable: false` for a named pipe, which has no file mode — its boundary is the per-user name
|
|
80
|
+
* plus the IPC token.
|
|
81
|
+
*/
|
|
82
|
+
export function restrictSocketToOwner(p) {
|
|
83
|
+
if (isNamedPipe(p))
|
|
84
|
+
return { applicable: false, mode: null };
|
|
85
|
+
try {
|
|
86
|
+
chmodSync(p, 0o600);
|
|
87
|
+
return { applicable: true, mode: statSync(p).mode & 0o777 };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return { applicable: true, mode: null, error: String(err?.code ?? err?.message ?? err) };
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/ipc-server.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* IPC Server — bidirectional Unix socket for local clients (Electron, CLI tools).
|
|
3
3
|
*
|
|
4
4
|
* Provides request-response RPCs and broadcast subscriptions over a line-based
|
|
5
|
-
* JSON protocol on /tmp/beehaven-ipc.sock.
|
|
5
|
+
* JSON protocol on /tmp/beehaven-ipc.sock (a per-user named pipe on Windows — see ipc-path.ts).
|
|
6
6
|
*
|
|
7
7
|
* Local clients send messages to the daemon, which forwards them to the DO
|
|
8
8
|
* via the relay WebSocket. DO responses and broadcasts are forwarded back
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
* { "type": "status", "connected": true, "daemonPid": 1234 }
|
|
25
25
|
*/
|
|
26
26
|
import { createConnection, createServer } from 'net';
|
|
27
|
-
import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
|
|
28
27
|
import { EventEmitter } from 'events';
|
|
29
28
|
import { resolveAuthScope } from './ipc-auth.js';
|
|
30
|
-
|
|
29
|
+
import { ipcSocketPath, socketMayExist, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
|
|
30
|
+
/** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
|
|
31
|
+
const IPC_SOCKET_PATH = ipcSocketPath();
|
|
31
32
|
/**
|
|
32
33
|
* Message types the daemon will process without authentication.
|
|
33
34
|
*
|
|
@@ -114,9 +115,10 @@ export function actionChain(action, input, depth = 0) {
|
|
|
114
115
|
*/
|
|
115
116
|
function probeIpcSocket(timeoutMs = 400) {
|
|
116
117
|
return new Promise((resolve) => {
|
|
117
|
-
|
|
118
|
+
const path = ipcSocketPath();
|
|
119
|
+
if (!socketMayExist(path))
|
|
118
120
|
return resolve(false);
|
|
119
|
-
const client = createConnection(
|
|
121
|
+
const client = createConnection(path);
|
|
120
122
|
const done = (alive) => {
|
|
121
123
|
try {
|
|
122
124
|
client.destroy();
|
|
@@ -138,6 +140,8 @@ function probeIpcSocket(timeoutMs = 400) {
|
|
|
138
140
|
export { probeIpcSocket };
|
|
139
141
|
export class IpcServer extends EventEmitter {
|
|
140
142
|
server = null;
|
|
143
|
+
/** The address this server bound (resolved at `start`, so an env override set before start applies). */
|
|
144
|
+
path = null;
|
|
141
145
|
clients = new Map();
|
|
142
146
|
clientCounter = 0;
|
|
143
147
|
virtualClientCounter = 0;
|
|
@@ -157,12 +161,10 @@ export class IpcServer extends EventEmitter {
|
|
|
157
161
|
* at this point; a live server would have been caught upstream.
|
|
158
162
|
*/
|
|
159
163
|
start() {
|
|
160
|
-
// Clean up stale socket
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
catch { }
|
|
164
|
+
// Clean up a stale socket file (a named pipe leaves none).
|
|
165
|
+
const path = ipcSocketPath();
|
|
166
|
+
this.path = path;
|
|
167
|
+
clearStaleSocket(path);
|
|
166
168
|
this.server = createServer((socket) => {
|
|
167
169
|
const clientId = `ipc_${++this.clientCounter}`;
|
|
168
170
|
// Connections start unauthenticated and scope=null. The client must
|
|
@@ -235,7 +237,7 @@ export class IpcServer extends EventEmitter {
|
|
|
235
237
|
// process-wide umask — umask changes are global and race with any
|
|
236
238
|
// other mkdirSync / writeFileSync happening in parallel (e.g.
|
|
237
239
|
// LocalHive pre-creating building data dirs during startup).
|
|
238
|
-
this.server.listen(
|
|
240
|
+
this.server.listen(path, () => {
|
|
239
241
|
/**
|
|
240
242
|
* ⚠⚠ CHMOD, THEN **VERIFY**. This was `chmodSync(…, 0o600)` inside an empty `catch {}`, and
|
|
241
243
|
* the mode it claims has been wrong on every start measured: the live socket comes up
|
|
@@ -266,19 +268,18 @@ export class IpcServer extends EventEmitter {
|
|
|
266
268
|
* was learned once and not applied here. Import at the top; never reach for `require` in this
|
|
267
269
|
* package.
|
|
268
270
|
*/
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
console.warn(`[ipc] SECURITY: could not set 0600 on ${IPC_SOCKET_PATH} — ${err?.code ?? err?.message ?? err}`);
|
|
271
|
+
// A Windows named pipe has no file mode (`applicable: false`): its boundary is the per-user
|
|
272
|
+
// pipe name plus the IPC token — see `ipc-path.ts`.
|
|
273
|
+
const restricted = restrictSocketToOwner(path);
|
|
274
|
+
const mode = restricted.mode;
|
|
275
|
+
if (restricted.error) {
|
|
276
|
+
console.warn(`[ipc] SECURITY: could not set 0600 on ${path} — ${restricted.error}`);
|
|
276
277
|
}
|
|
277
278
|
if (mode !== null && mode !== 0o600) {
|
|
278
279
|
console.warn(`[ipc] SECURITY: socket mode is 0${mode.toString(8)}, expected 0600. Other-user connect is still `
|
|
279
280
|
+ `blocked (no write bit), but the documented guarantee does not hold — do not weaken it further.`);
|
|
280
281
|
}
|
|
281
|
-
console.log(`[ipc] Listening on ${
|
|
282
|
+
console.log(`[ipc] Listening on ${path}${mode !== null ? ` (mode 0${mode.toString(8)})` : ''}`);
|
|
282
283
|
});
|
|
283
284
|
this.server.on('error', (err) => {
|
|
284
285
|
console.warn(`[ipc] Server error: ${err.message}`);
|
|
@@ -295,11 +296,7 @@ export class IpcServer extends EventEmitter {
|
|
|
295
296
|
this.server.close();
|
|
296
297
|
this.server = null;
|
|
297
298
|
}
|
|
298
|
-
|
|
299
|
-
if (existsSync(IPC_SOCKET_PATH))
|
|
300
|
-
unlinkSync(IPC_SOCKET_PATH);
|
|
301
|
-
}
|
|
302
|
-
catch { }
|
|
299
|
+
clearStaleSocket(this.path ?? ipcSocketPath());
|
|
303
300
|
}
|
|
304
301
|
/** Broadcast a message from the DO to all subscribed local clients */
|
|
305
302
|
/**
|
package/dist/setup-service.js
CHANGED
|
@@ -391,8 +391,12 @@ export async function cleanupAll() {
|
|
|
391
391
|
}
|
|
392
392
|
// 3. Socket + event log
|
|
393
393
|
const { existsSync, unlinkSync, readdirSync, rmSync, readFileSync, writeFileSync } = await import('node:fs');
|
|
394
|
-
const { join } = await import('node:path');
|
|
395
|
-
|
|
394
|
+
const { join, dirname } = await import('node:path');
|
|
395
|
+
const { watcherSocketPath, ipcSocketPath, eventsFilePath, isNamedPipe } = await import('./ipc-path.js');
|
|
396
|
+
const eventsFile = eventsFilePath();
|
|
397
|
+
for (const p of [watcherSocketPath(), ipcSocketPath(), eventsFile]) {
|
|
398
|
+
if (isNamedPipe(p))
|
|
399
|
+
continue; // a Windows pipe disappears with the daemon that held it
|
|
396
400
|
try {
|
|
397
401
|
if (existsSync(p)) {
|
|
398
402
|
unlinkSync(p);
|
|
@@ -402,10 +406,11 @@ export async function cleanupAll() {
|
|
|
402
406
|
catch { }
|
|
403
407
|
}
|
|
404
408
|
try {
|
|
405
|
-
|
|
409
|
+
const eventsDir = dirname(eventsFile);
|
|
410
|
+
for (const f of readdirSync(eventsDir)) {
|
|
406
411
|
if (f.startsWith('beehaven-events.jsonl.') && f.endsWith('.old')) {
|
|
407
412
|
try {
|
|
408
|
-
unlinkSync(join(
|
|
413
|
+
unlinkSync(join(eventsDir, f));
|
|
409
414
|
}
|
|
410
415
|
catch { }
|
|
411
416
|
}
|
package/dist/watcher.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
2
|
// Beehaven Office - Claude Code Event Watcher
|
|
3
|
-
// Primary:
|
|
3
|
+
// Primary: HTTP server on a local socket — /tmp/beehaven.sock, or a named pipe on Windows (ipc-path.ts)
|
|
4
4
|
// Fallback: Watches the JSONL events file written by the hook script
|
|
5
5
|
// ============================================================================
|
|
6
6
|
// chokidar is imported lazily so a missing bundle (e.g. when the daemon ships
|
|
7
7
|
// inside an Electron app whose node_modules layout doesn't include chokidar)
|
|
8
8
|
// degrades the file-watcher to a no-op rather than crashing the whole daemon.
|
|
9
9
|
// The primary Unix-socket path does not depend on chokidar.
|
|
10
|
-
import { createReadStream, existsSync, writeFileSync, statSync, renameSync,
|
|
10
|
+
import { createReadStream, existsSync, writeFileSync, statSync, renameSync, chmodSync, watch as fsWatch, } from 'fs';
|
|
11
11
|
import { createInterface } from 'readline';
|
|
12
12
|
import { createServer } from 'http';
|
|
13
13
|
import { EventEmitter } from 'events';
|
|
14
|
-
|
|
15
|
-
const
|
|
14
|
+
import { watcherSocketPath, eventsFilePath, clearStaleSocket, restrictSocketToOwner } from './ipc-path.js';
|
|
15
|
+
const EVENTS_FILE = eventsFilePath();
|
|
16
|
+
const SOCKET_PATH = watcherSocketPath();
|
|
16
17
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
17
18
|
export { SOCKET_PATH };
|
|
18
19
|
export class ClaudeWatcher extends EventEmitter {
|
|
@@ -32,14 +33,8 @@ export class ClaudeWatcher extends EventEmitter {
|
|
|
32
33
|
// ── Unix Domain Socket ───────────────────────────────────────────────
|
|
33
34
|
/** Start HTTP server on Unix domain socket for direct event delivery */
|
|
34
35
|
startSocket() {
|
|
35
|
-
// Remove stale socket file
|
|
36
|
-
|
|
37
|
-
if (existsSync(SOCKET_PATH))
|
|
38
|
-
unlinkSync(SOCKET_PATH);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
/* may not exist */
|
|
42
|
-
}
|
|
36
|
+
// Remove a stale socket file (a Windows named pipe leaves none)
|
|
37
|
+
clearStaleSocket(SOCKET_PATH);
|
|
43
38
|
this.socketServer = createServer((req, res) => {
|
|
44
39
|
if (req.method !== 'POST' || req.url !== '/event') {
|
|
45
40
|
res.writeHead(404);
|
|
@@ -71,10 +66,7 @@ export class ClaudeWatcher extends EventEmitter {
|
|
|
71
66
|
// during startup). A post-bind chmod achieves the same security
|
|
72
67
|
// without the race.
|
|
73
68
|
this.socketServer.listen(SOCKET_PATH, () => {
|
|
74
|
-
|
|
75
|
-
chmodSync(SOCKET_PATH, 0o600);
|
|
76
|
-
}
|
|
77
|
-
catch { }
|
|
69
|
+
restrictSocketToOwner(SOCKET_PATH); // no-op for a named pipe
|
|
78
70
|
console.log(`[watcher] Socket listening on ${SOCKET_PATH}`);
|
|
79
71
|
});
|
|
80
72
|
}
|
|
@@ -84,13 +76,7 @@ export class ClaudeWatcher extends EventEmitter {
|
|
|
84
76
|
this.socketServer.close();
|
|
85
77
|
this.socketServer = null;
|
|
86
78
|
}
|
|
87
|
-
|
|
88
|
-
if (existsSync(SOCKET_PATH))
|
|
89
|
-
unlinkSync(SOCKET_PATH);
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
/* may already be gone */
|
|
93
|
-
}
|
|
79
|
+
clearStaleSocket(SOCKET_PATH);
|
|
94
80
|
}
|
|
95
81
|
// ── File Watcher (fallback) ──────────────────────────────────────────
|
|
96
82
|
/** Start file watcher as fallback for when socket isn't available */
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* windows-hide — no console windows flashing open on Windows (CAN2-56).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ WHY WINDOWS FLASHED. A Windows process with no console of its own — the detached daemon,
|
|
5
|
+
* or `beehaven` run by an agent or an app rather than in a terminal — that starts a CONSOLE
|
|
6
|
+
* program (cmd.exe for every `exec`/`execSync`, powershell, git, node, …) makes Windows create a
|
|
7
|
+
* brand-new console window for that child. It pops up and closes as the child exits: one flash per
|
|
8
|
+
* call, and the daemon makes calls on timers. Node prevents it per call with `windowsHide: true`;
|
|
9
|
+
* the CLI had ~140 child-process calls and one of them set it.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ ONE CHOKEPOINT, NOT 140 CALL SITES. Imported FIRST by every entry point (`cli.ts`,
|
|
12
|
+
* `clearly.ts`, `daemon-entry.ts`), this wraps the `child_process` functions so `windowsHide`
|
|
13
|
+
* defaults to true for every caller in the process — this package, `@beehaven/core`, and any
|
|
14
|
+
* dependency — and `syncBuiltinESMExports()` makes ESM named imports (`import { spawn } …`) see
|
|
15
|
+
* the wrappers too. An explicit `windowsHide: false` is respected. Off Windows it does nothing.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ `exec` / `execFile` carry `util.promisify.custom` (so `promisify(exec)` resolves
|
|
18
|
+
* `{ stdout, stderr }`). The wrappers forward it; without that, every `promisify(exec)` caller
|
|
19
|
+
* would silently start receiving a bare string.
|
|
20
|
+
*/
|
|
21
|
+
import { createRequire, syncBuiltinESMExports } from 'node:module';
|
|
22
|
+
import { promisify } from 'node:util';
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const MARK = Symbol.for('beehaven.windowsHide');
|
|
25
|
+
/**
|
|
26
|
+
* A child_process call's arguments with `windowsHide: true` set, unless the caller chose a value.
|
|
27
|
+
* `takesArgv` = the second parameter may be an argv array (spawn, spawnSync, execFile,
|
|
28
|
+
* execFileSync, fork). Pure — exported for tests.
|
|
29
|
+
*/
|
|
30
|
+
export function withWindowsHide(args, takesArgv) {
|
|
31
|
+
const a = [...args];
|
|
32
|
+
for (let i = 1; i < a.length; i++) {
|
|
33
|
+
const v = a[i];
|
|
34
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
35
|
+
if (v.windowsHide === undefined)
|
|
36
|
+
a[i] = { ...v, windowsHide: true };
|
|
37
|
+
return a;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// No options object: it goes after the command (and its argv array), before any callback. An
|
|
41
|
+
// explicit `undefined`/`null` options slot is filled rather than shifted.
|
|
42
|
+
const at = takesArgv && Array.isArray(a[1]) ? 2 : 1;
|
|
43
|
+
if (at < a.length && (a[at] === undefined || a[at] === null))
|
|
44
|
+
a[at] = { windowsHide: true };
|
|
45
|
+
else
|
|
46
|
+
a.splice(at, 0, { windowsHide: true });
|
|
47
|
+
return a;
|
|
48
|
+
}
|
|
49
|
+
const WRAPPED = [
|
|
50
|
+
['spawn', true], ['spawnSync', true], ['execFile', true], ['execFileSync', true], ['fork', true],
|
|
51
|
+
['exec', false], ['execSync', false],
|
|
52
|
+
];
|
|
53
|
+
/** Wrap `child_process` once per process. Returns whether it is installed. `force` is for tests. */
|
|
54
|
+
export function installWindowsHide(opts = {}) {
|
|
55
|
+
if (process.platform !== 'win32' && !opts.force)
|
|
56
|
+
return false;
|
|
57
|
+
const cp = require('node:child_process');
|
|
58
|
+
if (cp[MARK])
|
|
59
|
+
return true;
|
|
60
|
+
for (const [name, takesArgv] of WRAPPED) {
|
|
61
|
+
const orig = cp[name];
|
|
62
|
+
if (typeof orig !== 'function')
|
|
63
|
+
continue;
|
|
64
|
+
const wrapper = function (...args) {
|
|
65
|
+
return orig.apply(this, withWindowsHide(args, takesArgv));
|
|
66
|
+
};
|
|
67
|
+
const custom = orig[promisify.custom];
|
|
68
|
+
if (typeof custom === 'function') {
|
|
69
|
+
wrapper[promisify.custom] = (...args) => custom.apply(orig, withWindowsHide(args, takesArgv));
|
|
70
|
+
}
|
|
71
|
+
cp[name] = wrapper;
|
|
72
|
+
}
|
|
73
|
+
cp[MARK] = true;
|
|
74
|
+
syncBuiltinESMExports();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
installWindowsHide();
|
package/package.json
CHANGED
|
@@ -64,15 +64,31 @@ handover. A mark of hand-placed béziers does not, and it shows.
|
|
|
64
64
|
| **Counter-form** | the shape is the NEGATIVE space between forms | clever marks that survive one colour |
|
|
65
65
|
| **Modular** | one unit repeated/rotated on a grid | systems, networks, collectives |
|
|
66
66
|
|
|
67
|
-
**Build it
|
|
67
|
+
**Build it from native layers, every number derived from the construction** — the way an
|
|
68
|
+
identity designer works in Figma, so each part of the mark stays a named, editable layer in the
|
|
69
|
+
brand colour (never one generated or hand-written SVG):
|
|
68
70
|
|
|
69
71
|
```jsonc
|
|
70
|
-
{"
|
|
71
|
-
"
|
|
72
|
+
{"batch":[
|
|
73
|
+
{"action":"frame.create","args":{"name":"Mark","x":120,"y":120,"w":240,"h":240}},
|
|
74
|
+
{"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/ring","parentId":"Mark",
|
|
75
|
+
"x":20,"y":20,"w":200,"h":200,"fill":"#1B4DFF"}},
|
|
76
|
+
{"action":"canvas.create-node","args":{"type":"ellipse","name":"mark/counter","parentId":"Mark",
|
|
77
|
+
"x":70,"y":70,"w":100,"h":100,"fill":"#FFFFFF"}},
|
|
78
|
+
{"action":"vector.create","args":{"name":"mark/blade","parentId":"Mark","closed":true,"fill":"#1B4DFF",
|
|
79
|
+
"points":[{"x":120,"y":20},{"x":220,"y":120},{"x":120,"y":120}]}},
|
|
80
|
+
{"action":"canvas.group","args":{"ids":["mark/ring","mark/counter","mark/blade"],"name":"mark"}}
|
|
81
|
+
]}
|
|
72
82
|
```
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
Here R = 100: the counter is R/2, the blade's corners sit on the circle. Curves are
|
|
85
|
+
`vector.create` points with `hIn`/`hOut` control points (absolute coordinates) placed where the
|
|
86
|
+
construction says; a quarter-circle's handle sits 0.552·R from its anchor. A counter-form is a
|
|
87
|
+
ground-colour shape layered on top; `vector.boolean` makes a true cut-out when an editor is open.
|
|
88
|
+
Raw SVG (`type:"svg"`) is only for importing a mark the client already has.
|
|
89
|
+
|
|
90
|
+
⚠ **Wordmarks are real text nodes, or outlined paths** — and `<text>` inside an imported `svg`
|
|
91
|
+
node does NOT render headlessly (no system fonts are loaded).
|
|
76
92
|
|
|
77
93
|
**Test the mark against these before going further. A mark that fails any of them is not done:**
|
|
78
94
|
|
|
@@ -110,10 +110,19 @@ can read and edit artifact records, while canvas-specific actions use the CLI.
|
|
|
110
110
|
beehaven env
|
|
111
111
|
beehaven connect home
|
|
112
112
|
beehaven agent login <name> --label "Design <artifact>" --client cli
|
|
113
|
-
beehaven call canvas-perceive '{"compositionId":"<id>","
|
|
113
|
+
beehaven call canvas-perceive '{"compositionId":"<id>","outline":true}'
|
|
114
114
|
beehaven call canvas-catalog '{"query":"create-node"}'
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
Read a canvas the way you read a repository: `canvas-perceive {outline:true}` is `tree` (artboards,
|
|
118
|
+
sections, layer counts, first copy, EMPTY sections — a few hundred bytes); `{frame:"<id or name>"}`
|
|
119
|
+
opens one frame like a file (its layers indented, positions relative to their parent);
|
|
120
|
+
`{find:"Start free"}` is `grep` (copy, layer names or `#hex`, with each match's frame path). Use
|
|
121
|
+
`format:"json"` only when you need every field of every node. Every `canvas-act` write returns
|
|
122
|
+
`diagnostics` — a lint of the artboards it touched (text overflow, collisions, covered text,
|
|
123
|
+
contrast) — treat them like compiler errors and fix them in the next batch. `canvas.review` is still
|
|
124
|
+
the pixel check and the ticket gate.
|
|
125
|
+
|
|
117
126
|
A canvas is planned like a git repository: its own plan board, a WORK TICKET for the request, and
|
|
118
127
|
COMMITS — one ticket per coherent layer or section of a larger change.
|
|
119
128
|
`design-start {title, brandId?}` makes a PRIVATE DRAFT canvas, bound to the brand, with its private
|
|
@@ -133,7 +142,7 @@ builds on the last and can be reviewed alone. Build ONE commit per canvas-act ba
|
|
|
133
142
|
`canvas.review` of the artboard and carries
|
|
134
143
|
`"ticket":{"id":"<key>","status":"done","resolution":"<what changed>"}` — it lands only if that
|
|
135
144
|
review is clean or look; `result.ticket` says whether it did, and the ticket logs the canvas
|
|
136
|
-
revision and the layers the commit changed. Auto-layout, gradients, shadows and
|
|
145
|
+
revision and the layers the commit changed. Auto-layout, gradients, shadows, drawing and placeholders:
|
|
137
146
|
see "Technique" in [authoring.md](authoring.md).
|
|
138
147
|
|
|
139
148
|
`composition-board-get {compositionId}` reads the plan back. `design-start` with a project, ticket or
|
|
@@ -195,8 +204,11 @@ hand-author hundreds of coordinates in a chat message.
|
|
|
195
204
|
Check contrast on the actual ground. Use the user's real brand assets.
|
|
196
205
|
- Choose relevant imagery, a meaningful diagram, a product view or a deliberate
|
|
197
206
|
typographic treatment. Decorative blobs and random stock photos do not prove a
|
|
198
|
-
product claim.
|
|
199
|
-
|
|
207
|
+
product claim. Draw marks, icons, illustrations and diagrams yourself from native
|
|
208
|
+
layers (shapes, vector.create pen paths, text, groups), the way a designer works in
|
|
209
|
+
Figma, so every part stays named and editable. Raster generation suits photographic
|
|
210
|
+
imagery when an image tool is available. Raw SVG (an svg node, or a generated SVG)
|
|
211
|
+
is for importing an existing file or a requested SVG, never the default.
|
|
200
212
|
- Define repeated components once. Vary composition where the content changes.
|
|
201
213
|
A deck of identical cards has little rhythm even when every card aligns.
|
|
202
214
|
- Keep important copy and layers editable. Do not flatten the whole composition
|
|
@@ -123,14 +123,15 @@ deliverable requires them, with content order and hierarchy reconsidered for the
|
|
|
123
123
|
|
|
124
124
|
The same rules the in-app assistant works to (canvas-catalog returns them as `technique`).
|
|
125
125
|
|
|
126
|
-
- AUTO-LAYOUT: rows and stacks (nav links, button groups, card rows, form fields) are a frame whose children you create in reading order, then canvas.auto-layout { id, direction, gap, padding, align, justify, hug }. hug:false keeps the frame size, which a full-width bar needs with justify:"space-between"; set a child to layoutSizingH/V "fill" | "hug" | "fixed" with canvas.update-nodes.
|
|
126
|
+
- AUTO-LAYOUT: rows and stacks (nav links, button groups, card rows, form fields) are a frame whose children you create in reading order, then canvas.auto-layout { id, direction, gap, padding, align, justify, hug }. hug:false keeps the frame size, which a full-width bar needs with justify:"space-between"; set a child to layoutSizingH/V "fill" | "hug" | "fixed" with canvas.update-nodes. The frame stays live: create more children into it (parentId) and they flow in; layoutPositioning:"absolute" takes one out of the flow.
|
|
127
127
|
- GRADIENTS: gradient { kind, angle, stops: [{ offset, color }] } replaces the solid fill. angle is the direction the colour travels: 0 = left to right, 90 = top to bottom, 45 = top-left to bottom-right (not CSS degrees). Use two or three stops from the brand palette, and keep text off a busy gradient or check its contrast in canvas.review.
|
|
128
128
|
- SHADOWS AND BLUR: effects on any node. A soft card shadow is { type: "drop-shadow", x: 0, y: 4, blur: 16, spread: 0, color: "#0000001F" }; frosted glass is { type: "background-blur", blur: 16 }. Never fake a shadow with an extra rectangle.
|
|
129
|
-
-
|
|
129
|
+
- DRAWING: build every mark, icon, illustration and diagram shape from native layers, as a designer does in Figma, never as one generated SVG file. Primitives are canvas.create-node rect and ellipse; curves are pen paths, vector.create { name, parentId, points: [{ x, y, hIn?: { x, y }, hOut?: { x, y } }], closed, fill or stroke, strokeWidth }, where hIn/hOut are absolute control points: use few anchors with smooth handles, and refine with vector.read, vector.vertex.move and vector.handle.set. Round caps and joins: canvas.update-nodes patch { strokeCap: "round", strokeJoin: "round" }. Work back to front: ground, big silhouettes in flat brand colours, then features, then details and highlights; check the silhouette in canvas.review before detailing. Name layers by part ("fox/ear-left"), group each figure with canvas.group { ids, name } and set overlap with canvas.order { ids, op }. Make a cut-out by layering a ground-colour shape on top; vector.boolean { op } on selected paths needs an open editor. Keep one stroke weight per drawing. Photographic or painterly imagery is a placed raster. Raw SVG stays supported but is never the default: when an SVG is asked for, write the markup yourself as an svg node, canvas.create-node { type: "svg", name, w, h, svg } with <svg viewBox="0 0 W H"> matching w and h, or import existing markup the same way; never an SVG generation pipeline.
|
|
130
|
+
- PLACEHOLDERS: until real imagery exists, draw a native placeholder at the final size in a muted brand tone, never a flat accent block: a rect with the slot radius plus a simple motif (an ellipse sun, a vector.create mountain), grouped with canvas.group and named "<section> image placeholder", with any label in its own text node.
|
|
130
131
|
- SECTIONS: one frame per page section, named exactly as its ticket says, inside the page artboard. Build a section in one canvas-act batch that ends with canvas.review of the artboard and carries ticket: { id: "<its key>", status: "done", resolution: "<what changed>" }. The ticket moves only when that review is clean or look; result.ticket says whether it did. Then start the next section.
|
|
131
132
|
|
|
132
133
|
Use `arrow.create {from,to,routing,label}` for connected diagrams, so arrows remain bound
|
|
133
|
-
when nodes move.
|
|
134
|
+
when nodes move. Draw custom vector details natively (shapes, vector.create pen paths); raw SVG only imports an existing file. Keep labels as
|
|
134
135
|
native text so they remain editable and measurable.
|
|
135
136
|
|
|
136
137
|
Use `canvas.place {kind,id,x,y,w,h,name,parentId?}` to put a workspace object (document,
|
package/skills/index.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"project-plans.md",
|
|
18
18
|
"review.mjs"
|
|
19
19
|
],
|
|
20
|
-
"sha": "
|
|
20
|
+
"sha": "b942eebe6f0397fb"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"name": "agent-home",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"files": [
|
|
54
54
|
"SKILL.md"
|
|
55
55
|
],
|
|
56
|
-
"sha": "
|
|
56
|
+
"sha": "755db2d1ab8c759f"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"name": "pair-on-canvas",
|