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.
- package/dist/agent-attribution.js +20 -2
- package/dist/agent-scope.js +55 -10
- package/dist/agent-state.js +44 -0
- package/dist/agents-md-text.js +64 -0
- package/dist/browser-login.js +14 -4
- package/dist/build-channel.js +2 -2
- package/dist/claude-bridge.js +12 -9
- package/dist/clearly.js +4 -3
- package/dist/cli-state.js +45 -3
- package/dist/cli.js +82 -53
- package/dist/commands/agent-skills.js +12 -1
- package/dist/commands/agent.js +235 -28
- package/dist/commands/agents-md.js +118 -0
- package/dist/commands/daemon-cmd.js +2 -0
- package/dist/commands/help.js +21 -0
- package/dist/commands/self-update.js +32 -5
- package/dist/commands/workspace.js +22 -6
- 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 +131 -62
- package/dist/dashboard.js +118 -6
- 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 +33 -33
- package/dist/npm-update.js +138 -0
- package/dist/session-binding.js +135 -0
- package/dist/session-target.js +91 -0
- package/dist/setup-service.js +9 -4
- package/dist/verb-surface.js +1 -0
- package/dist/watcher.js +9 -23
- package/dist/windows-hide.js +77 -0
- package/package.json +2 -2
- package/scripts/sync-skills.mjs +6 -0
- package/skills/agent-doodle-space/SKILL.md +1 -1
- package/skills/agent-home/SKILL.md +1 -1
- package/skills/brand-identity/SKILL.md +22 -5
- package/skills/clearly-agent/SKILL.md +38 -14
- package/skills/clearly-canvas/SKILL.md +31 -5
- package/skills/clearly-canvas/authoring.md +41 -12
- package/skills/clearly-canvas/example.mjs +72 -69
- package/skills/clearly-docs/SKILL.md +151 -0
- package/skills/clearly-init/SKILL.md +135 -0
- package/skills/clearly-workflows/SKILL.md +90 -0
- package/skills/clearly-workspace/SKILL.md +1 -1
- package/skills/codebase-map/SKILL.md +1 -1
- package/skills/design-craft/SKILL.md +9 -6
- package/skills/index.json +45 -10
- package/skills/layout-systems/SKILL.md +14 -11
- package/skills/pair-on-canvas/SKILL.md +1 -1
- package/skills/sticker-pack/SKILL.md +132 -0
- package/skills/visualize/SKILL.md +188 -0
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
|
/**
|
|
@@ -605,20 +602,23 @@ export class IpcServer extends EventEmitter {
|
|
|
605
602
|
// IPC bus so the same pipeline/app-rpc handlers serve both Claude and
|
|
606
603
|
// the human UI.
|
|
607
604
|
/** Register a virtual client and return its id. */
|
|
608
|
-
createVirtualClient(virtualSend, initialSubscriptions = ['*']) {
|
|
605
|
+
createVirtualClient(virtualSend, initialSubscriptions = ['*'], scope = null) {
|
|
609
606
|
const id = `virt_${++this.virtualClientCounter}`;
|
|
607
|
+
/**
|
|
608
|
+
* ⚠⚠ A VIRTUAL CLIENT IS ONLY AS TRUSTED AS WHOEVER IS ON THE OTHER END OF IT (CAN2-64).
|
|
609
|
+
* This used to be `authenticated: true, scope: 'developer'` for everyone, justified by "the
|
|
610
|
+
* dashboard bridge enforces its own auth (session cookie, origin check)". It enforced none,
|
|
611
|
+
* so any web page that opened ws://127.0.0.1:9800/apps/ws could call get-token. The bridge
|
|
612
|
+
* now judges the connection (dashboard.ts `appBridgeTrust`) and passes 'consumer' or null;
|
|
613
|
+
* a null client must authenticate with the IPC token, exactly like a socket client.
|
|
614
|
+
*/
|
|
610
615
|
const client = {
|
|
611
616
|
id,
|
|
612
617
|
virtualSend,
|
|
613
618
|
subscriptions: initialSubscriptions,
|
|
614
619
|
buffer: '',
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// (session cookie, origin check) before creating the virtual client,
|
|
618
|
-
// so we hand them developer scope to mirror what a logged-in tray
|
|
619
|
-
// user can already do.
|
|
620
|
-
authenticated: true,
|
|
621
|
-
scope: 'developer',
|
|
620
|
+
authenticated: scope !== null,
|
|
621
|
+
scope,
|
|
622
622
|
};
|
|
623
623
|
this.clients.set(id, client);
|
|
624
624
|
// Mimic the on-connect status frame real clients receive so the bridge
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm-update — updating a CLI that was installed with `npm install -g` (CAN2-57).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ npm IS THE ONLY WINDOWS INSTALL, AND IT NEVER UPDATED. The daemon's hourly updater re-runs the
|
|
5
|
+
* bash `install.sh`, so it served only `~/.beehaven/cli` installs; `beehaven update` refused npm
|
|
6
|
+
* installs and printed the npm command instead. A Windows user stayed on whatever version they
|
|
7
|
+
* first installed until they noticed the notice. Both paths now call this module.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ IT UPDATES THE COPY THAT IS RUNNING, OR NOTHING. The npm that sits beside the running Node is
|
|
10
|
+
* asked for its global root; if this package is not the one installed there (an npx run, a second
|
|
11
|
+
* Node, a copied folder), installing would create a SECOND copy that shadows or is shadowed by
|
|
12
|
+
* this one — the failure `self-update.ts` documents for curl installs. So it refuses and says why.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ A GLOBAL PREFIX THE USER CANNOT WRITE IS NOT "TRY sudo". Windows (`%AppData%\npm`) and
|
|
15
|
+
* Homebrew/nvm prefixes are user-writable; a system `/usr/local` usually is not, and an unattended
|
|
16
|
+
* daemon must never escalate. Not writable → the one-line manual command, nothing else.
|
|
17
|
+
*
|
|
18
|
+
* ⚠ THE EXACT VERSION, NOT `@latest`. The caller passes the version from the release manifest
|
|
19
|
+
* (`latest.json`, stable only), so the channel is decided by our publish script, never by a dist
|
|
20
|
+
* tag someone moved by hand.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile, execFileSync } from 'child_process';
|
|
23
|
+
import { accessSync, constants, existsSync, readFileSync, realpathSync } from 'fs';
|
|
24
|
+
import { homedir } from 'os';
|
|
25
|
+
import { dirname, join, resolve } from 'path';
|
|
26
|
+
export const PACKAGE = '@clearlysh/beehaven';
|
|
27
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
28
|
+
/** The npm that belongs to the Node running this process, else whatever `npm` is on PATH. */
|
|
29
|
+
export function npmCommand(platform = process.platform, execPath = process.execPath) {
|
|
30
|
+
const local = join(dirname(execPath), platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
31
|
+
return existsSync(local) ? local : platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
32
|
+
}
|
|
33
|
+
/** Windows `.cmd` shims cannot be spawned without a shell (Node refuses with EINVAL since 18.20/20.12). */
|
|
34
|
+
const needsShell = (cmd) => /\.cmd$/i.test(cmd);
|
|
35
|
+
const quoteForShell = (s) => (/[\s"&|<>^]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
|
|
36
|
+
/** Run npm synchronously; returns stdout. Never inside the package directory (Windows rename locks). */
|
|
37
|
+
function npmSync(npm, args, timeoutMs) {
|
|
38
|
+
const shell = needsShell(npm);
|
|
39
|
+
return execFileSync(shell ? quoteForShell(npm) : npm, args, {
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
cwd: homedir(),
|
|
42
|
+
timeout: timeoutMs,
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
44
|
+
shell,
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Can this install update itself with npm? `pkgRoot` is the running package's root (the folder
|
|
50
|
+
* holding its package.json).
|
|
51
|
+
*/
|
|
52
|
+
export function npmUpdatePlan(pkgRoot, platform = process.platform) {
|
|
53
|
+
const npm = npmCommand(platform);
|
|
54
|
+
let globalRoot;
|
|
55
|
+
try {
|
|
56
|
+
globalRoot = npmSync(npm, ['root', '-g'], 30_000).trim();
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
return { ok: false, npm, reason: `npm is not available (${String(e?.code ?? e?.message ?? e).slice(0, 80)})` };
|
|
60
|
+
}
|
|
61
|
+
const real = (p) => { try {
|
|
62
|
+
return realpathSync(p);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return resolve(p);
|
|
66
|
+
} };
|
|
67
|
+
const installed = join(globalRoot, ...PACKAGE.split('/'));
|
|
68
|
+
if (real(installed).toLowerCase() !== real(pkgRoot).toLowerCase()) {
|
|
69
|
+
return { ok: false, npm, globalRoot, reason: `this copy (${pkgRoot}) is not npm's global install (${installed})` };
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
accessSync(globalRoot, constants.W_OK);
|
|
73
|
+
accessSync(installed, constants.W_OK);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { ok: false, npm, globalRoot, reason: `npm's global folder is not writable by you (${globalRoot})` };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, npm, globalRoot };
|
|
79
|
+
}
|
|
80
|
+
/** The version npm's global install of this package reports, read from disk. */
|
|
81
|
+
export function installedNpmVersion(globalRoot) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(readFileSync(join(globalRoot, ...PACKAGE.split('/'), 'package.json'), 'utf8')).version ?? null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The newest published version, from npm (for `beehaven update`; the daemon uses the release manifest). */
|
|
90
|
+
export function latestNpmVersion(npm) {
|
|
91
|
+
try {
|
|
92
|
+
const v = npmSync(npm, ['view', PACKAGE, 'version', '--registry=https://registry.npmjs.org'], 30_000).trim();
|
|
93
|
+
return VERSION_RE.test(v) ? v : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The arguments that install exactly `version` globally — exported so the shape is pinned. */
|
|
100
|
+
export function npmInstallArgs(version) {
|
|
101
|
+
if (!VERSION_RE.test(version))
|
|
102
|
+
throw new Error(`not a release version: ${version}`);
|
|
103
|
+
return ['install', '-g', `${PACKAGE}@${version}`, '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org'];
|
|
104
|
+
}
|
|
105
|
+
/** Install `version` (blocking — the CLI's `update`). Verifies what landed on disk. */
|
|
106
|
+
export function installNpmVersionSync(plan, version) {
|
|
107
|
+
if (!plan.ok || !plan.globalRoot)
|
|
108
|
+
return { ok: false, message: plan.reason ?? 'npm update not possible here' };
|
|
109
|
+
try {
|
|
110
|
+
npmSync(plan.npm, npmInstallArgs(version), 5 * 60_000);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
const detail = String(e?.stderr || e?.message || e).trim().split('\n').slice(-3).join(' ').slice(0, 240);
|
|
114
|
+
return { ok: false, message: `npm install failed: ${detail}` };
|
|
115
|
+
}
|
|
116
|
+
const got = installedNpmVersion(plan.globalRoot);
|
|
117
|
+
return got === version
|
|
118
|
+
? { ok: true, message: `Updated to ${version}.` }
|
|
119
|
+
: { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` };
|
|
120
|
+
}
|
|
121
|
+
/** Install `version` without blocking the event loop (the daemon's hourly updater). */
|
|
122
|
+
export function installNpmVersion(plan, version) {
|
|
123
|
+
return new Promise((resolveP) => {
|
|
124
|
+
if (!plan.ok || !plan.globalRoot)
|
|
125
|
+
return resolveP({ ok: false, message: plan.reason ?? 'npm update not possible here' });
|
|
126
|
+
const shell = needsShell(plan.npm);
|
|
127
|
+
execFile(shell ? quoteForShell(plan.npm) : plan.npm, npmInstallArgs(version), {
|
|
128
|
+
cwd: homedir(), timeout: 5 * 60_000, shell, windowsHide: true,
|
|
129
|
+
}, (err, _out, stderr) => {
|
|
130
|
+
if (err)
|
|
131
|
+
return resolveP({ ok: false, message: `npm install failed: ${String(stderr || err.message).trim().split('\n').slice(-3).join(' ').slice(0, 240)}` });
|
|
132
|
+
const got = installedNpmVersion(plan.globalRoot);
|
|
133
|
+
resolveP(got === version
|
|
134
|
+
? { ok: true, message: `Updated to ${version}.` }
|
|
135
|
+
: { ok: false, message: `npm reported success but ${got ?? 'nothing'} is installed, not ${version}.` });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-binding — ONE AGENT SESSION = one identity + one workspace + one env (AWO-394).
|
|
3
|
+
*
|
|
4
|
+
* `agent login` used to scatter a session across three places with three different keys:
|
|
5
|
+
* · the identity — `agents/<name>.json`, shared by every terminal using that name
|
|
6
|
+
* · the work session id — a field on that SAME shared record, so a second terminal's login
|
|
7
|
+
* ended the first terminal's session and both then wrote under the new one
|
|
8
|
+
* · the cursor — `target-<name>.json`, so two terminals as one agent shared a workspace,
|
|
9
|
+
* and an agent that never connected inherited the daemon's machine-global focus
|
|
10
|
+
*
|
|
11
|
+
* Founder, 2026-10-24: "If agent A is working in workspace1 and agent B is working in workspace1
|
|
12
|
+
* and agent A connects to shopify DO, would that be isolated" — it was not, reliably. This file is
|
|
13
|
+
* the per-session record that makes it so: keyed on the PROCESS SCOPE (`deriveAgentScope()`: a
|
|
14
|
+
* Claude/Codex/Gemini session, a terminal tab, or an explicit `BEEHAVEN_AGENT`), written by
|
|
15
|
+
* `agent login`, moved only by `connect` in that same session, and read by every verb.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ A LEAF MODULE, LIKE `agent-scope.ts`. `agent-state` (identity) and `cli-state` (cursor) both
|
|
18
|
+
* read it, so it may import neither — that is the cycle `agent-scope.ts` documents.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ THE ENV IS PART OF THE SESSION. One daemon dials one relay; `beehaven env prod` in any
|
|
21
|
+
* terminal re-points every session on the machine. A session remembers the env it was opened on so
|
|
22
|
+
* a call after such a switch is refused with the fix, instead of landing in another environment's
|
|
23
|
+
* workspace under the same name.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { resolveRelaySelection } from './relay-presets.js';
|
|
29
|
+
const SESSIONS_DIR = join(homedir(), '.beehaven', 'sessions');
|
|
30
|
+
/** Filesystem key for a scope — the same sanitising rule the cursor file uses. */
|
|
31
|
+
export function scopeKey(scope) {
|
|
32
|
+
return String(scope || '').trim().replace(/[^A-Za-z0-9_-]/g, '');
|
|
33
|
+
}
|
|
34
|
+
function fileFor(scope) {
|
|
35
|
+
const key = scopeKey(scope);
|
|
36
|
+
return key ? join(SESSIONS_DIR, `${key}.json`) : null;
|
|
37
|
+
}
|
|
38
|
+
export function readSessionBinding(scope) {
|
|
39
|
+
try {
|
|
40
|
+
const file = fileFor(scope);
|
|
41
|
+
if (!file || !existsSync(file))
|
|
42
|
+
return null;
|
|
43
|
+
const b = JSON.parse(readFileSync(file, 'utf8'));
|
|
44
|
+
if (!b || typeof b.agent !== 'string' || !b.agent || typeof b.workspace !== 'string')
|
|
45
|
+
return null;
|
|
46
|
+
return { ...b, scope };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function writeSessionBinding(binding) {
|
|
53
|
+
const file = fileFor(binding.scope);
|
|
54
|
+
if (!file)
|
|
55
|
+
throw new Error('This process has no scope to bind a session to — set BEEHAVEN_AGENT=<name>.');
|
|
56
|
+
mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
57
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
58
|
+
writeFileSync(tmp, JSON.stringify(binding, null, 2), { mode: 0o600 });
|
|
59
|
+
renameSync(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
export function updateSessionBinding(scope, patch) {
|
|
62
|
+
const cur = readSessionBinding(scope);
|
|
63
|
+
if (!cur)
|
|
64
|
+
return null;
|
|
65
|
+
const next = { ...cur, ...patch, scope };
|
|
66
|
+
writeSessionBinding(next);
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
export function clearSessionBinding(scope) {
|
|
70
|
+
try {
|
|
71
|
+
const file = fileFor(scope);
|
|
72
|
+
if (file && existsSync(file))
|
|
73
|
+
unlinkSync(file);
|
|
74
|
+
}
|
|
75
|
+
catch { /* already gone */ }
|
|
76
|
+
}
|
|
77
|
+
/** Every session bound on this machine — `agent list` shows them, `logout --all` ends them. */
|
|
78
|
+
export function listSessionBindings() {
|
|
79
|
+
try {
|
|
80
|
+
if (!existsSync(SESSIONS_DIR))
|
|
81
|
+
return [];
|
|
82
|
+
return readdirSync(SESSIONS_DIR)
|
|
83
|
+
.filter((f) => f.endsWith('.json'))
|
|
84
|
+
.map((f) => readSessionBinding(f.slice(0, -5)))
|
|
85
|
+
.filter((b) => !!b);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The env this machine's daemon dials right now (`beehaven env`). */
|
|
92
|
+
export function currentEnvName() {
|
|
93
|
+
try {
|
|
94
|
+
const p = join(homedir(), '.beehaven', 'config.json');
|
|
95
|
+
const cfg = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {};
|
|
96
|
+
return resolveRelaySelection(cfg).envName;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return 'prod';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Why this session must not run here, or null. A session opened on staging is refused after
|
|
104
|
+
* someone switched the daemon to prod — the same workspace address names different data there.
|
|
105
|
+
*/
|
|
106
|
+
export function envMismatch(binding, now = currentEnvName()) {
|
|
107
|
+
if (!binding?.env || binding.env === now)
|
|
108
|
+
return null;
|
|
109
|
+
return `This session (${binding.agent}) was opened on ${binding.env}, but the daemon is now on ${now}.\n`
|
|
110
|
+
+ ` Switch back: beehaven env ${binding.env}\n`
|
|
111
|
+
+ ` Or start a ${now} session here: beehaven agent login ${binding.agent}`;
|
|
112
|
+
}
|
|
113
|
+
/** `workspace` / `team/<id>` / a bare 64-hex id → the address a cursor stores. */
|
|
114
|
+
export function normalizeWorkspaceAddress(input) {
|
|
115
|
+
const v = String(input || '').trim();
|
|
116
|
+
if (!v)
|
|
117
|
+
return null;
|
|
118
|
+
if (['home', 'me', 'workspace'].includes(v))
|
|
119
|
+
return 'workspace';
|
|
120
|
+
if (/^[0-9a-f]{64}$/i.test(v))
|
|
121
|
+
return `workspace/${v.toLowerCase()}`;
|
|
122
|
+
if (/^[A-Za-z][A-Za-z0-9]*\/[A-Za-z0-9._:-]{1,200}$/.test(v))
|
|
123
|
+
return v;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/** Human label for an address — `home`, `team bld_…`, `shopifyStore clearly-demo`. */
|
|
127
|
+
export function describeAddress(address) {
|
|
128
|
+
if (!address || ['workspace', 'home', 'me'].includes(address))
|
|
129
|
+
return 'home (your default workspace)';
|
|
130
|
+
const i = address.indexOf('/');
|
|
131
|
+
if (i < 0)
|
|
132
|
+
return address;
|
|
133
|
+
const id = address.slice(i + 1);
|
|
134
|
+
return `${address.slice(0, i)} ${id.length > 20 ? `${id.slice(0, 12)}…` : id}`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-target — THE ONE ANSWER TO "WHERE DOES THIS CALL LAND?" (CLR-1161, AWO-393, AWO-394).
|
|
3
|
+
*
|
|
4
|
+
* `call`, `actions`, `batch`, `explain`, `code-run` and `watch git` resolved the cursor here (it
|
|
5
|
+
* lived inline in cli.ts); the typed verbs — `ticket`, `sheet`, `slides`, `design`, `mention`,
|
|
6
|
+
* `trash` — went through `agentRpc`, which never asked. So an agent connected to a team workspace
|
|
7
|
+
* ran `beehaven call ticket-create` in the team and `beehaven ticket create` in its home, in one
|
|
8
|
+
* terminal, with no warning (AWO-393). Every verb now resolves through this function.
|
|
9
|
+
*
|
|
10
|
+
* ⚠⚠ AN AGENT SESSION NEVER READS OR MOVES THE DAEMON'S FOCUS. The daemon's `connectedBuilding` is
|
|
11
|
+
* one variable for the whole machine: any terminal's `connect` sets it and any `connect home`
|
|
12
|
+
* clears it. A human with no cursor file still inherits it (that is the long-standing behaviour a
|
|
13
|
+
* person can see and correct); an agent with no cursor is at HOME, full stop — that is what makes
|
|
14
|
+
* agent A connecting to a Shopify store invisible to agent B in the same workspace.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ THE SESSION'S ENV IS CHECKED HERE TOO, so every verb that routes also refuses a session opened
|
|
17
|
+
* on another environment (see `envMismatch`).
|
|
18
|
+
*/
|
|
19
|
+
import { getTarget, isHomeAddress } from './cli-state.js';
|
|
20
|
+
import { activeAgentName, activeSessionBinding } from './agent-state.js';
|
|
21
|
+
import { isAgentRuntime } from './agent-scope.js';
|
|
22
|
+
import { envMismatch } from './session-binding.js';
|
|
23
|
+
/** Is this invocation an agent's (a signed-in identity, or a harness that must sign in)? */
|
|
24
|
+
export function isAgentInvocation(values = {}) {
|
|
25
|
+
try {
|
|
26
|
+
return !!activeAgentName(values) || isAgentRuntime();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return isAgentRuntime();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Why this session must not run (another env), or null. */
|
|
33
|
+
export function sessionRefusal(values = {}) {
|
|
34
|
+
try {
|
|
35
|
+
return envMismatch(activeSessionBinding(activeAgentName(values)));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function resolveTargetBuilding(values) {
|
|
42
|
+
const refusal = sessionRefusal(values);
|
|
43
|
+
if (refusal) {
|
|
44
|
+
console.error(` ${refusal}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
if (values.building)
|
|
48
|
+
return; // an explicit flag always wins
|
|
49
|
+
const t = getTarget();
|
|
50
|
+
if (t?.address?.includes('/')) {
|
|
51
|
+
const [bType, ...bIdParts] = t.address.split('/');
|
|
52
|
+
values.building = `${bType}:${bIdParts.join('/')}`;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// ⚠ An agent: no file, or a home file, both mean HOME — never the machine-global focus, and an
|
|
56
|
+
// agent's call never clears the focus a person in another terminal set.
|
|
57
|
+
if (isAgentInvocation(values))
|
|
58
|
+
return;
|
|
59
|
+
if (t && isHomeAddress(t.address)) {
|
|
60
|
+
/**
|
|
61
|
+
* ⚠ HOME IS A POSITIVE STATEMENT, NOT AN ABSENCE. The daemon's focus is a routing
|
|
62
|
+
* optimisation; the file is where the user said to be. If they disagree the daemon is
|
|
63
|
+
* stale — drop it, so the call lands where `pwd` says it will, and SAY so.
|
|
64
|
+
*/
|
|
65
|
+
try {
|
|
66
|
+
const { daemonGetConnection, daemonDisconnect } = await import('./daemon-client.js');
|
|
67
|
+
const conn = await daemonGetConnection();
|
|
68
|
+
if (conn?.buildingId) {
|
|
69
|
+
await daemonDisconnect();
|
|
70
|
+
console.error(`\x1b[2m (dropped a stale daemon focus on ${conn.buildingType}/${conn.buildingId}`
|
|
71
|
+
+ ` — your target is home)\x1b[0m`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* no daemon, or it refused — home is the default route anyway */ }
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!t) {
|
|
78
|
+
/**
|
|
79
|
+
* ⚠ A PERSON WITH NO TARGET EVER SET inherits the daemon's connection — kept for back-compat
|
|
80
|
+
* with terminals that `connect` through the daemon and never wrote a file. Agents are handled
|
|
81
|
+
* above and never reach this.
|
|
82
|
+
*/
|
|
83
|
+
try {
|
|
84
|
+
const { daemonGetConnection } = await import('./daemon-client.js');
|
|
85
|
+
const conn = await daemonGetConnection();
|
|
86
|
+
if (conn)
|
|
87
|
+
values.building = `${conn.buildingType}:${conn.buildingId}`;
|
|
88
|
+
}
|
|
89
|
+
catch { /* fall through to home */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
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/verb-surface.js
CHANGED
|
@@ -60,6 +60,7 @@ export const VERB_SURFACE = {
|
|
|
60
60
|
logout: { cls: 'STRUCTURAL', why: 'clears local credentials' },
|
|
61
61
|
signup: { cls: 'STRUCTURAL', why: 'opens a browser' },
|
|
62
62
|
handoff: { cls: 'STRUCTURAL', why: 'posts a local file to the anonymous door; no account, no workspace' },
|
|
63
|
+
'agents-md': { cls: 'STRUCTURAL', why: "writes the Clearly block into this repo's AGENTS.md; no workspace" },
|
|
63
64
|
whoami: { cls: 'STRUCTURAL', why: 'reads local credentials' },
|
|
64
65
|
start: { cls: 'STRUCTURAL', why: 'spawns the daemon `call` needs' },
|
|
65
66
|
stop: { cls: 'STRUCTURAL', why: 'daemon lifecycle' },
|
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 */
|