beehaven 0.8.29 → 0.8.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agent-attribution.js +20 -2
  2. package/dist/agent-scope.js +55 -10
  3. package/dist/agent-state.js +44 -0
  4. package/dist/agents-md-text.js +64 -0
  5. package/dist/browser-login.js +14 -4
  6. package/dist/build-channel.js +2 -2
  7. package/dist/claude-bridge.js +12 -9
  8. package/dist/clearly.js +4 -3
  9. package/dist/cli-state.js +45 -3
  10. package/dist/cli.js +82 -53
  11. package/dist/commands/agent-skills.js +12 -1
  12. package/dist/commands/agent.js +235 -28
  13. package/dist/commands/agents-md.js +118 -0
  14. package/dist/commands/daemon-cmd.js +2 -0
  15. package/dist/commands/help.js +21 -0
  16. package/dist/commands/self-update.js +32 -5
  17. package/dist/commands/workspace.js +22 -6
  18. package/dist/daemon-client.js +10 -6
  19. package/dist/daemon-entry.js +1 -0
  20. package/dist/daemon-process.js +125 -0
  21. package/dist/daemon.js +131 -62
  22. package/dist/dashboard.js +118 -6
  23. package/dist/ipc-auth.js +3 -1
  24. package/dist/ipc-client.js +7 -6
  25. package/dist/ipc-path.js +92 -0
  26. package/dist/ipc-server.js +33 -33
  27. package/dist/npm-update.js +138 -0
  28. package/dist/session-binding.js +135 -0
  29. package/dist/session-target.js +91 -0
  30. package/dist/setup-service.js +9 -4
  31. package/dist/verb-surface.js +1 -0
  32. package/dist/watcher.js +9 -23
  33. package/dist/windows-hide.js +77 -0
  34. package/package.json +2 -2
  35. package/scripts/sync-skills.mjs +6 -0
  36. package/skills/agent-doodle-space/SKILL.md +1 -1
  37. package/skills/agent-home/SKILL.md +1 -1
  38. package/skills/brand-identity/SKILL.md +22 -5
  39. package/skills/clearly-agent/SKILL.md +38 -14
  40. package/skills/clearly-canvas/SKILL.md +31 -5
  41. package/skills/clearly-canvas/authoring.md +41 -12
  42. package/skills/clearly-canvas/example.mjs +72 -69
  43. package/skills/clearly-docs/SKILL.md +151 -0
  44. package/skills/clearly-init/SKILL.md +135 -0
  45. package/skills/clearly-workflows/SKILL.md +90 -0
  46. package/skills/clearly-workspace/SKILL.md +1 -1
  47. package/skills/codebase-map/SKILL.md +1 -1
  48. package/skills/design-craft/SKILL.md +9 -6
  49. package/skills/index.json +45 -10
  50. package/skills/layout-systems/SKILL.md +14 -11
  51. package/skills/pair-on-canvas/SKILL.md +1 -1
  52. package/skills/sticker-pack/SKILL.md +132 -0
  53. package/skills/visualize/SKILL.md +188 -0
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@
16
16
  if (!process.env.BEEHAVEN_IPC_SCOPE) {
17
17
  process.env.BEEHAVEN_IPC_SCOPE = 'developer';
18
18
  }
19
+ import './windows-hide.js'; // FIRST: no console windows flashing on Windows (CAN2-56)
19
20
  import { parseArgs } from 'node:util';
20
21
  import { devModeAvailable, buildLabel, PUBLIC_BUILD } from './build-channel.js';
21
22
  import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, } from 'fs';
@@ -142,6 +143,7 @@ function printHelp() {
142
143
 
143
144
  beehaven guide Agent-first setup and signup handoff
144
145
  beehaven signup Create a free Clearly account
146
+ beehaven agents-md Add Clearly to this project's AGENTS.md
145
147
 
146
148
  Personal workspace:
147
149
  beehaven cd home
@@ -448,8 +450,9 @@ ${DEV_ONLY_HELP ? `
448
450
 
449
451
  Note: if you also run the Hive menu-bar app, the tray is the
450
452
  primary owner of the daemon. CLI start/stop only act when the tray
451
- isn't running its own daemon. The same socket (/tmp/beehaven-ipc.sock)
452
- is shared in both modes — never two daemons at once.
453
+ isn't running its own daemon. The same socket (/tmp/beehaven-ipc.sock;
454
+ a per-user named pipe on Windows) is shared in both modes — never two
455
+ daemons at once.
453
456
 
454
457
  ─────────────────────────────────────────────────────────────────────
455
458
  OPTIONS
@@ -846,48 +849,9 @@ async function printStatus() {
846
849
  * One function, because four hand-written copies is how one missing `else` became four.
847
850
  */
848
851
  async function resolveTargetBuilding(values) {
849
- if (values.building)
850
- return; // an explicit flag always wins
851
- const { getTarget, isHomeAddress } = await import('./cli-state.js');
852
- const t = getTarget();
853
- if (t?.address?.includes('/')) {
854
- const [bType, ...bIdParts] = t.address.split('/');
855
- values.building = `${bType}:${bIdParts.join('/')}`;
856
- return;
857
- }
858
- if (t && isHomeAddress(t.address)) {
859
- /**
860
- * ⚠ HOME IS A POSITIVE STATEMENT, NOT AN ABSENCE. The daemon's focus is a routing
861
- * optimisation; the file is where the user said to be. If they disagree the daemon is
862
- * stale — drop it, so the call lands where `pwd` says it will, and SAY so.
863
- */
864
- try {
865
- const { daemonGetConnection, daemonDisconnect } = await import('./daemon-client.js');
866
- const conn = await daemonGetConnection();
867
- if (conn?.buildingId) {
868
- await daemonDisconnect();
869
- console.error(`\x1b[2m (dropped a stale daemon focus on ${conn.buildingType}/${conn.buildingId}`
870
- + ` — your target is home)\x1b[0m`);
871
- }
872
- }
873
- catch { /* no daemon, or it refused — home is the default route anyway */ }
874
- return;
875
- }
876
- if (!t) {
877
- /**
878
- * ⚠ NO TARGET EVER SET — inherit the daemon's connection. The machine-global fallback
879
- * `cli-state.ts` warns about: another room, the Mac app or a human's terminal can set
880
- * it, so an agent that never runs `connect` inherits someone else's cursor. Kept for
881
- * back-compat; `connect home` is what opts out, and this fix makes that opt-out real.
882
- */
883
- try {
884
- const { daemonGetConnection } = await import('./daemon-client.js');
885
- const conn = await daemonGetConnection();
886
- if (conn)
887
- values.building = `${conn.buildingType}:${conn.buildingId}`;
888
- }
889
- catch { /* fall through to home */ }
890
- }
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);
891
855
  }
892
856
  /**
893
857
  * ⚠⚠ AN IDENTIFIABLE AGENT MUST LOG IN BEFORE IT ACTS.
@@ -954,6 +918,11 @@ const AGENT_LOGIN_EXEMPT = new Set([
954
918
  * would refuse the one person it is for — the agent whose user has not signed up yet (SGS-73).
955
919
  */
956
920
  'handoff',
921
+ /**
922
+ * ⚠ `agents-md` WRITES A FILE IN THIS REPO AND NOTHING ELSE — it never opens a workspace, and it is
923
+ * part of setting a project up, which must not require the login it helps a teammate do (CAN2-66).
924
+ */
925
+ 'agents-md',
957
926
  ]);
958
927
  /**
959
928
  * ⚠ ALIAS → CANONICAL VERB, for the activity log's `source` column only.
@@ -1095,8 +1064,10 @@ async function requireAgentSession(command, values) {
1095
1064
  cfg = {};
1096
1065
  }
1097
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');
1098
1068
  const who = principalKind({
1099
- agentSessionId: rec?.sessionId,
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,
1100
1071
  agentExpiresAt: rec?.expiresAt,
1101
1072
  agentRuntime: isAgentRuntime(),
1102
1073
  accountUserId: cfg.userId,
@@ -1369,6 +1340,9 @@ async function run() {
1369
1340
  state: { type: 'string' },
1370
1341
  goal: { type: 'string' },
1371
1342
  remove: { type: 'boolean', default: false },
1343
+ // `agents-md`: the MCP block instead of the CLI one; print it instead of writing it (CAN2-66).
1344
+ mcp: { type: 'boolean', default: false },
1345
+ print: { type: 'boolean', default: false },
1372
1346
  /**
1373
1347
  * ⚠⚠ `beehaven sheet` / `beehaven slides` — AND AN UNDECLARED FLAG IS A FATAL, NOT A NO-OP.
1374
1348
  *
@@ -2226,6 +2200,14 @@ async function run() {
2226
2200
  // workspace via daemonRpc('shell:exec', …) — see
2227
2201
  // commands/shell.ts:resolveDefaultBuilding + execRemote.
2228
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();
2229
2211
  if (HOME_ADDRS.has(target)) {
2230
2212
  const { setTarget } = await import('./cli-state.js');
2231
2213
  // Canonical label is 'workspace' (no "home DO" — the daemon's relay is to the
@@ -2236,7 +2218,11 @@ async function run() {
2236
2218
  setTarget(CANONICAL_HOME_ADDRESS, 'cloud');
2237
2219
  // Best-effort daemon-side cleanup of any prior connect()
2238
2220
  // focus — we want home to be the default routing target.
2239
- if (daemonUp) {
2221
+ if (ownSession) {
2222
+ const { moveAgentSession } = await import('./commands/agent.js');
2223
+ await moveAgentSession(CANONICAL_HOME_ADDRESS);
2224
+ }
2225
+ else if (daemonUp) {
2240
2226
  try {
2241
2227
  await daemonDisconnect();
2242
2228
  }
@@ -2279,6 +2265,15 @@ async function run() {
2279
2265
  // where we meant to be, and we can retry without a re-`cd`.
2280
2266
  const { setTarget } = await import('./cli-state.js');
2281
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
+ }
2282
2277
  try {
2283
2278
  await daemonConnect(bType, bId);
2284
2279
  }
@@ -2388,7 +2383,13 @@ async function run() {
2388
2383
  const { clearTarget: clearDisconn } = await import('./cli-state.js');
2389
2384
  clearDisconn();
2390
2385
  const { isDaemonRunning: isDaemonRunningDisc, daemonDisconnect: daemonDisconnectDisc } = await import('./daemon-client.js');
2391
- if (await isDaemonRunningDisc()) {
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()) {
2392
2393
  try {
2393
2394
  await daemonDisconnectDisc();
2394
2395
  }
@@ -2446,7 +2447,22 @@ async function run() {
2446
2447
  // ⚠ `values` HERE TOO — `beehaven status --agent alice` must report who that call would
2447
2448
  // run as, not who the shell happens to be bound to. See `requireAgentSession`.
2448
2449
  const who = activeAgentName(values);
2449
- if (who && eff === who) {
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) {
2450
2466
  // Identity, workspace session and cursor are one handle.
2451
2467
  const { loadAgent } = await import('./agent-state.js');
2452
2468
  const r = loadAgent(who);
@@ -2461,6 +2477,11 @@ async function run() {
2461
2477
  : ` Agent: ${color.green(who)}${color.dim(' (logged in — calls run as this agent)')}`);
2462
2478
  console.log(` Scope: ${color.green(eff)}${color.dim(' (cursor follows the agent, not the session)')}`);
2463
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
+ }
2464
2485
  else if (raw) {
2465
2486
  console.log(` Scope: ${color.green(raw)}${color.dim(' (private cursor — concurrent agents cannot clobber it)')}`);
2466
2487
  console.log(` Agent: ${color.dim('none — calls run as you. `beehaven agent login <name>` to act as an agent')}`);
@@ -3143,6 +3164,15 @@ async function run() {
3143
3164
  console.log(' (or quit and relaunch the Hive Mac app if it owns the daemon).');
3144
3165
  break;
3145
3166
  }
3167
+ case 'agents-md': {
3168
+ const { cmdAgentsMd } = await import('./commands/agents-md.js');
3169
+ process.exitCode = cmdAgentsMd(positionals[1], {
3170
+ mcp: !!values.mcp,
3171
+ print: !!values.print,
3172
+ remove: !!values.remove,
3173
+ });
3174
+ break;
3175
+ }
3146
3176
  case 'handoff': {
3147
3177
  const { cmdHandoff } = await import('./commands/handoff.js');
3148
3178
  process.exitCode = await cmdHandoff(positionals[1], {
@@ -3171,11 +3201,10 @@ async function run() {
3171
3201
  console.log(` Sign up at ${color.cyan(signupUrl)}.`);
3172
3202
  console.log(' Then run "beehaven login" to wire the CLI to your account.');
3173
3203
  console.log('');
3174
- try {
3175
- const { spawn } = await import('child_process');
3176
- spawn('open', [signupUrl], { stdio: 'ignore', detached: true }).unref();
3177
- }
3178
- catch { }
3204
+ // The shared opener: per-platform (Windows has no `open`), and a spawn failure prints the
3205
+ // URL instead of crashing on an unhandled child 'error' event.
3206
+ const { openInBrowser } = await import('./browser-login.js');
3207
+ openInBrowser(signupUrl);
3179
3208
  break;
3180
3209
  }
3181
3210
  case 'treehouse': {
@@ -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 && localSha(destDir, skillFiles) !== 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;
@@ -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) ──