beehaven 0.8.33 → 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/build-channel.js +2 -2
- package/dist/cli-state.js +45 -3
- package/dist/cli.js +56 -46
- package/dist/commands/agent-skills.js +12 -1
- package/dist/commands/agent.js +235 -28
- package/dist/commands/workspace.js +19 -3
- package/dist/session-binding.js +135 -0
- package/dist/session-target.js +91 -0
- package/package.json +2 -2
- package/skills/clearly-agent/SKILL.md +27 -10
- package/skills/clearly-canvas/authoring.md +4 -3
- package/skills/index.json +2 -2
|
@@ -62,6 +62,21 @@ export const BOOTSTRAP_ACTIONS = new Set([
|
|
|
62
62
|
// The workspace-scoped predecessors, kept so an older flow still bootstraps.
|
|
63
63
|
'agent-mint-token', 'agent-create', 'agent-list',
|
|
64
64
|
]);
|
|
65
|
+
/**
|
|
66
|
+
* ⚠⚠ SPT-13 — `agent-create` ESTABLISHES AN IDENTITY ONLY WHEN IT CREATES THE ONE SIGNED IN.
|
|
67
|
+
*
|
|
68
|
+
* Unwrapped, it runs as the account owner. That is right for the bootstrap it exists for — the
|
|
69
|
+
* signed-in agent creating ITSELF in a workspace that has no row for it yet. It was wrong for every
|
|
70
|
+
* other agent: a signed-in agent whose matrix says `agents.create = false` minted `ss41-probe`
|
|
71
|
+
* because the call left as its owner. Creating ANOTHER agent is ordinary work, so it is wrapped and
|
|
72
|
+
* the capability gate decides.
|
|
73
|
+
*/
|
|
74
|
+
function createsAnotherAgent(action, input, active) {
|
|
75
|
+
if (action !== 'agent-create' || !active || active.expired)
|
|
76
|
+
return false;
|
|
77
|
+
// Without an `id` the server mints a random one, so the new agent cannot be the signed-in one.
|
|
78
|
+
return String(input?.id ?? '').trim() !== active.agentId;
|
|
79
|
+
}
|
|
65
80
|
/**
|
|
66
81
|
* ⚠⚠ ACTIONS THE DAEMON ANSWERS ITSELF — THEY NEVER REACH A WORKSPACE, SO THEY CANNOT BE WRAPPED.
|
|
67
82
|
*
|
|
@@ -165,7 +180,7 @@ export function alreadyAttributed(action, input) {
|
|
|
165
180
|
export function activeAgentWrap(action, input, values) {
|
|
166
181
|
if (alreadyAttributed(action, input))
|
|
167
182
|
return { action, input };
|
|
168
|
-
if (
|
|
183
|
+
if (DAEMON_LOCAL_ACTIONS.has(action))
|
|
169
184
|
return { action, input };
|
|
170
185
|
let active = null;
|
|
171
186
|
try {
|
|
@@ -174,6 +189,8 @@ export function activeAgentWrap(action, input, values) {
|
|
|
174
189
|
catch {
|
|
175
190
|
return { action, input };
|
|
176
191
|
}
|
|
192
|
+
if (BOOTSTRAP_ACTIONS.has(action) && !createsAnotherAgent(action, input, active))
|
|
193
|
+
return { action, input };
|
|
177
194
|
if (!active || active.expired)
|
|
178
195
|
return { action, input };
|
|
179
196
|
return {
|
|
@@ -182,7 +199,8 @@ export function activeAgentWrap(action, input, values) {
|
|
|
182
199
|
agentId: active.agentId,
|
|
183
200
|
action,
|
|
184
201
|
input,
|
|
185
|
-
|
|
202
|
+
// This session's own work session (AWO-394), not the record every terminal shares.
|
|
203
|
+
...(active.sessionId ? { sessionId: active.sessionId } : {}),
|
|
186
204
|
// ⚠ WHICH DOOR — `cli:call` vs `cli:ticket` vs … See `cliDoor()` and
|
|
187
205
|
// `agent_activity.source`. Set here, at the ONE wrap every CLI path shares, so a new
|
|
188
206
|
// verb is labelled without its author having to know this exists.
|
package/dist/agent-scope.js
CHANGED
|
@@ -29,9 +29,43 @@
|
|
|
29
29
|
* `cd` is recorded.
|
|
30
30
|
*/
|
|
31
31
|
export function deriveAgentScope(env = process.env) {
|
|
32
|
+
/**
|
|
33
|
+
* ⚠⚠ A HARNESS SESSION ID OUTRANKS `BEEHAVEN_AGENT` (AWO-394). The env var names WHO — and
|
|
34
|
+
* `activeAgentName` still reads it first for that — but it is not a per-session handle: the
|
|
35
|
+
* daemon spawns every desktop Claude session with `BEEHAVEN_AGENT=<desktop principal>`, so they
|
|
36
|
+
* all derived ONE scope and shared one cursor and one work session. The session id is what
|
|
37
|
+
* tells two conversations of one agent apart.
|
|
38
|
+
*/
|
|
39
|
+
const harness = harnessScope(env);
|
|
40
|
+
if (harness && harness !== 'claude')
|
|
41
|
+
return harness;
|
|
32
42
|
const explicit = (env.BEEHAVEN_AGENT || '').trim();
|
|
33
43
|
if (explicit)
|
|
34
|
-
return explicit; // an explicit choice
|
|
44
|
+
return explicit; // an explicit choice, absent a session id
|
|
45
|
+
if (harness)
|
|
46
|
+
return harness;
|
|
47
|
+
/**
|
|
48
|
+
* ⚠ NO MARKER AND NO TTY IS STILL NOT A PERSON. Key on the PARENT pid — stable for the
|
|
49
|
+
* life of one process tree, different for a concurrent one. Not perfect (pids are reused
|
|
50
|
+
* eventually), but the failure mode of a collision here is two runs of the SAME tool,
|
|
51
|
+
* which is far milder than the shared-file failure it replaces: two DIFFERENT agents.
|
|
52
|
+
*/
|
|
53
|
+
if (!process.stdin.isTTY && !process.stdout.isTTY)
|
|
54
|
+
return `pid-${process.ppid}`;
|
|
55
|
+
/**
|
|
56
|
+
* ⚠ A TERMINAL TAB IS ITS OWN SCOPE WHEN THE TERMINAL SAYS WHICH TAB (AWO-394). Every human
|
|
57
|
+
* terminal used to derive `''`, so one `agent login` in any tab made EVERY tab that agent — and
|
|
58
|
+
* the login's own advice ("other terminals: export BEEHAVEN_AGENT") was moot. These ids are set
|
|
59
|
+
* per tab/pane by the terminal itself. Without one, `''` keeps the old shared behaviour.
|
|
60
|
+
*
|
|
61
|
+
* ⚠ A HUMAN WHO HAS NOT LOGGED IN AS AN AGENT STILL SHARES `target.json` — `cli-state`
|
|
62
|
+
* maps an unbound `term-*` scope back to the shared cursor, so nobody's `cd` moves.
|
|
63
|
+
*/
|
|
64
|
+
const tab = terminalTabId(env);
|
|
65
|
+
return tab ? `term-${tab}` : ''; // a human at a terminal
|
|
66
|
+
}
|
|
67
|
+
/** The harness session scope (`claude-…`, `codex-…`, `gemini-…`), or '' — see `deriveAgentScope`. */
|
|
68
|
+
function harnessScope(env) {
|
|
35
69
|
// Claude Code — its own marker (`daemon-omni.ts` already relies on it: "the SDK refuses to
|
|
36
70
|
// start when CLAUDECODE=1 is set … it's how it refuses to recurse"), plus a per-session id.
|
|
37
71
|
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) {
|
|
@@ -45,15 +79,26 @@ export function deriveAgentScope(env = process.env) {
|
|
|
45
79
|
if (env.GEMINI_CLI_SESSION_ID) {
|
|
46
80
|
return `gemini-${String(env.GEMINI_CLI_SESSION_ID).replace(/[^A-Za-z0-9]/g, '').slice(0, 12)}`;
|
|
47
81
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Which terminal tab or pane this is, from the id the terminal itself exports — or ''.
|
|
86
|
+
* macOS Terminal / iTerm2 (`TERM_SESSION_ID`, `ITERM_SESSION_ID`), Windows Terminal, tmux, kitty,
|
|
87
|
+
* WezTerm. Hashed to a short token: some carry paths or GUIDs, and it lands in a filename.
|
|
88
|
+
*/
|
|
89
|
+
export function terminalTabId(env = process.env) {
|
|
90
|
+
const raw = env.TMUX_PANE && env.TMUX ? `tmux${env.TMUX}${env.TMUX_PANE}`
|
|
91
|
+
: env.ITERM_SESSION_ID || env.TERM_SESSION_ID || env.WT_SESSION
|
|
92
|
+
|| (env.KITTY_WINDOW_ID ? `kitty${env.KITTY_WINDOW_ID}` : '')
|
|
93
|
+
|| (env.WEZTERM_PANE ? `wezterm${env.WEZTERM_PANE}` : '');
|
|
94
|
+
if (!raw)
|
|
95
|
+
return '';
|
|
96
|
+
let h = 2166136261; // FNV-1a — stable, dependency-free
|
|
97
|
+
for (let i = 0; i < raw.length; i++) {
|
|
98
|
+
h ^= raw.charCodeAt(i);
|
|
99
|
+
h = Math.imul(h, 16777619);
|
|
100
|
+
}
|
|
101
|
+
return (h >>> 0).toString(36);
|
|
57
102
|
}
|
|
58
103
|
export function principalKind(input) {
|
|
59
104
|
const now = input.now ?? Date.now();
|
package/dist/agent-state.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* (`agent:mint`) and the CLI just stores the resulting record.
|
|
12
12
|
*/
|
|
13
13
|
import { deriveAgentScope } from './agent-scope.js';
|
|
14
|
+
import { readSessionBinding } from './session-binding.js';
|
|
14
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, chmodSync, } from 'node:fs';
|
|
15
16
|
import { homedir } from 'node:os';
|
|
16
17
|
import { join } from 'node:path';
|
|
@@ -230,6 +231,17 @@ export function bindAgentScope(name, scope, opts = {}) {
|
|
|
230
231
|
}
|
|
231
232
|
catch { /* binding is a convenience; a failed write must not fail the login */ }
|
|
232
233
|
}
|
|
234
|
+
/** Forget that `scope` was logged in as `name` — `agent logout` in that session (AWO-394). */
|
|
235
|
+
export function unbindAgentScope(name, scope) {
|
|
236
|
+
try {
|
|
237
|
+
const rec = loadAgent(name);
|
|
238
|
+
const prev = Array.isArray(rec?.boundScopes) ? rec.boundScopes : [];
|
|
239
|
+
if (!rec || !prev.includes(scope))
|
|
240
|
+
return;
|
|
241
|
+
saveAgent(name, { ...rec, boundScopes: prev.filter((s) => s !== scope) });
|
|
242
|
+
}
|
|
243
|
+
catch { /* best-effort */ }
|
|
244
|
+
}
|
|
233
245
|
/**
|
|
234
246
|
* The stable half of a process scope — the TOOL rather than the conversation.
|
|
235
247
|
*
|
|
@@ -240,6 +252,9 @@ export function bindAgentScope(name, scope, opts = {}) {
|
|
|
240
252
|
export function scopeFamilyKey(scope) {
|
|
241
253
|
if (!scope)
|
|
242
254
|
return 'family:terminal';
|
|
255
|
+
// A terminal TAB (AWO-394) inherits nothing: one tab's login must not become another tab's identity.
|
|
256
|
+
if (scope.startsWith('term-'))
|
|
257
|
+
return null;
|
|
243
258
|
const m = /^(claude|codex|gemini)-/.exec(scope);
|
|
244
259
|
if (m)
|
|
245
260
|
return `family:${m[1]}`;
|
|
@@ -254,6 +269,14 @@ export function scopeFamilyKey(scope) {
|
|
|
254
269
|
*/
|
|
255
270
|
export function agentForScope(scope = '') {
|
|
256
271
|
try {
|
|
272
|
+
/**
|
|
273
|
+
* ⚠ THE SESSION BINDING WINS (AWO-394). `agent login` writes `sessions/<scope>.json`, and that
|
|
274
|
+
* exact record — not a list on a record shared by every terminal using the name — is what this
|
|
275
|
+
* session is. `boundScopes` below stays for sessions bound before it existed and for the family.
|
|
276
|
+
*/
|
|
277
|
+
const session = scope ? readSessionBinding(scope) : null;
|
|
278
|
+
if (session && loadAgent(session.agent))
|
|
279
|
+
return session.agent;
|
|
257
280
|
const all = listAgents();
|
|
258
281
|
const boundTo = (key) => all
|
|
259
282
|
.filter(({ record }) => Array.isArray(record.boundScopes)
|
|
@@ -369,6 +392,18 @@ export function activeAgentName(values) {
|
|
|
369
392
|
return null;
|
|
370
393
|
}
|
|
371
394
|
}
|
|
395
|
+
/** The session binding of THIS process, when it belongs to `name` (or to whoever is active). */
|
|
396
|
+
export function activeSessionBinding(name) {
|
|
397
|
+
try {
|
|
398
|
+
const b = readSessionBinding(deriveAgentScope());
|
|
399
|
+
if (!b)
|
|
400
|
+
return null;
|
|
401
|
+
return !name || b.agent === name ? b : null;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
372
407
|
/**
|
|
373
408
|
* Resolve the active agent identity for this invocation, or null when no
|
|
374
409
|
* agent is selected. Used by the call path to attach the token + route the
|
|
@@ -381,6 +416,13 @@ export function resolveActiveAgent(values) {
|
|
|
381
416
|
const record = loadAgent(name);
|
|
382
417
|
if (!record)
|
|
383
418
|
return null;
|
|
419
|
+
const session = activeSessionBinding(name);
|
|
420
|
+
/**
|
|
421
|
+
* ⚠ THE SESSION'S OWN WORK SESSION, NOT THE SHARED RECORD'S (AWO-394). `record.sessionId` is one
|
|
422
|
+
* field for every terminal using this name, so a second terminal's login replaced the first's and
|
|
423
|
+
* both then wrote under it. A session bound by `agent login` carries its own.
|
|
424
|
+
*/
|
|
425
|
+
const sessionId = session ? session.sessionId : record.sessionId;
|
|
384
426
|
return {
|
|
385
427
|
name,
|
|
386
428
|
token: record.token,
|
|
@@ -389,6 +431,8 @@ export function resolveActiveAgent(values) {
|
|
|
389
431
|
avatarId: record.avatarId,
|
|
390
432
|
record,
|
|
391
433
|
expired: typeof record.expiresAt === 'number' && record.expiresAt <= Date.now(),
|
|
434
|
+
...(sessionId ? { sessionId } : {}),
|
|
435
|
+
...(session ? { session } : {}),
|
|
392
436
|
};
|
|
393
437
|
}
|
|
394
438
|
/**
|
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 = '0fc3f190acdb';
|
|
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-27T20:29Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
package/dist/cli-state.js
CHANGED
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
* target is what stops the inheritance. Pin it, or pass `--building` per call.
|
|
42
42
|
*/
|
|
43
43
|
import { deriveAgentScope } from './agent-scope.js';
|
|
44
|
-
import { agentForScope } from './agent-state.js';
|
|
44
|
+
import { agentForScope, loadAgent } from './agent-state.js';
|
|
45
|
+
import { readSessionBinding, scopeKey } from './session-binding.js';
|
|
45
46
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from 'fs';
|
|
46
47
|
import { join } from 'path';
|
|
47
48
|
import { homedir } from 'os';
|
|
@@ -92,14 +93,55 @@ export function effectiveScope() {
|
|
|
92
93
|
const raw = deriveAgentScope();
|
|
93
94
|
if (!raw)
|
|
94
95
|
return ''; // a human — the shared file
|
|
96
|
+
/**
|
|
97
|
+
* ⚠⚠ A SESSION BOUND BY `agent login` HAS ITS OWN CURSOR (AWO-394) — keyed on the session, not
|
|
98
|
+
* the agent name. Keyed on the name, two terminals logged in as one agent shared a workspace:
|
|
99
|
+
* one `connect` moved both. The workspace a NEW session starts in is chosen at login (the
|
|
100
|
+
* identity's last workspace, or `--workspace`), which keeps what the name key was for.
|
|
101
|
+
*/
|
|
102
|
+
if (readSessionBinding(raw))
|
|
103
|
+
return raw;
|
|
95
104
|
try {
|
|
105
|
+
// A session bound before session bindings existed keeps its agent-keyed cursor until it logs in again.
|
|
96
106
|
const bound = agentForScope(raw);
|
|
97
|
-
if (bound)
|
|
98
|
-
|
|
107
|
+
if (bound && !raw.startsWith('term-')) {
|
|
108
|
+
// …but only an EXACT binding. A session that merely inherited the family's identity starts
|
|
109
|
+
// at home in its own cursor — it must not inherit someone else's workspace with the name.
|
|
110
|
+
const rec = loadAgent(bound);
|
|
111
|
+
if (Array.isArray(rec?.boundScopes) && rec.boundScopes.includes(raw))
|
|
112
|
+
return bound;
|
|
113
|
+
}
|
|
99
114
|
}
|
|
100
115
|
catch { /* no agents dir, or a bad record — fall back to the process scope */ }
|
|
116
|
+
// A terminal tab nobody logged in from is the human: the shared cursor, exactly as before.
|
|
117
|
+
if (raw.startsWith('term-'))
|
|
118
|
+
return '';
|
|
101
119
|
return raw;
|
|
102
120
|
}
|
|
121
|
+
/** The cursor file of a given session scope — `agent login` seeds a NEW session's cursor here. */
|
|
122
|
+
export function writeTargetForScope(scope, address, kind) {
|
|
123
|
+
const key = scopeKey(scope);
|
|
124
|
+
if (!key)
|
|
125
|
+
return;
|
|
126
|
+
ensureStateDir();
|
|
127
|
+
const file = join(STATE_DIR, `target-${key}.json`);
|
|
128
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
129
|
+
writeFileSync(tmp, JSON.stringify({ address, connectedAt: Date.now(), kind: kind ?? inferKind(address) }, null, 2), { mode: 0o600 });
|
|
130
|
+
renameSync(tmp, file);
|
|
131
|
+
}
|
|
132
|
+
/** Read the cursor of a given scope without making it this process's (the legacy agent-keyed file). */
|
|
133
|
+
export function readTargetForKey(key) {
|
|
134
|
+
try {
|
|
135
|
+
const safe = scopeKey(key);
|
|
136
|
+
if (!safe)
|
|
137
|
+
return null;
|
|
138
|
+
const parsed = JSON.parse(readFileSync(join(STATE_DIR, `target-${safe}.json`), 'utf-8'));
|
|
139
|
+
return typeof parsed?.address === 'string' && parsed.address ? parsed : null;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
103
145
|
function targetFileFor(agent = effectiveScope()) {
|
|
104
146
|
const safe = (agent || '').trim().replace(/[^A-Za-z0-9_-]/g, '');
|
|
105
147
|
return join(STATE_DIR, safe ? `target-${safe}.json` : 'target.json');
|
package/dist/cli.js
CHANGED
|
@@ -849,48 +849,9 @@ async function printStatus() {
|
|
|
849
849
|
* One function, because four hand-written copies is how one missing `else` became four.
|
|
850
850
|
*/
|
|
851
851
|
async function resolveTargetBuilding(values) {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const t = getTarget();
|
|
856
|
-
if (t?.address?.includes('/')) {
|
|
857
|
-
const [bType, ...bIdParts] = t.address.split('/');
|
|
858
|
-
values.building = `${bType}:${bIdParts.join('/')}`;
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
if (t && isHomeAddress(t.address)) {
|
|
862
|
-
/**
|
|
863
|
-
* ⚠ HOME IS A POSITIVE STATEMENT, NOT AN ABSENCE. The daemon's focus is a routing
|
|
864
|
-
* optimisation; the file is where the user said to be. If they disagree the daemon is
|
|
865
|
-
* stale — drop it, so the call lands where `pwd` says it will, and SAY so.
|
|
866
|
-
*/
|
|
867
|
-
try {
|
|
868
|
-
const { daemonGetConnection, daemonDisconnect } = await import('./daemon-client.js');
|
|
869
|
-
const conn = await daemonGetConnection();
|
|
870
|
-
if (conn?.buildingId) {
|
|
871
|
-
await daemonDisconnect();
|
|
872
|
-
console.error(`\x1b[2m (dropped a stale daemon focus on ${conn.buildingType}/${conn.buildingId}`
|
|
873
|
-
+ ` — your target is home)\x1b[0m`);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
catch { /* no daemon, or it refused — home is the default route anyway */ }
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
if (!t) {
|
|
880
|
-
/**
|
|
881
|
-
* ⚠ NO TARGET EVER SET — inherit the daemon's connection. The machine-global fallback
|
|
882
|
-
* `cli-state.ts` warns about: another room, the Mac app or a human's terminal can set
|
|
883
|
-
* it, so an agent that never runs `connect` inherits someone else's cursor. Kept for
|
|
884
|
-
* back-compat; `connect home` is what opts out, and this fix makes that opt-out real.
|
|
885
|
-
*/
|
|
886
|
-
try {
|
|
887
|
-
const { daemonGetConnection } = await import('./daemon-client.js');
|
|
888
|
-
const conn = await daemonGetConnection();
|
|
889
|
-
if (conn)
|
|
890
|
-
values.building = `${conn.buildingType}:${conn.buildingId}`;
|
|
891
|
-
}
|
|
892
|
-
catch { /* fall through to home */ }
|
|
893
|
-
}
|
|
852
|
+
// ⚠ ONE resolver for every verb, typed or raw (AWO-393/394) — see session-target.ts.
|
|
853
|
+
const { resolveTargetBuilding: resolve } = await import('./session-target.js');
|
|
854
|
+
await resolve(values);
|
|
894
855
|
}
|
|
895
856
|
/**
|
|
896
857
|
* ⚠⚠ AN IDENTIFIABLE AGENT MUST LOG IN BEFORE IT ACTS.
|
|
@@ -1103,8 +1064,10 @@ async function requireAgentSession(command, values) {
|
|
|
1103
1064
|
cfg = {};
|
|
1104
1065
|
}
|
|
1105
1066
|
// ⚠ ONE DECISION, EXECUTED BY A GUARD (`principalKind`). This branch does nothing but act on it.
|
|
1067
|
+
const { activeSessionBinding: sessionForGate } = await import('./agent-state.js');
|
|
1106
1068
|
const who = principalKind({
|
|
1107
|
-
|
|
1069
|
+
// This session's own work session first (AWO-394); the shared record's for sessions bound before it.
|
|
1070
|
+
agentSessionId: sessionForGate(name)?.sessionId ?? rec?.sessionId,
|
|
1108
1071
|
agentExpiresAt: rec?.expiresAt,
|
|
1109
1072
|
agentRuntime: isAgentRuntime(),
|
|
1110
1073
|
accountUserId: cfg.userId,
|
|
@@ -2237,6 +2200,14 @@ async function run() {
|
|
|
2237
2200
|
// workspace via daemonRpc('shell:exec', …) — see
|
|
2238
2201
|
// commands/shell.ts:resolveDefaultBuilding + execRemote.
|
|
2239
2202
|
const { HOME_ADDRESSES: HOME_ADDRS, CANONICAL_HOME_ADDRESS } = await import('./cli-state.js');
|
|
2203
|
+
/**
|
|
2204
|
+
* ⚠⚠ A SIGNED-IN SESSION MOVES ONLY ITSELF (AWO-394). The daemon's focus is ONE variable for
|
|
2205
|
+
* the machine, so `connect` used to re-point every other terminal that had no cursor of its
|
|
2206
|
+
* own — agent A connecting to a Shopify store moved agent B. A session's own cursor file is
|
|
2207
|
+
* the whole of its routing; the daemon is not told.
|
|
2208
|
+
*/
|
|
2209
|
+
const { activeSessionBinding: sessionOf } = await import('./agent-state.js');
|
|
2210
|
+
const ownSession = sessionOf();
|
|
2240
2211
|
if (HOME_ADDRS.has(target)) {
|
|
2241
2212
|
const { setTarget } = await import('./cli-state.js');
|
|
2242
2213
|
// Canonical label is 'workspace' (no "home DO" — the daemon's relay is to the
|
|
@@ -2247,7 +2218,11 @@ async function run() {
|
|
|
2247
2218
|
setTarget(CANONICAL_HOME_ADDRESS, 'cloud');
|
|
2248
2219
|
// Best-effort daemon-side cleanup of any prior connect()
|
|
2249
2220
|
// focus — we want home to be the default routing target.
|
|
2250
|
-
if (
|
|
2221
|
+
if (ownSession) {
|
|
2222
|
+
const { moveAgentSession } = await import('./commands/agent.js');
|
|
2223
|
+
await moveAgentSession(CANONICAL_HOME_ADDRESS);
|
|
2224
|
+
}
|
|
2225
|
+
else if (daemonUp) {
|
|
2251
2226
|
try {
|
|
2252
2227
|
await daemonDisconnect();
|
|
2253
2228
|
}
|
|
@@ -2290,6 +2265,15 @@ async function run() {
|
|
|
2290
2265
|
// where we meant to be, and we can retry without a re-`cd`.
|
|
2291
2266
|
const { setTarget } = await import('./cli-state.js');
|
|
2292
2267
|
setTarget(`${bType}/${bId}`, 'cloud');
|
|
2268
|
+
if (ownSession) {
|
|
2269
|
+
const { moveAgentSession } = await import('./commands/agent.js');
|
|
2270
|
+
await moveAgentSession(`${bType}/${bId}`);
|
|
2271
|
+
if (bType === 'shopifyStore' || bType === 'store') {
|
|
2272
|
+
console.log(color.dim(` Store DOs take operator calls through: beehaven admin do ${bType}/${bId} <action> '<json>' (platform admins)`));
|
|
2273
|
+
}
|
|
2274
|
+
console.log(` ${color.green('✓')} This session → ${color.green(`${bType}/${bId}`)}`);
|
|
2275
|
+
break;
|
|
2276
|
+
}
|
|
2293
2277
|
try {
|
|
2294
2278
|
await daemonConnect(bType, bId);
|
|
2295
2279
|
}
|
|
@@ -2399,7 +2383,13 @@ async function run() {
|
|
|
2399
2383
|
const { clearTarget: clearDisconn } = await import('./cli-state.js');
|
|
2400
2384
|
clearDisconn();
|
|
2401
2385
|
const { isDaemonRunning: isDaemonRunningDisc, daemonDisconnect: daemonDisconnectDisc } = await import('./daemon-client.js');
|
|
2402
|
-
|
|
2386
|
+
const { activeSessionBinding: sessionOfDisc } = await import('./agent-state.js');
|
|
2387
|
+
if (sessionOfDisc()) {
|
|
2388
|
+
// AWO-394 — a signed-in session goes home alone; the machine-wide focus is someone else's.
|
|
2389
|
+
const { moveAgentSession } = await import('./commands/agent.js');
|
|
2390
|
+
await moveAgentSession('workspace');
|
|
2391
|
+
}
|
|
2392
|
+
else if (await isDaemonRunningDisc()) {
|
|
2403
2393
|
try {
|
|
2404
2394
|
await daemonDisconnectDisc();
|
|
2405
2395
|
}
|
|
@@ -2457,7 +2447,22 @@ async function run() {
|
|
|
2457
2447
|
// ⚠ `values` HERE TOO — `beehaven status --agent alice` must report who that call would
|
|
2458
2448
|
// run as, not who the shell happens to be bound to. See `requireAgentSession`.
|
|
2459
2449
|
const who = activeAgentName(values);
|
|
2460
|
-
|
|
2450
|
+
const { activeSessionBinding } = await import('./agent-state.js');
|
|
2451
|
+
const { describeAddress, currentEnvName } = await import('./session-binding.js');
|
|
2452
|
+
const bound = activeSessionBinding(who);
|
|
2453
|
+
if (who && bound) {
|
|
2454
|
+
// AWO-394 — one session: identity + workspace + env, owned by this scope.
|
|
2455
|
+
const { loadAgent } = await import('./agent-state.js');
|
|
2456
|
+
const r = loadAgent(who);
|
|
2457
|
+
const gone = typeof r?.expiresAt === 'number' && r.expiresAt <= Date.now();
|
|
2458
|
+
const envNow = currentEnvName();
|
|
2459
|
+
console.log(gone
|
|
2460
|
+
? ` Agent: ${color.green(who)} ${'\x1b[31m'}TOKEN EXPIRED\x1b[0m${color.dim(' — calls are refused until `beehaven agent login ' + who + '`')}`
|
|
2461
|
+
: ` Agent: ${color.green(who)}${color.dim(' (this session — every call here runs as this agent)')}`);
|
|
2462
|
+
console.log(` Session: ${color.green(bound.scope)}${color.dim(` work session ${bound.sessionId ? bound.sessionId.slice(0, 8) : 'none'} · in ${describeAddress(bound.workspace)}`)}`);
|
|
2463
|
+
console.log(` Env: ${bound.env === envNow ? color.green(bound.env) : `\x1b[31m${bound.env} — daemon is on ${envNow}; calls are refused\x1b[0m`}`);
|
|
2464
|
+
}
|
|
2465
|
+
else if (who && eff === who) {
|
|
2461
2466
|
// Identity, workspace session and cursor are one handle.
|
|
2462
2467
|
const { loadAgent } = await import('./agent-state.js');
|
|
2463
2468
|
const r = loadAgent(who);
|
|
@@ -2472,6 +2477,11 @@ async function run() {
|
|
|
2472
2477
|
: ` Agent: ${color.green(who)}${color.dim(' (logged in — calls run as this agent)')}`);
|
|
2473
2478
|
console.log(` Scope: ${color.green(eff)}${color.dim(' (cursor follows the agent, not the session)')}`);
|
|
2474
2479
|
}
|
|
2480
|
+
else if (raw.startsWith('term-')) {
|
|
2481
|
+
// AWO-394 — a tab nobody signed in from is the person, on the shared cursor.
|
|
2482
|
+
console.log(` Scope: ${color.green(raw)}${color.dim(' (this tab — `beehaven agent login <name>` here signs in THIS tab only)')}`);
|
|
2483
|
+
console.log(` Agent: ${color.dim('none — calls run as you, on the cursor your other terminals share')}`);
|
|
2484
|
+
}
|
|
2475
2485
|
else if (raw) {
|
|
2476
2486
|
console.log(` Scope: ${color.green(raw)}${color.dim(' (private cursor — concurrent agents cannot clobber it)')}`);
|
|
2477
2487
|
console.log(` Agent: ${color.dim('none — calls run as you. `beehaven agent login <name>` to act as an agent')}`);
|
|
@@ -236,14 +236,25 @@ export function installAgentSkills() {
|
|
|
236
236
|
* ⚠ NO STAMP ⇒ TREATED AS UNTOUCHED. Every skill installed before stamping existed has
|
|
237
237
|
* none, and assuming an edit there would freeze them permanently — updates would silently
|
|
238
238
|
* stop landing, which is the failure mode this whole change exists to remove.
|
|
239
|
+
*
|
|
240
|
+
* ⚠ IDENTICAL TO WHAT SHIPS ⇒ CURRENT, WHATEVER THE STAMP SAYS (PPO-476). The Mac app writes
|
|
241
|
+
* these same files on every launch, and a copy can only equal the release by coming from
|
|
242
|
+
* it. Asked first, a stale stamp called every skill "edited locally" and none was ever
|
|
243
|
+
* re-stamped, so the next real update never landed either.
|
|
239
244
|
*/
|
|
240
245
|
if (existing) {
|
|
246
|
+
const local = localSha(destDir, skillFiles);
|
|
247
|
+
if (entry?.sha && local === entry.sha) {
|
|
248
|
+
stamp(destDir, skillFiles, warnings);
|
|
249
|
+
steps.push(`${agent}/${name}=current`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
241
252
|
let stamped = '';
|
|
242
253
|
try {
|
|
243
254
|
stamped = JSON.parse(fs.readFileSync(path.join(destDir, STAMP), 'utf8')).sha ?? '';
|
|
244
255
|
}
|
|
245
256
|
catch { /* unstamped */ }
|
|
246
|
-
if (stamped &&
|
|
257
|
+
if (stamped && local !== stamped) {
|
|
247
258
|
warnings.push(`${agent}/${name}: edited locally — left alone (delete it to take the shipped copy)`);
|
|
248
259
|
steps.push(`${agent}/${name}=locally-modified`);
|
|
249
260
|
continue;
|
package/dist/commands/agent.js
CHANGED
|
@@ -91,6 +91,60 @@ async function endWorkspaceSession(record, summary) {
|
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/** `team/<id>` → the `--building` form `resolveBuilding` takes; home → undefined. */
|
|
95
|
+
function buildingFor(address) {
|
|
96
|
+
if (!address || !address.includes('/'))
|
|
97
|
+
return undefined;
|
|
98
|
+
const i = address.indexOf('/');
|
|
99
|
+
return `${address.slice(0, i)}:${address.slice(i + 1)}`;
|
|
100
|
+
}
|
|
101
|
+
/** Workspace DOs hold agent work sessions; a store or a local app does not. */
|
|
102
|
+
function holdsWorkSessions(address) {
|
|
103
|
+
return !address.includes('/') || /^(team|workspace)\//.test(address);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Run an action in a given workspace (AWO-394) — the session lives in the workspace it works in,
|
|
107
|
+
* not in whichever one the daemon happens to dial. Home goes direct; others through the proxy.
|
|
108
|
+
*/
|
|
109
|
+
async function rpcIn(address, action, input, timeout) {
|
|
110
|
+
const { resolveBuilding } = await import('./workspace.js');
|
|
111
|
+
const routed = resolveBuilding(action, input, buildingFor(address));
|
|
112
|
+
return rpcOnce(routed.action, routed.input, timeout);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* "hive2 (workspace/98bef9…)" rather than a bare id — what an agent reads to know where it is.
|
|
116
|
+
* Best-effort: the account roster (`workspace:list`), falling back to the address itself.
|
|
117
|
+
*/
|
|
118
|
+
async function workspaceLabel(address) {
|
|
119
|
+
const { describeAddress } = await import('../session-binding.js');
|
|
120
|
+
const plain = describeAddress(address);
|
|
121
|
+
if (address.includes('/') && !/^(team|workspace)\//.test(address))
|
|
122
|
+
return plain;
|
|
123
|
+
try {
|
|
124
|
+
const { daemonRpc } = await import('../daemon-client.js');
|
|
125
|
+
const res = await Promise.race([daemonRpc('workspace:list', {}), new Promise((r) => setTimeout(() => r(null), 4_000))]);
|
|
126
|
+
const inner = res?.result ?? res;
|
|
127
|
+
const id = address.includes('/') ? address.slice(address.indexOf('/') + 1) : String(inner?.current ?? '');
|
|
128
|
+
const hit = (inner?.workspaces ?? []).find((w) => String(w?.id ?? '') === id);
|
|
129
|
+
if (hit?.name)
|
|
130
|
+
return address.includes('/') ? `${hit.name} (${plain})` : `${hit.name} — home (your default workspace)`;
|
|
131
|
+
}
|
|
132
|
+
catch { /* the roster is a nicety */ }
|
|
133
|
+
return plain;
|
|
134
|
+
}
|
|
135
|
+
/** End one work session where it lives. Best-effort, like `endWorkspaceSession`. */
|
|
136
|
+
async function endSessionIn(address, sessionId, agentId, summary) {
|
|
137
|
+
try {
|
|
138
|
+
const r = await rpcIn(address, 'agent-session-end', {
|
|
139
|
+
id: sessionId, agentId, ...(summary?.trim() ? { summary: summary.trim() } : {}),
|
|
140
|
+
}, 5_000);
|
|
141
|
+
const result = r?.result ?? r;
|
|
142
|
+
return result?.ok !== false && !result?.error;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
94
148
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
95
149
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
96
150
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -701,7 +755,38 @@ async function agentLogin(args, values) {
|
|
|
701
755
|
* actually began. `existingRecord` is read before the write for exactly this.
|
|
702
756
|
*/
|
|
703
757
|
const birthday = existingRecord?.birthday ?? new Date().toISOString();
|
|
704
|
-
|
|
758
|
+
/**
|
|
759
|
+
* ⚠⚠ ONE SESSION = THIS PROCESS SCOPE (AWO-394). A re-login replaces the session THIS scope had,
|
|
760
|
+
* and nothing else: it used to end `record.sessionId` — one field shared by every terminal using
|
|
761
|
+
* the name — so logging in from a second terminal closed the first terminal's session and both
|
|
762
|
+
* then wrote under the new one.
|
|
763
|
+
*/
|
|
764
|
+
const { deriveAgentScope } = await import('../agent-scope.js');
|
|
765
|
+
const { readSessionBinding, writeSessionBinding, currentEnvName, normalizeWorkspaceAddress, describeAddress, } = await import('../session-binding.js');
|
|
766
|
+
const { writeTargetForScope, readTargetForKey } = await import('../cli-state.js');
|
|
767
|
+
const scope = deriveAgentScope();
|
|
768
|
+
const priorBinding = scope ? readSessionBinding(scope) : null;
|
|
769
|
+
if (priorBinding?.sessionId)
|
|
770
|
+
await endSessionIn(priorBinding.workspace, priorBinding.sessionId, minted.agentId);
|
|
771
|
+
/**
|
|
772
|
+
* WHERE THIS SESSION WORKS: `--workspace`, else where this identity last worked, else home. The
|
|
773
|
+
* identity's last workspace is what keeps a durable agent from losing its place each conversation
|
|
774
|
+
* — the reason the cursor used to be keyed on the agent name — without letting one live session
|
|
775
|
+
* move another.
|
|
776
|
+
*/
|
|
777
|
+
const requestedWorkspace = typeof values.workspace === 'string' ? values.workspace.trim() : '';
|
|
778
|
+
const normalizedRequest = requestedWorkspace ? normalizeWorkspaceAddress(requestedWorkspace) : null;
|
|
779
|
+
if (requestedWorkspace && !normalizedRequest) {
|
|
780
|
+
console.error(` Unrecognised workspace "${requestedWorkspace}".`);
|
|
781
|
+
console.error(' Use: home · team/<id> · workspace/<id> · a 64-character workspace id (beehaven workspaces lists them)');
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
const legacyCursor = readTargetForKey(name)?.address;
|
|
785
|
+
const chosenWorkspace = normalizedRequest
|
|
786
|
+
|| existingRecord?.lastWorkspace
|
|
787
|
+
|| (legacyCursor ? normalizeWorkspaceAddress(legacyCursor) : null)
|
|
788
|
+
|| 'workspace';
|
|
789
|
+
const sessionEnv = currentEnvName();
|
|
705
790
|
/**
|
|
706
791
|
* ⚠⚠ FILL THE LIFETIME FIELDS FROM THE TOKEN — the cloud mint path does not
|
|
707
792
|
* return them and a record without `expiresAt` can never be seen as expired.
|
|
@@ -740,8 +825,9 @@ async function agentLogin(args, values) {
|
|
|
740
825
|
* resuming is least recoverable.
|
|
741
826
|
*/
|
|
742
827
|
...(existingRecord?.boundScopes ? { boundScopes: existingRecord.boundScopes } : {}),
|
|
743
|
-
...(
|
|
828
|
+
...(existingRecord?.sessionId ? { sessionId: existingRecord.sessionId } : {}),
|
|
744
829
|
...(existingRecord?.lastLoginAt ? { lastLoginAt: existingRecord.lastLoginAt } : {}),
|
|
830
|
+
lastWorkspace: chosenWorkspace,
|
|
745
831
|
};
|
|
746
832
|
saveAgent(name, record);
|
|
747
833
|
/**
|
|
@@ -754,10 +840,14 @@ async function agentLogin(args, values) {
|
|
|
754
840
|
* instead of silently replaced. The family (what un-logged-in sessions inherit) is claimed
|
|
755
841
|
* below, only once a session actually starts.
|
|
756
842
|
*/
|
|
757
|
-
const { deriveAgentScope } = await import('../agent-scope.js');
|
|
758
843
|
const { bindAgentScope, agentForScope, loginSessionOutcome } = await import('../agent-state.js');
|
|
759
|
-
const scope = deriveAgentScope();
|
|
760
844
|
bindAgentScope(name, scope, { family: false });
|
|
845
|
+
// The session record: identity + workspace + env, owned by this scope alone. Written before the
|
|
846
|
+
// workspace answers for the same reason the binding is — a refused login must still own its scope.
|
|
847
|
+
if (scope) {
|
|
848
|
+
writeSessionBinding({ scope, agent: name, workspace: chosenWorkspace, env: sessionEnv, boundAt: Date.now() });
|
|
849
|
+
writeTargetForScope(scope, chosenWorkspace, chosenWorkspace === 'workspace' ? 'cloud' : undefined);
|
|
850
|
+
}
|
|
761
851
|
// Sync to Electron store if present
|
|
762
852
|
try {
|
|
763
853
|
const p = join(homedir(), 'Library', 'Application Support', 'clearly-desktop', 'config.json');
|
|
@@ -821,18 +911,36 @@ async function agentLogin(args, values) {
|
|
|
821
911
|
// `loginSessionOutcome` — and is reported, never swallowed.
|
|
822
912
|
let started;
|
|
823
913
|
let thrown;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
914
|
+
const sessionInput = {
|
|
915
|
+
agentId: record.agentId,
|
|
916
|
+
client: String(values.client ?? 'cli').trim() || 'cli',
|
|
917
|
+
label: String(values.label ?? '').trim() || undefined,
|
|
918
|
+
expiresAt: record.expiresAt,
|
|
919
|
+
};
|
|
920
|
+
if (holdsWorkSessions(chosenWorkspace)) {
|
|
921
|
+
try {
|
|
922
|
+
// ⚠ IN THE SESSION'S WORKSPACE (AWO-394) — it used to open in the daemon's default
|
|
923
|
+
// workspace whatever the cursor said, and that id then rode on calls to other workspaces.
|
|
924
|
+
started = await rpcIn(chosenWorkspace, 'agent-session-start', sessionInput, 15_000);
|
|
925
|
+
const first = loginSessionOutcome(started);
|
|
926
|
+
// An identity is the ACCOUNT's; each workspace admits it once. Same self-heal `call` does.
|
|
927
|
+
if (first.kind === 'refused' && chosenWorkspace !== 'workspace' && /unknown agent|not admitted|unknown-agent/i.test(`${first.scope} ${first.reason}`)) {
|
|
928
|
+
const admitted = await rpcIn(chosenWorkspace, 'agent-admit', { agentId: record.agentId }, 20_000).catch(() => null);
|
|
929
|
+
if ((admitted?.result ?? admitted)?.ok)
|
|
930
|
+
started = await rpcIn(chosenWorkspace, 'agent-session-start', sessionInput, 15_000);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
catch (err) {
|
|
934
|
+
thrown = err ?? new Error('agent-session-start failed');
|
|
935
|
+
}
|
|
831
936
|
}
|
|
832
|
-
|
|
833
|
-
|
|
937
|
+
const outcome = holdsWorkSessions(chosenWorkspace)
|
|
938
|
+
? loginSessionOutcome(started, thrown)
|
|
939
|
+
: { kind: 'unreachable', reason: 'a store keeps no agent work sessions' };
|
|
940
|
+
if (outcome.kind === 'started' && scope) {
|
|
941
|
+
const { updateSessionBinding } = await import('../session-binding.js');
|
|
942
|
+
updateSessionBinding(scope, { sessionId: outcome.sessionId });
|
|
834
943
|
}
|
|
835
|
-
const outcome = loginSessionOutcome(started, thrown);
|
|
836
944
|
if (outcome.kind === 'started') {
|
|
837
945
|
saveAgentSession(name, outcome.sessionId);
|
|
838
946
|
/**
|
|
@@ -876,8 +984,11 @@ async function agentLogin(args, values) {
|
|
|
876
984
|
if (scope) {
|
|
877
985
|
const resolved = agentForScope(scope);
|
|
878
986
|
if (resolved === name) {
|
|
879
|
-
console.log(`
|
|
880
|
-
console.log(dim('
|
|
987
|
+
console.log(` This session ${cyan(scope)}`);
|
|
988
|
+
console.log(` Agent ${cyan(name)}${dim(' every beehaven command here runs as this agent — nothing to pass per call')}`);
|
|
989
|
+
console.log(` Workspace ${cyan(await workspaceLabel(chosenWorkspace))}${dim(' switch: beehaven connect <workspace>')}`);
|
|
990
|
+
console.log(` Env ${cyan(sessionEnv)}${dim(' calls are refused if the daemon moves to another env')}`);
|
|
991
|
+
console.log(dim(' Other sessions and terminal tabs keep their own agent and workspace.'));
|
|
881
992
|
}
|
|
882
993
|
else {
|
|
883
994
|
console.error(` ${yellow('⚠')} Could not bind this session (${scope}) to ${name}; calls resolve to ${resolved ?? 'nobody'}.`);
|
|
@@ -886,9 +997,10 @@ async function agentLogin(args, values) {
|
|
|
886
997
|
}
|
|
887
998
|
}
|
|
888
999
|
else {
|
|
889
|
-
|
|
1000
|
+
// No per-session handle: a terminal that does not say which tab it is.
|
|
1001
|
+
console.log(' This terminal has no session id, so the sign-in is shared by every such terminal.');
|
|
1002
|
+
console.log(' To keep it to THIS terminal:');
|
|
890
1003
|
console.log(` ${cyan(`eval "$(beehaven agent use ${name})"`)}`);
|
|
891
|
-
console.log(dim(` or: export BEEHAVEN_AGENT=${name}`));
|
|
892
1004
|
}
|
|
893
1005
|
}
|
|
894
1006
|
console.log('');
|
|
@@ -1159,6 +1271,14 @@ function agentWhoami(values) {
|
|
|
1159
1271
|
console.log(` birthday: ${r.birthday ? formatBirthday(r.birthday) : 'unknown (created before birthdays)'}`);
|
|
1160
1272
|
console.log(` voice: ${r.voice?.provider ? (r.voice.voiceId ? `${r.voice.provider}:${r.voice.voiceId}` : `${r.voice.provider} (base voice)`) : 'unbound — speak picks whatever is reachable'}`);
|
|
1161
1273
|
console.log(` expires: ${formatRemaining(r.expiresAt)}`);
|
|
1274
|
+
if (active.session) {
|
|
1275
|
+
console.log(` session: ${active.session.scope} (work session ${active.session.sessionId ? active.session.sessionId.slice(0, 8) : 'none'})`);
|
|
1276
|
+
console.log(` workspace: ${active.session.workspace}`);
|
|
1277
|
+
console.log(` env: ${active.session.env}`);
|
|
1278
|
+
}
|
|
1279
|
+
else {
|
|
1280
|
+
console.log(dim(' session: none bound here — `beehaven agent login ' + active.name + '` binds this session'));
|
|
1281
|
+
}
|
|
1162
1282
|
if (active.expired)
|
|
1163
1283
|
console.log(dim(` refresh: beehaven agent login ${active.name}`));
|
|
1164
1284
|
console.log('');
|
|
@@ -1184,6 +1304,53 @@ async function agentStatus(args, values) {
|
|
|
1184
1304
|
}
|
|
1185
1305
|
console.log(` ${green('✓')} ${cyan(name)} status: ${String(result.message ?? message)}`);
|
|
1186
1306
|
}
|
|
1307
|
+
// ── connect, for a signed-in session ─────────────────
|
|
1308
|
+
/**
|
|
1309
|
+
* `beehaven connect <address>` in a signed-in session (AWO-394): move THIS session — its cursor,
|
|
1310
|
+
* its work session and its binding — and nothing else. The cursor file is already this session's;
|
|
1311
|
+
* this moves the work session to the workspace it now works in (a session lives in one workspace's
|
|
1312
|
+
* DO, and its id used to ride on calls to others), and remembers the workspace on the identity so
|
|
1313
|
+
* the next login starts there.
|
|
1314
|
+
*/
|
|
1315
|
+
export async function moveAgentSession(address) {
|
|
1316
|
+
const { activeSessionBinding } = await import('../agent-state.js');
|
|
1317
|
+
const { updateSessionBinding, describeAddress } = await import('../session-binding.js');
|
|
1318
|
+
const bound = activeSessionBinding();
|
|
1319
|
+
if (!bound)
|
|
1320
|
+
return;
|
|
1321
|
+
const record = loadAgent(bound.agent);
|
|
1322
|
+
if (!record)
|
|
1323
|
+
return;
|
|
1324
|
+
if (bound.workspace === address)
|
|
1325
|
+
return;
|
|
1326
|
+
if (bound.sessionId)
|
|
1327
|
+
await endSessionIn(bound.workspace, bound.sessionId, record.agentId);
|
|
1328
|
+
let sessionId;
|
|
1329
|
+
if (holdsWorkSessions(address)) {
|
|
1330
|
+
const input = { agentId: record.agentId, client: 'cli', expiresAt: record.expiresAt };
|
|
1331
|
+
const { loginSessionOutcome } = await import('../agent-state.js');
|
|
1332
|
+
try {
|
|
1333
|
+
let r = await rpcIn(address, 'agent-session-start', input, 15_000);
|
|
1334
|
+
let o = loginSessionOutcome(r);
|
|
1335
|
+
if (o.kind === 'refused' && address !== 'workspace' && /unknown agent|not admitted|unknown-agent/i.test(`${o.scope} ${o.reason}`)) {
|
|
1336
|
+
const admitted = await rpcIn(address, 'agent-admit', { agentId: record.agentId }, 20_000).catch(() => null);
|
|
1337
|
+
if ((admitted?.result ?? admitted)?.ok) {
|
|
1338
|
+
console.error(dim(` ${bound.agent} joined this workspace.`));
|
|
1339
|
+
r = await rpcIn(address, 'agent-session-start', input, 15_000);
|
|
1340
|
+
o = loginSessionOutcome(r);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (o.kind === 'started')
|
|
1344
|
+
sessionId = o.sessionId;
|
|
1345
|
+
else if (o.kind === 'refused')
|
|
1346
|
+
console.error(` ${yellow('⚠')} ${bound.agent} has no work session here: ${o.reason}`);
|
|
1347
|
+
}
|
|
1348
|
+
catch { /* unreachable — calls still route; the next login opens a session */ }
|
|
1349
|
+
}
|
|
1350
|
+
updateSessionBinding(bound.scope, { workspace: address, sessionId });
|
|
1351
|
+
saveAgent(bound.agent, { ...record, lastWorkspace: address });
|
|
1352
|
+
console.log(dim(` Session ${bound.scope} (${bound.agent}) now works in ${await workspaceLabel(address)}${sessionId ? ` · work session ${sessionId.slice(0, 8)}` : ''}. Other sessions are unaffected.`));
|
|
1353
|
+
}
|
|
1187
1354
|
// ── handoff ──────────────────────────────────────────
|
|
1188
1355
|
/**
|
|
1189
1356
|
* End the current work session WITH a note — the one thing the next login (this agent's, or the
|
|
@@ -1203,6 +1370,22 @@ async function agentHandoff(args, values) {
|
|
|
1203
1370
|
process.exit(1);
|
|
1204
1371
|
}
|
|
1205
1372
|
const record = loadAgent(name);
|
|
1373
|
+
const { activeSessionBinding } = await import('../agent-state.js');
|
|
1374
|
+
const bound = activeSessionBinding(name);
|
|
1375
|
+
if (bound?.sessionId && record) {
|
|
1376
|
+
// AWO-394 — hand off THIS session, where it lives; other sessions of the name are untouched.
|
|
1377
|
+
if (!await endSessionIn(bound.workspace, bound.sessionId, record.agentId, summary)) {
|
|
1378
|
+
console.error(` ${yellow('⚠')} The workspace did not accept the handoff; the session is still open.`);
|
|
1379
|
+
process.exitCode = 1;
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const { updateSessionBinding } = await import('../session-binding.js');
|
|
1383
|
+
updateSessionBinding(bound.scope, { sessionId: undefined });
|
|
1384
|
+
if (record.sessionId === bound.sessionId)
|
|
1385
|
+
clearAgentSessionId(name);
|
|
1386
|
+
console.log(` ${green('✓')} Handed off session ${cyan(bound.sessionId.slice(0, 8))} — the next login for ${cyan(name)} reads this first.`);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1206
1389
|
if (!record?.sessionId) {
|
|
1207
1390
|
console.error(` ${yellow('⚠')} ${cyan(name)} has no open work session on this device.`);
|
|
1208
1391
|
console.error(dim(` Start one with: beehaven agent login ${name} --label "<work>" --client cli`));
|
|
@@ -1236,18 +1419,42 @@ async function agentLogout(args, values) {
|
|
|
1236
1419
|
// it stays in the workspace (source of truth) AND on this device, so you can
|
|
1237
1420
|
// log back in. To remove the current workspace projection use `agent delete <name>`;
|
|
1238
1421
|
// to remove the portable account identity use `agent delete <name> --account --yes`.
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1422
|
+
/**
|
|
1423
|
+
* ⚠ AWO-394 — signing out ends THIS session (its binding, its work session, its cursor) and
|
|
1424
|
+
* leaves every other session signed in as the same name running. It used to clear the shared
|
|
1425
|
+
* record's session, which locked every other terminal using the name out at once.
|
|
1426
|
+
*/
|
|
1427
|
+
const { activeSessionBinding, unbindAgentScope } = await import('../agent-state.js');
|
|
1428
|
+
const { clearSessionBinding, listSessionBindings } = await import('../session-binding.js');
|
|
1429
|
+
const bound = activeSessionBinding(name);
|
|
1430
|
+
if (bound) {
|
|
1431
|
+
if (bound.sessionId)
|
|
1432
|
+
await endSessionIn(bound.workspace, bound.sessionId, record.agentId);
|
|
1433
|
+
clearSessionBinding(bound.scope);
|
|
1434
|
+
// …and this scope stops resolving to the name, or the next call here would still act as it.
|
|
1435
|
+
unbindAgentScope(name, bound.scope);
|
|
1436
|
+
if (record.sessionId && record.sessionId === bound.sessionId)
|
|
1437
|
+
clearAgentSessionId(name);
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
await endWorkspaceSession(record);
|
|
1441
|
+
clearAgentSession(name);
|
|
1442
|
+
}
|
|
1443
|
+
const others = listSessionBindings().filter((b) => b.agent === name);
|
|
1444
|
+
if (!others.length) {
|
|
1445
|
+
try {
|
|
1446
|
+
await rpcOnce('agent:disconnect', { agentId: record.agentId ?? name }, 5_000);
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
/* daemon down — nothing to close */
|
|
1450
|
+
}
|
|
1245
1451
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1452
|
+
console.log(` ${green('✓')} Signed out agent "${name}" ${bound ? 'in this session' : ''} — session ended, identity kept.`);
|
|
1453
|
+
if (others.length)
|
|
1454
|
+
console.log(dim(` ${others.length} other session${others.length === 1 ? ' is' : 's are'} still signed in as ${name}.`));
|
|
1248
1455
|
console.log(dim(` Log back in: beehaven agent login ${name} · workspace remove: beehaven agent delete ${name}`));
|
|
1249
|
-
if (
|
|
1250
|
-
console.log(dim(`
|
|
1456
|
+
if (process.env.BEEHAVEN_AGENT === name && !values.agent) {
|
|
1457
|
+
console.log(dim(` BEEHAVEN_AGENT still names ${name} in this shell — run: unset BEEHAVEN_AGENT`));
|
|
1251
1458
|
}
|
|
1252
1459
|
}
|
|
1253
1460
|
// ── delete (destructive — removes the identity from the workspace + device) ──
|
|
@@ -725,8 +725,18 @@ export { activeAgentWrap, setCliDoor, cliDoor, alreadyAttributed, BOOTSTRAP_ACTI
|
|
|
725
725
|
* ambient identity and is correct for every verb that does not offer that flag.
|
|
726
726
|
*/
|
|
727
727
|
export async function agentRpc(action, input, timeout, values = {}) {
|
|
728
|
-
|
|
729
|
-
|
|
728
|
+
/**
|
|
729
|
+
* ⚠⚠ THE TYPED VERBS FOLLOW THE SESSION'S WORKSPACE, LIKE `call` (AWO-393). This sent a bare
|
|
730
|
+
* `rpcOnce` to the daemon's default workspace: after `connect team/<id>`, `beehaven call
|
|
731
|
+
* ticket-create` filed in the team and `beehaven ticket create` filed at home. Same resolver,
|
|
732
|
+
* same order as `cmdCall` — wrap as the agent first, then the building envelope around it.
|
|
733
|
+
*/
|
|
734
|
+
const routed = { ...values };
|
|
735
|
+
const { resolveTargetBuilding } = await import('../session-target.js');
|
|
736
|
+
await resolveTargetBuilding(routed);
|
|
737
|
+
const wrapped = activeAgentWrap(action, input, routed);
|
|
738
|
+
const resolved = resolveBuilding(wrapped.action, wrapped.input, routed.building);
|
|
739
|
+
return rpcOnce(resolved.action, resolved.input, timeout);
|
|
730
740
|
}
|
|
731
741
|
/**
|
|
732
742
|
* Does this result payload represent a failure? Mirrors `isFailurePayload` in the worker's
|
|
@@ -1260,7 +1270,13 @@ function printCallErrorHints(msg, action, values) {
|
|
|
1260
1270
|
if (!values.json) {
|
|
1261
1271
|
console.error('');
|
|
1262
1272
|
console.error('\x1b[2m Try:\x1b[0m');
|
|
1263
|
-
if (
|
|
1273
|
+
if (/admin:(store|team|workspace)-rpc/.test(s) && s.includes('not allowed')) {
|
|
1274
|
+
// A store/team target is reached through an admin:*-rpc proxy, which the workspace refuses
|
|
1275
|
+
// to members over the daemon. Platform admins have the HTTP door instead (PPO-444).
|
|
1276
|
+
console.error(`\x1b[2m beehaven admin do shopifyStore/<id> ${action} '<json>' # platform admins: the operator door\x1b[0m`);
|
|
1277
|
+
console.error(`\x1b[2m beehaven admin dashboard shopifyStore/<id> # state, logs, snapshots\x1b[0m`);
|
|
1278
|
+
}
|
|
1279
|
+
else if (s.includes('unknown action') || s.includes('404')) {
|
|
1264
1280
|
console.error(`\x1b[2m beehaven actions --search <name> # find the mounted action\x1b[0m`);
|
|
1265
1281
|
console.error(`\x1b[2m beehaven pwd # confirm you\'re on the right DO\x1b[0m`);
|
|
1266
1282
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beehaven",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.34",
|
|
4
4
|
"description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
|
-
"better-sqlite3": "
|
|
56
|
+
"better-sqlite3": "13.0.3",
|
|
57
57
|
"node-pty": "1.0.0",
|
|
58
58
|
"open": "10.1.0"
|
|
59
59
|
},
|
|
@@ -5,14 +5,30 @@ description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini
|
|
|
5
5
|
|
|
6
6
|
# clearly-agent — who you are, and what that makes your work
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Sign in once — this session is then you, in one workspace
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
beehaven env
|
|
12
|
-
beehaven
|
|
13
|
-
beehaven agent login <name> --label "what I am here to do" --client cli
|
|
11
|
+
beehaven env # staging or prod: the session remembers it
|
|
12
|
+
beehaven agent login <name> --label "what I am here to do" --client cli [--workspace <ws>]
|
|
14
13
|
```
|
|
15
14
|
|
|
15
|
+
After login **every** `beehaven` command in this session runs as `<name>`, in its workspace — no
|
|
16
|
+
`--agent`, no export, nothing per call. The login prints the session, the agent, the workspace (by
|
|
17
|
+
name) and the env. `--workspace` takes `home`, `workspace/<id>`, `team/<id>` or a bare 64-character
|
|
18
|
+
id (`beehaven workspaces` lists them); without it a session starts where this identity last worked.
|
|
19
|
+
|
|
20
|
+
**One session = one identity + one workspace + one env (AWO-394).** A session is a Claude/Codex/
|
|
21
|
+
Gemini conversation, or a terminal tab. Each has its own:
|
|
22
|
+
- workspace — `beehaven connect <ws>` moves THIS session only (and moves its work session there);
|
|
23
|
+
another agent, or another conversation signed in as the same name, stays where it is;
|
|
24
|
+
- work session — a second login under the same name no longer ends the first one's;
|
|
25
|
+
- env — if someone switches the daemon (`beehaven env prod`), calls from a staging session are
|
|
26
|
+
refused with the fix instead of landing in the other environment.
|
|
27
|
+
|
|
28
|
+
Typed verbs (`ticket`, `sheet`, `slides`, `design`, `mention`, `trash`) follow the session's
|
|
29
|
+
workspace exactly like `call` (AWO-393). A Shopify store is an operator target:
|
|
30
|
+
`beehaven admin do shopifyStore/<id> <action> '<json>'` (platform admins).
|
|
31
|
+
|
|
16
32
|
## Personal workspace means a private layer, not another DO
|
|
17
33
|
|
|
18
34
|
Every human and every agent in the selected workspace has a **personal workspace layer**: its own
|
|
@@ -38,13 +54,14 @@ steps. CI gets a real identity for one command: `beehaven agent login ci`.
|
|
|
38
54
|
| 2 | `BEEHAVEN_AGENT=<name>` | a script, or another terminal |
|
|
39
55
|
| 3 | the **scope binding** from `agent login` | this session, no flag needed |
|
|
40
56
|
|
|
41
|
-
⚠ **The binding needs no cooperation.** `agent login`
|
|
42
|
-
`claude-<sessionId>`, `codex-…`, `gemini-…`,
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
⚠ **The binding needs no cooperation.** `agent login` writes `~/.beehaven/sessions/<scope>.json` for
|
|
58
|
+
the calling process's scope — `claude-<sessionId>`, `codex-…`, `gemini-…`, `term-<tab>` for a
|
|
59
|
+
terminal tab that says which tab it is (macOS Terminal, iTerm2, Windows Terminal, tmux, kitty,
|
|
60
|
+
WezTerm), or `pid-<ppid>` for a non-TTY. Two harness sessions or two tabs are two agents
|
|
61
|
+
concurrently with no env var; logging in twice from one session means the second login.
|
|
45
62
|
|
|
46
|
-
⚠
|
|
47
|
-
|
|
63
|
+
⚠ What is still machine-wide: one daemon, one relay, one `beehaven env` (sessions refuse a mismatch
|
|
64
|
+
rather than follow it). `agent logout` ends this session only.
|
|
48
65
|
|
|
49
66
|
## Identity ≠ session
|
|
50
67
|
|
|
@@ -147,11 +147,12 @@ deliverable requires them, with content order and hierarchy reconsidered for the
|
|
|
147
147
|
|
|
148
148
|
The same rules the in-app assistant works to (canvas-catalog returns them as `technique`).
|
|
149
149
|
|
|
150
|
-
- AUTO-LAYOUT:
|
|
150
|
+
- AUTO-LAYOUT: build every row, stack, card and grid AS an auto-layout frame from the start: frame.create (or canvas.create-node type "frame") { name, parentId, autoLayout: { direction: "h" | "v" | "grid", gap, padding, align, justify, hug } }, then create its children with parentId and NO x/y; they flow in creation order and the frame stays live. canvas.auto-layout { id, … } changes the settings; with only { id } it converts a hand-positioned frame, reading direction, gap and padding off the children so nothing moves, and review reports any stack left positioned by hand. hug:false keeps the frame size; justify "space-between" | "space-around" | "space-evenly" are the Auto gaps; align:"baseline" lines up text in a row. Grids take columns, rows, columnGap, rowGap, columnSizes / rowSizes ("fill", "2fr", "hug", px). wrap:true flows into new rows; give Fill cards a minW so they wrap where you mean. Child settings go on the child via canvas.update-nodes: layoutSizingH/V "fill" | "hug" | "fixed", minW / maxW / minH / maxH, gridColumn / gridRow / gridColumnSpan, layoutPositioning:"absolute".
|
|
151
|
+
- VARIABLES: use the brand by name, not by value: in canvas.create-node and frame.create write fill: "brand/accent", stroke: "brand/line", radius: "brand/radius", autoLayout: { gap: "brand/spacing", padding: "brand/spacing" }, and on text textStyle: "brand/heading" (or "brand/body"), which sets family, weight, size, line height and letter spacing together from the brand's type role. Each binds, so the layer follows the canvas brand and a switch of brand; "var/<name>" reaches a workspace variable (text: "var/product/name", visible: "var/flags/promo"). A dark version of a frame is variableModes: { brand: "dark" } on it, not new colours. canvas.variable binds existing layers (op "bind", "apply-style", "set-mode"); op "list" shows every variable and style. A typed value detaches.
|
|
151
152
|
- 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.
|
|
152
153
|
- 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.
|
|
153
|
-
- 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,
|
|
154
|
-
- COMPUTED GEOMETRY: shapes from math (radial petals, spirals, grids) are computed and drawn in ONE call: canvas-act { compositionId, script } runs JavaScript with geo.petal/circle/arc/star/polygon/spiral/smooth/rotate/radial/grid/bounds (degrees, 0 = 3 o'clock, clockwise) and a read-only scene list, then applies the { action, args } steps it returns. Never copy numbers from one tool result into another call.
|
|
154
|
+
- 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, d, fill or stroke, strokeWidth }: write d as SVG path data (M, C, Z), native editable points in far fewer tokens than points: [{ x, y, hIn?, hOut? }] (hIn/hOut are absolute control points); few anchors, smooth curves. It also takes shape: petal|circle|polygon|star|arc|spiral with cx, cy and sizes; strokeCap/strokeJoin apply. Reshape in place with canvas.update-nodes { ids, patch: { points | d | shape } }. Draw a repeated part once: canvas.repeat { id, count, around | cx, cy } turns one petal into a flower; canvas.rotate { ids, angle, cx, cy }; canvas.boolean { ids, op: "union" | "subtract" | "intersect" | "exclude" } (exact, editable operands). 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 }. 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.
|
|
155
|
+
- COMPUTED GEOMETRY: shapes from math (radial petals, spirals, grids) are computed and drawn in ONE call: canvas-act { compositionId, script } runs JavaScript with geo.petal/circle/arc/star/polygon/spiral/smooth/rotate/radial/grid/bounds (degrees, 0 = 3 o'clock, clockwise) and a read-only scene list, then applies the { action, args } steps it returns. Never copy numbers from one tool result into another call. A new top-level artboard goes beside or below what scene already holds (or leave out both x and y), never at 0,0 over an existing one.
|
|
155
156
|
- SHEETS: several concepts or variants go on canvas.sheet { name, title, subtitle, cards or count, cols, cardW, cardH }: a live wrap grid of card frames that own their captions, so rows never overlap. Draw into a card with parentId = its id.
|
|
156
157
|
- MEASURE: answer spacing, overlap and alignment questions with canvas.measure { frame } or { ids } and quote its numbers, never from memory or a preview.
|
|
157
158
|
- 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.
|
package/skills/index.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"project-plans.md",
|
|
18
18
|
"review.mjs"
|
|
19
19
|
],
|
|
20
|
-
"sha": "
|
|
20
|
+
"sha": "e23e25bd46b02d9c"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"name": "agent-home",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"files": [
|
|
83
83
|
"SKILL.md"
|
|
84
84
|
],
|
|
85
|
-
"sha": "
|
|
85
|
+
"sha": "11505106cb1dd287"
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
"name": "visualize",
|