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
@@ -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 (BOOTSTRAP_ACTIONS.has(action) || DAEMON_LOCAL_ACTIONS.has(action))
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
- ...(active.record.sessionId ? { sessionId: active.record.sessionId } : {}),
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.
@@ -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 always wins
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
- * ⚠ NO MARKER AND NO TTY IS STILL NOT A PERSON. Key on the PARENT pid — stable for the
50
- * life of one process tree, different for a concurrent one. Not perfect (pids are reused
51
- * eventually), but the failure mode of a collision here is two runs of the SAME tool,
52
- * which is far milder than the shared-file failure it replaces: two DIFFERENT agents.
53
- */
54
- if (!process.stdin.isTTY && !process.stdout.isTTY)
55
- return `pid-${process.ppid}`;
56
- return ''; // a human at a terminal
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();
@@ -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
  /**
@@ -0,0 +1,64 @@
1
+ /**
2
+ * agents-md-text — the Clearly block for a PROJECT's AGENTS.md, as `beehaven agents-md` writes it
3
+ * (CAN2-66).
4
+ *
5
+ * ⚠⚠ A COPY, PINNED. The source of truth is the monorepo's `lib/connections/agentsMd.ts`, which the
6
+ * Integrations page, each agent's details and the MCP `clearly-agents-md` prompt render. This
7
+ * package cannot import it (it ships on its own), so the text is repeated here and the monorepo's
8
+ * `lib/__tests__/agentsMdCliParity.test.mjs` compares the two OUTPUTS, name argument included.
9
+ * Change both or the test fails — a stale copy does not render wrong, it instructs wrong.
10
+ *
11
+ * ⚠ NO IMPORTS, so that test can load this file directly.
12
+ */
13
+ export const CLI_BLOCK_START = '<!-- clearly:cli:start -->';
14
+ export const CLI_BLOCK_END = '<!-- clearly:cli:end -->';
15
+ export const MCP_BLOCK_START = '<!-- clearly:mcp:start -->';
16
+ export const MCP_BLOCK_END = '<!-- clearly:mcp:end -->';
17
+ /** What goes in CLAUDE.md so Claude Code reads the AGENTS.md block too. */
18
+ export const CLAUDE_MD_IMPORT = '@AGENTS.md';
19
+ /** The block for agents that use the `beehaven` CLI. */
20
+ export function agentsMdCliBlock(agentName) {
21
+ const name = (agentName ?? '').trim();
22
+ const login = name
23
+ ? `\`beehaven agent login ${name} --label "<what you are doing>"\` — you are ${name} in this workspace.`
24
+ : '`beehaven agent login <name> --label "<what you are doing>"`, using your own name (claude, codex, gemini).';
25
+ return [
26
+ CLI_BLOCK_START,
27
+ '<!-- managed-by: clearly. Run `beehaven agents-md` to update; edit outside this block. -->',
28
+ '',
29
+ '## Clearly workspace',
30
+ '',
31
+ 'This project keeps its documents, plans, boards and tickets in Clearly. Use the `beehaven` CLI',
32
+ 'for anything the team should see, not files in this repo.',
33
+ '',
34
+ `- Sign in once per session: ${login}`,
35
+ ' If it says the daemon is not running: `beehaven start --headless`.',
36
+ '- Read the `clearly-agent` skill before anything else, and `clearly-canvas` before design work',
37
+ ' (`beehaven skills install` puts them in place).',
38
+ '- `beehaven actions` lists what you can call; `beehaven call <action> \'<json>\'` calls it.',
39
+ '- Search before you create, file work in the project it belongs to, and end the session with',
40
+ ' `beehaven agent handoff "what you did · what is next"` so the next one starts there.',
41
+ CLI_BLOCK_END,
42
+ ].join('\n');
43
+ }
44
+ /** The block for agents connected over MCP (the `clearly` server). */
45
+ export function agentsMdMcpBlock() {
46
+ return [
47
+ MCP_BLOCK_START,
48
+ '<!-- managed-by: clearly-mcp. Re-run the `clearly-agents-md` prompt to update; edit outside this block. -->',
49
+ '',
50
+ '## Clearly workspace (connected over MCP as `clearly`)',
51
+ '',
52
+ 'This project keeps its documents, plans, boards and tickets in Clearly. Use the `clearly_*`',
53
+ 'tools, not files in this repo, for anything the team should see.',
54
+ '',
55
+ '- Start with `clearly_catalog` to see what exists; search with `clearly_grep` / `clearly_glob`.',
56
+ '- Read with `clearly_read`, make new work with `clearly_create`, change it with `clearly_edit`,',
57
+ ' and discuss it with `clearly_comment`. They take a `target`: a path (`~/Q1/Plan.md`), a key',
58
+ ' (`CLR-42`), an id or an exact title. The extension names the type (`.md` document, `.csv`',
59
+ ' sheet, `.board.json` board); a trailing slash means a project.',
60
+ '- Search before you write, and write decisions back so the next session inherits them.',
61
+ '- If a job is not covered by a listed tool, say so rather than guessing an action name.',
62
+ MCP_BLOCK_END,
63
+ ].join('\n');
64
+ }
@@ -93,10 +93,15 @@ async function warnIfLocalDevDown() {
93
93
  * Linux; start on Windows. Failure is SURFACED — silent failure was the bug — so the person can
94
94
  * paste the URL themselves.
95
95
  */
96
- function openInBrowser(url) {
96
+ export function openInBrowser(url) {
97
97
  try {
98
- const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
99
- const proc = spawn(opener, [url], { stdio: 'ignore', detached: true });
98
+ // ⚠ Windows: `start` is a cmd.exe BUILTIN, not a program, so spawning it always failed (ENOENT)
99
+ // and login fell back to "paste this URL". The URL protocol handler is a real executable, and
100
+ // passing the URL as an argv entry keeps cmd from splitting it at `&` (CAN2-56).
101
+ const [opener, args] = process.platform === 'darwin' ? ['open', [url]]
102
+ : process.platform === 'win32' ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
103
+ : ['xdg-open', [url]];
104
+ const proc = spawn(opener, args, { stdio: 'ignore', detached: true, windowsHide: true });
100
105
  proc.unref();
101
106
  proc.on('error', (err) => {
102
107
  console.log(` ${color.yellow('!')} Couldn't auto-open browser: ${err.message}`);
@@ -260,7 +265,12 @@ export function persistBrowserLogin(captured) {
260
265
  userId: captured.userId,
261
266
  email: captured.email ?? null,
262
267
  displayName: captured.displayName ?? null,
263
- tokenStorage: 'keychain',
268
+ /**
269
+ * ⚠ SAY WHERE THE TOKEN REALLY IS (CAN2-65). This was always 'keychain', so on Windows —
270
+ * which has no keychain here — `whoami` reported a keychain that did not exist, and
271
+ * `Relay.loadConfig` / the refresh path shelled out to macOS `security` on every load.
272
+ */
273
+ tokenStorage: keychain.available ? 'keychain' : 'file',
264
274
  refreshToken: captured.refreshToken,
265
275
  token: captured.idToken,
266
276
  endpoint: dialUrl,
@@ -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 = 'd733c0004e58-dirty';
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-10T21:21Z';
58
+ export const BUILD_TIME = '2026-10-27T20:29Z';
59
59
  /**
60
60
  * The build identity as one short string, for `--version`.
61
61
  *
@@ -15,12 +15,13 @@
15
15
  * await bridge.callApp('recorder', 'record', { source: 'mic' });
16
16
  */
17
17
  import { createConnection } from 'net';
18
+ import { request as httpRequest } from 'http';
18
19
  import { existsSync, readFileSync, readdirSync } from 'fs';
19
20
  import { join } from 'path';
20
21
  import { homedir } from 'os';
21
22
  import { EventEmitter } from 'events';
22
23
  import { activeAgentWrap } from './agent-attribution.js';
23
- const IPC_SOCKET_PATH = '/tmp/beehaven-ipc.sock';
24
+ import { ipcSocketPath, watcherSocketPath, socketMayExist } from './ipc-path.js';
24
25
  const CONFIG_DIR = join(homedir(), '.beehaven');
25
26
  export class ClaudeBridge extends EventEmitter {
26
27
  socket = null;
@@ -39,11 +40,12 @@ export class ClaudeBridge extends EventEmitter {
39
40
  async connect() {
40
41
  if (this.connected)
41
42
  return true;
42
- if (!existsSync(IPC_SOCKET_PATH))
43
+ const ipcPath = ipcSocketPath();
44
+ if (!socketMayExist(ipcPath))
43
45
  return false;
44
46
  return new Promise((resolve) => {
45
47
  const timeout = setTimeout(() => resolve(false), 5_000);
46
- const socket = createConnection({ path: IPC_SOCKET_PATH });
48
+ const socket = createConnection({ path: ipcPath });
47
49
  this.socket = socket;
48
50
  socket.on('connect', () => {
49
51
  this.connected = true;
@@ -198,11 +200,12 @@ export class ClaudeBridge extends EventEmitter {
198
200
  }
199
201
  // ── Internal ───────────────────────────────────────────────────────
200
202
  sendEvent(hookEvent, extra = {}) {
201
- // Send via the watcher socket (HTTP POST to /tmp/beehaven.sock)
202
- // This is the same protocol mwah uses.
203
- if (!existsSync('/tmp/beehaven.sock'))
203
+ // Send via the watcher socket (HTTP POST to /event) — the same protocol mwah uses.
204
+ // ⚠ `http` is IMPORTED: this was `require('http')`, which is undefined in this ESM package,
205
+ // so every event threw a ReferenceError once the socket existed (CAN2-56).
206
+ const watcherPath = watcherSocketPath();
207
+ if (!socketMayExist(watcherPath))
204
208
  return;
205
- const http = require('http');
206
209
  const body = JSON.stringify({
207
210
  hook_event_name: hookEvent,
208
211
  session_id: this.sessionId,
@@ -211,8 +214,8 @@ export class ClaudeBridge extends EventEmitter {
211
214
  source: 'claude-code',
212
215
  ...extra,
213
216
  });
214
- const req = http.request({
215
- socketPath: '/tmp/beehaven.sock',
217
+ const req = httpRequest({
218
+ socketPath: watcherPath,
216
219
  path: '/event',
217
220
  method: 'POST',
218
221
  headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
package/dist/clearly.js CHANGED
@@ -26,6 +26,7 @@
26
26
  * product promises. GitHub / Linear / Slack ingestion is a separate project; nothing here
27
27
  * pretends otherwise, and `status` says so out loud.
28
28
  */
29
+ import './windows-hide.js'; // FIRST: no console windows flashing on Windows (CAN2-56)
29
30
  import { execFileSync, spawnSync } from 'node:child_process';
30
31
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
31
32
  import { dirname, join, resolve } from 'node:path';
@@ -47,12 +48,12 @@ const c = {
47
48
  red: (s) => `\x1b[31m${s}\x1b[0m`,
48
49
  };
49
50
  /**
50
- * ⚠ THE AGENT TURN IS THE SLOW PART. The server's `RESPOND_TIMEOUT_MS` is 300s (clearly CDS-90, was
51
- * 180s); this waits that plus the report's tail, so a turn that finishes on time is never abandoned
51
+ * ⚠ THE AGENT JOB IS THE SLOW PART. The server's `RESPOND_TIMEOUT_MS` is 900s (WDP3-62);
52
+ * this waits that plus the report's tail, so a turn that finishes on time is never abandoned
52
53
  * by its caller and a timeout here still means the turn itself ran out. The relay-forward window
53
54
  * follows the `_timeout` sent with the call.
54
55
  */
55
- export const TURN_MS = 330_000;
56
+ export const TURN_MS = 930_000;
56
57
  /**
57
58
  * ⚠ STDERR IS CAPTURED, NOT INHERITED. A failing probe is normal here — `HEAD~30` does not exist in
58
59
  * a repo with two commits — and with inherited stderr git printed
package/dist/cli-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
- return bound;
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');