@clearlysh/beehaven 0.8.32 → 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.
@@ -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
- const previousSessionEnded = await endWorkspaceSession(existingRecord);
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
- ...(!previousSessionEnded && existingRecord?.sessionId ? { sessionId: existingRecord.sessionId } : {}),
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
- try {
825
- started = await rpcOnce('agent-session-start', {
826
- agentId: record.agentId,
827
- client: String(values.client ?? 'cli').trim() || 'cli',
828
- label: String(values.label ?? '').trim() || undefined,
829
- expiresAt: record.expiresAt,
830
- }, 15_000);
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
- catch (err) {
833
- thrown = err ?? new Error('agent-session-start failed');
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(` ${green('✓')} Bound to this session (${cyan(scope)}) — every call here now runs as ${cyan(name)}.`);
880
- console.log(dim(' Other terminals: export BEEHAVEN_AGENT=' + name));
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
- console.log(' Activate in THIS terminal:');
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
- await endWorkspaceSession(record);
1240
- try {
1241
- await rpcOnce('agent:disconnect', { agentId: record.agentId ?? name }, 5_000);
1242
- }
1243
- catch {
1244
- /* daemon down — nothing to close */
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
- clearAgentSession(name);
1247
- console.log(` ${green('✓')} Signed out agent "${name}" — session ended, identity kept.`);
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 (activeAgentName(values) === name && !values.agent) {
1250
- console.log(dim(` This was your active agent — run: unset BEEHAVEN_AGENT`));
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
- const wrapped = activeAgentWrap(action, input, values);
729
- return rpcOnce(wrapped.action, wrapped.input, timeout);
728
+ /**
729
+ * ⚠⚠ THE TYPED VERBS FOLLOW THE SESSION'S WORKSPACE, LIKE `call` (AWO-393). This sent a bare
730
+ * `rpcOnce` to the daemon's default workspace: after `connect team/<id>`, `beehaven call
731
+ * ticket-create` filed in the team and `beehaven ticket create` filed at home. Same resolver,
732
+ * same order as `cmdCall` — wrap as the agent first, then the building envelope around it.
733
+ */
734
+ const routed = { ...values };
735
+ const { resolveTargetBuilding } = await import('../session-target.js');
736
+ await resolveTargetBuilding(routed);
737
+ const wrapped = activeAgentWrap(action, input, routed);
738
+ const resolved = resolveBuilding(wrapped.action, wrapped.input, routed.building);
739
+ return rpcOnce(resolved.action, resolved.input, timeout);
730
740
  }
731
741
  /**
732
742
  * Does this result payload represent a failure? Mirrors `isFailurePayload` in the worker's
@@ -1260,7 +1270,13 @@ function printCallErrorHints(msg, action, values) {
1260
1270
  if (!values.json) {
1261
1271
  console.error('');
1262
1272
  console.error('\x1b[2m Try:\x1b[0m');
1263
- if (s.includes('unknown action') || s.includes('404')) {
1273
+ if (/admin:(store|team|workspace)-rpc/.test(s) && s.includes('not allowed')) {
1274
+ // A store/team target is reached through an admin:*-rpc proxy, which the workspace refuses
1275
+ // to members over the daemon. Platform admins have the HTTP door instead (PPO-444).
1276
+ console.error(`\x1b[2m beehaven admin do shopifyStore/<id> ${action} '<json>' # platform admins: the operator door\x1b[0m`);
1277
+ console.error(`\x1b[2m beehaven admin dashboard shopifyStore/<id> # state, logs, snapshots\x1b[0m`);
1278
+ }
1279
+ else if (s.includes('unknown action') || s.includes('404')) {
1264
1280
  console.error(`\x1b[2m beehaven actions --search <name> # find the mounted action\x1b[0m`);
1265
1281
  console.error(`\x1b[2m beehaven pwd # confirm you\'re on the right DO\x1b[0m`);
1266
1282
  }
@@ -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": "@clearlysh/beehaven",
3
- "version": "0.8.32",
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": "11.0.0",
56
+ "better-sqlite3": "13.0.3",
57
57
  "node-pty": "1.0.0",
58
58
  "open": "10.1.0"
59
59
  },