beehaven 0.8.16

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,1442 @@
1
+ /**
2
+ * Daemon-side Omni — Claude Agent SDK integration. Wraps SDK `query()`
3
+ * calls into a streaming event loop that local IPC callers (FAB via DO
4
+ * relay, mwah CLI, beehaven CLI) can drive.
5
+ *
6
+ * IPC call: `omni:prompt` { prompt, sessionId?, model?, compositionId?, ... }
7
+ * Stream: subscribers see `agent:text-delta` / `agent:tool-call` /
8
+ * `agent:tool-result` / `agent:turn-complete` / `agent:done`
9
+ * events on the same channel mwah's CloudBridge listens for.
10
+ *
11
+ * Why a daemon module (not workspace.ts)?
12
+ * The DO runtime can't fork subprocesses; the SDK spawns Claude Code
13
+ * under the hood (Bash/Read/Edit are executed by a child process,
14
+ * even via `query()`). So the SDK fundamentally needs Node, and the
15
+ * daemon is the natural place.
16
+ *
17
+ * ⚠⚠ LOCAL IPC ONLY — AND THIS HEADER USED TO DOCUMENT THE OPPOSITE.
18
+ *
19
+ * It said: "The Workspace DO acts as a routing layer: forwards FAB prompts
20
+ * via `omni:prompt` over the relay, re-broadcasts the resulting `omni:event`
21
+ * envelopes as agent:* messages back to the web client."
22
+ *
23
+ * That path does not exist and must not be built. `daemon.ts` (at the
24
+ * registration of these very verbs) states it as a COMPLIANCE BOUNDARY, not
25
+ * a shortcut: "Cloud→daemon Claude orchestration is deliberately disabled
26
+ * (see relay.ts 'Anthropic credential use policy'). The IPC socket is chmod
27
+ * 0600, so the kernel guarantees the caller is this UNIX user driving their
28
+ * own subscription. Do NOT bridge these onto `relay.onDaemonRpcRequest` or
29
+ * any other inbound relay path — that is precisely the shape that was
30
+ * switched off."
31
+ *
32
+ * ⚠ So a reader who trusted this header was being pointed at the one
33
+ * integration the adjacent file forbids by name, with a policy reason. A
34
+ * stale comment is ordinarily documentation debt; a stale comment that
35
+ * describes a forbidden path as the design is an instruction to violate it.
36
+ *
37
+ * Callers reach these verbs over the LOCAL IPC socket only: the FAB through
38
+ * the desktop app's own IPC client, plus `mwah` and `beehaven` on this
39
+ * machine. Nothing inbound from the relay.
40
+ *
41
+ * Session model — one-shot-per-turn (#48):
42
+ * Earlier versions kept one persistent `query({prompt:AsyncIterable})`
43
+ * alive per session and pushed messages into it. That mode silently
44
+ * broke the SDK's `resume` option — every turn forked a new
45
+ * session_id. Switching to one `query()` per turn with explicit
46
+ * `resume: <sdkSessionId>` made cross-restart persistence work.
47
+ *
48
+ * Each prompt opens a fresh `query()`. The SDK's session_id from
49
+ * the init message is captured into OmniSession.sdkSessionId so
50
+ * subsequent turns thread it as `resume:` — same JSONL file is
51
+ * appended to, conversation persists across daemon restarts. On a
52
+ * fresh process, findLatestSdkSessionId() scans the composition's
53
+ * project dir on disk and primes the in-memory session with the
54
+ * latest session_id.
55
+ *
56
+ * Concurrency: one turn in flight per session. Subsequent calls to
57
+ * promptOmni queue FIFO via OmniSession.queuedPrompts and drain in
58
+ * the runTurn finally block.
59
+ *
60
+ * Auth:
61
+ * The SDK reads ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN from
62
+ * process env. We pass them through from the daemon's process.env —
63
+ * no separate token plumbing. If the user has Claude Code installed
64
+ * and logged in (subscription token), it Just Works.
65
+ *
66
+ * Nesting guard:
67
+ * The SDK refuses to start when CLAUDECODE=1 is set (it's how
68
+ * Claude Code detects "I'm being nested in another Claude Code
69
+ * session" and bails). We delete it from our env passthrough so the
70
+ * daemon-spawned SDK isn't blocked when the user happened to start
71
+ * the daemon from inside a Claude Code session.
72
+ */
73
+ import { query, SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from '@anthropic-ai/claude-agent-sdk';
74
+ import { buildToolGate } from './daemon-omni-tool-gate.js';
75
+ import { COMPANY_ADDON, companyModeEnabled, workContextBlock } from './daemon-omni-company.js';
76
+ import { ensureDefaultPoliciesRegistered } from './daemon-omni-policies.js';
77
+ import { getDefaultHooks, setCompactionEmitter } from './daemon-omni-hooks.js';
78
+ import { findClaudeBinary } from './claude-credential-store.js';
79
+ import { resolveBoundRepo } from './composition-repos.js';
80
+ import { createClearlyMcpServer } from './daemon-mcp-clearly.js';
81
+ import { createPrincipalMcpServer, clearPrincipalContext, } from './daemon-omni-principal.js';
82
+ // Cached path to the `claude` CLI binary. The Anthropic Agent SDK
83
+ // normally resolves this from its own platform-specific package
84
+ // (@anthropic-ai/claude-agent-sdk-{platform}-{arch}/claude), but when
85
+ // the Mac app's npm install ran with --omit=optional that package
86
+ // isn't present and the SDK errors with "Native CLI binary for
87
+ // {platform}-{arch} not found." `findClaudeBinary()` falls back to
88
+ // the user's own `claude login`-authed install at ~/.local/bin/claude,
89
+ // which inherits the same Keychain auth the SDK would have used
90
+ // anyway — so this resolves both the bundling and the auth issue in
91
+ // one step. Resolved once on first turn, cached for the daemon's
92
+ // lifetime (the path doesn't change between turns).
93
+ let _cachedClaudeBinary = undefined;
94
+ async function getClaudeBinary() {
95
+ if (_cachedClaudeBinary !== undefined)
96
+ return _cachedClaudeBinary;
97
+ _cachedClaudeBinary = await findClaudeBinary();
98
+ return _cachedClaudeBinary;
99
+ }
100
+ let _compactionEmitterWired = false;
101
+ function ensureCompactionEmitterWired() {
102
+ if (_compactionEmitterWired)
103
+ return;
104
+ _compactionEmitterWired = true;
105
+ setCompactionEmitter((event) => {
106
+ emit({
107
+ type: 'agent:compacting',
108
+ sessionId: event.sessionId,
109
+ kind: event.kind,
110
+ ...(event.messageCount != null ? { messageCount: event.messageCount } : {}),
111
+ ...(event.reason ? { reason: event.reason } : {}),
112
+ });
113
+ });
114
+ }
115
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, chmodSync, } from 'fs';
116
+ import { join, dirname, resolve as resolvePath } from 'path';
117
+ import { homedir } from 'os';
118
+ import { fileURLToPath } from 'url';
119
+ /**
120
+ * Decode the `text` property of an MCP tool argument while Claude is still
121
+ * producing the JSON. `JSON.parse` cannot help until the closing brace arrives,
122
+ * and emitting raw fragments would briefly show escaped characters (or a
123
+ * half-written unicode escape) in the chat. Keep the tiny decoder here beside
124
+ * the SDK stream adapter so principal-facing text has a real live path all the
125
+ * way to the desktop window.
126
+ */
127
+ class PrincipalTextReader {
128
+ buffer = '';
129
+ start = -1;
130
+ emitted = 0;
131
+ push(fragment) {
132
+ this.buffer += fragment;
133
+ if (this.start < 0) {
134
+ const match = /"text"\s*:\s*"/.exec(this.buffer);
135
+ if (!match)
136
+ return '';
137
+ this.start = match.index + match[0].length;
138
+ }
139
+ let decoded = '';
140
+ let index = this.start;
141
+ while (index < this.buffer.length) {
142
+ const ch = this.buffer[index];
143
+ if (ch === '"')
144
+ break;
145
+ if (ch === '\\') {
146
+ if (index + 1 >= this.buffer.length)
147
+ break;
148
+ const escape = this.buffer[index + 1];
149
+ if (escape === 'u') {
150
+ if (index + 6 > this.buffer.length)
151
+ break;
152
+ decoded += String.fromCharCode(parseInt(this.buffer.slice(index + 2, index + 6), 16));
153
+ index += 6;
154
+ continue;
155
+ }
156
+ decoded += { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' }[escape] ?? escape;
157
+ index += 2;
158
+ continue;
159
+ }
160
+ decoded += ch;
161
+ index += 1;
162
+ }
163
+ const fresh = decoded.slice(this.emitted);
164
+ this.emitted = decoded.length;
165
+ return fresh;
166
+ }
167
+ }
168
+ const sessions = new Map();
169
+ const subscribers = new Set();
170
+ // ── First-party beehaven tools ───────────────────────────────────────
171
+ //
172
+ // The whole beehaven surface — canvas, compositions, the avatar, brand —
173
+ // mounted as NATIVE MCP tools inside every daemon-claude session, not as
174
+ // shell-outs it has to discover.
175
+ //
176
+ // `daemon-mcp-clearly.ts` has always described this wiring in its own header
177
+ // and nothing ever performed it: `createClearlyMcpServer` was exported and
178
+ // called from nowhere, so the tools existed only as a design. That is the gap
179
+ // this closes. Before, reaching the canvas meant Claude writing
180
+ // `beehaven call canvas-act '<json>'` into Bash — a ~50-100ms node bootstrap
181
+ // per call, a shell-injection surface, and a permission gate that had to
182
+ // REGEX A COMMAND STRING to work out what was being attempted. Now it is
183
+ // `mcp__clearly__canvas_add_text` with a Zod-checked signature, in-process,
184
+ // and gate-able by name.
185
+ //
186
+ // Registered rather than imported-and-constructed because the context it needs
187
+ // (the relay socket, the local app manager) lives in daemon.ts's closure. Same
188
+ // shape as `setTierResolver` — daemon.ts owns the wiring, this module owns the
189
+ // use. Left unregistered (a CLI-hosted session, a unit test), the session is
190
+ // simply plain Claude Code, which is the correct degradation.
191
+ let _clearlyMcpServer = null;
192
+ /** Wire the first-party beehaven tool surface. Call once from the daemon. */
193
+ export function setClearlyTools(ctx) {
194
+ _clearlyMcpServer = ctx ? createClearlyMcpServer(ctx) : null;
195
+ }
196
+ /**
197
+ * OMNI'S OWN TOOLS — reply, ask, the board, design docs, the principal's screen.
198
+ *
199
+ * ⚠ SEPARATE FROM `clearly`, and deliberately so. The `clearly` surface is the
200
+ * WORKSPACE (canvas, compositions, images, vectors) and is useful to any agent.
201
+ * This one is the PRINCIPAL — a conversation, a question that blocks, a card, a
202
+ * window on someone's screen — and it only means anything when something is
203
+ * attached to render it. Mounting them as one server would make an unattended
204
+ * session (a cron, a CLI one-shot) advertise `reply_to_principal` to a model with
205
+ * nobody on the other end.
206
+ *
207
+ * Registered rather than constructed here for the same reason as `clearly`: the
208
+ * context it needs (the emit channel, the ask round-trip) lives in the caller's
209
+ * closure. Left unregistered, Omni is simply Claude Code with the beehaven
210
+ * tools — which is the correct degradation for a headless session.
211
+ */
212
+ let _principalMcpServer = null;
213
+ /**
214
+ * The session whose turn is running right now, for tagging principal events.
215
+ * Set by `runTurn`, and NOT cleared when it finishes — a tool result can arrive
216
+ * a tick after the turn's `finally`, and clearing would tag it with nothing.
217
+ * One turn runs at a time per daemon, so the last writer is always correct.
218
+ */
219
+ let _activeTurnSessionId = 'default';
220
+ export const activeTurnSessionId = () => _activeTurnSessionId;
221
+ /**
222
+ * Does a WINDOWING surface currently have the conversation — the Mac app, as
223
+ * opposed to a terminal?
224
+ *
225
+ * ⚠ SET BY THE SURFACE, NOT INFERRED. The daemon cannot tell a Mac-app client
226
+ * from a CLI one by looking at the socket, and guessing wrong in the permissive
227
+ * direction is the bad one: Omni would report "opened the board" to someone
228
+ * staring at a terminal. Declared explicitly, defaulting to FALSE, so a surface
229
+ * that never claims the capability never gets credited with it.
230
+ */
231
+ let _windowSurfaces = 0;
232
+ export function declareWindowSurface() {
233
+ _windowSurfaces++;
234
+ let released = false;
235
+ return () => {
236
+ // Idempotent — a double release must not drive the count negative and
237
+ // silently disable windows for a surface that is still attached.
238
+ if (released)
239
+ return;
240
+ released = true;
241
+ _windowSurfaces = Math.max(0, _windowSurfaces - 1);
242
+ };
243
+ }
244
+ export const hasWindowSurface = () => _windowSurfaces > 0;
245
+ export function setPrincipalTools(ctx) {
246
+ _principalMcpServer = ctx ? createPrincipalMcpServer(ctx) : null;
247
+ // Unmounting must also drop the context the GATE reads, or a daemon that has
248
+ // detached its principal would keep refusing edits against a board nothing can
249
+ // reach any more.
250
+ if (!ctx)
251
+ clearPrincipalContext();
252
+ }
253
+ export function principalToolsMounted() {
254
+ return !!_principalMcpServer;
255
+ }
256
+ /**
257
+ * OMNI'S IDENTITY — the standing prompt that makes this session Omni rather than
258
+ * a generic Claude Code.
259
+ *
260
+ * ⚠ A DAEMON-LEVEL DEFAULT, NOT A PER-CALL ARGUMENT, and that is the point. The
261
+ * identity used to be assembled inside the Mac app and handed to the session
262
+ * Electron spawned, so it reached exactly one caller: `beehaven agent`, the
263
+ * avatar panel and any CLI got an anonymous Claude with the same tools and none
264
+ * of the instructions for using them. Registered here, every entry point gets
265
+ * the same agent.
266
+ *
267
+ * ⚠ IT IS THE SYSTEM INSTRUCTION, NOT AN APPEND TO ONE. It used to ride in as
268
+ * `{preset:'claude_code', append}`, so the prompt the model actually read opened
269
+ * with Claude Code's own preset and Omni arrived underneath it as a footnote —
270
+ * the identity was a modifier on someone else's agent rather than the agent.
271
+ * Passed as a bare string (the SDK's custom-prompt form) it IS the prompt, which
272
+ * is what this file has claimed all along. The TOOL preset is unaffected and
273
+ * still `claude_code`: the tools were never the problem.
274
+ *
275
+ * A per-call `appendSystemPrompt` LAYERS ON TOP of it — see `resolveSystemPrompt`.
276
+ */
277
+ let _omniIdentity = '';
278
+ export function setOmniIdentity(prompt) {
279
+ _omniIdentity = String(prompt ?? '');
280
+ }
281
+ export const omniIdentitySet = () => !!_omniIdentity;
282
+ /**
283
+ * The registered identity text, for diagnostics.
284
+ *
285
+ * ⚠ THE TEXT, NOT A BOOLEAN. `omniIdentitySet()` answers "is one registered"; the company-mode
286
+ * diagnostic also reports PROMPT WEIGHT, and a boolean cannot be measured. Read-only — the setter
287
+ * above stays the only way to change it.
288
+ */
289
+ export const omniIdentityText = () => _omniIdentity;
290
+ /**
291
+ * ⚠ A PER-CALL PROMPT IS A MODIFIER, AND MUST NOT REPLACE THE IDENTITY.
292
+ *
293
+ * It used to replace it, on the premise that the one caller passing a prompt had
294
+ * assembled a full one (the Mac app's). That caller is gone — `ea-chat.ts` sends
295
+ * no prompt and relies on this default. The two callers that DO pass one send a
296
+ * fragment: `beehaven claude` sends dev mode, and a pipeline/tree/consensus stage
297
+ * sends its role ("You are the DRAFTER"). Both were silently running as an
298
+ * anonymous Claude Code holding Omni's tools — precisely the failure registering
299
+ * the identity here was meant to end.
300
+ *
301
+ * The identity leads and the modifier follows, split on the SDK's cache boundary
302
+ * so the ~20 KB identity is a cacheable prefix shared across every session while
303
+ * the per-call half stays session-local. A caller that genuinely carries the
304
+ * identity already is passed through untouched, which is the doubling the old
305
+ * replace-semantics existed to prevent.
306
+ *
307
+ * With no identity registered there is nothing to be, so the preset stays — a
308
+ * bare stage fragment as the whole system prompt would be worse than either.
309
+ */
310
+ export function resolveSystemPrompt(callerPrompt) {
311
+ const caller = String(callerPrompt ?? '').trim();
312
+ /**
313
+ * ⚠⚠ COMPANY MODE JOINS THE IDENTITY, BEFORE THE BOUNDARY — it is static per build, so it
314
+ * belongs in the CACHEABLE prefix. Appending it as a per-call modifier would push ~6 KB across
315
+ * the boundary on every turn, which is the exact cost this function was split to avoid.
316
+ *
317
+ * ⚠ AND IT FOLLOWS THE IDENTITY, NEVER REPLACES IT. Every safety property — what may be run, the
318
+ * git rules, what must never be done unasked, when to stop and ask — lives in the identity. A
319
+ * mode that swapped it out would trade all of that for a role-play.
320
+ */
321
+ const base = _omniIdentity && companyModeEnabled()
322
+ ? `${_omniIdentity}\n\n${COMPANY_ADDON}`
323
+ : _omniIdentity;
324
+ if (!base) {
325
+ /**
326
+ * ⚠ NO IDENTITY REGISTERED. The preset stays, and company mode still applies as an append —
327
+ * a bare operating manual as the WHOLE system prompt would be worse than either, but silently
328
+ * dropping the mode the operator switched on would be worse still.
329
+ */
330
+ const append = [companyModeEnabled() ? COMPANY_ADDON : '', caller].filter(Boolean).join('\n\n');
331
+ return { type: 'preset', preset: 'claude_code', ...(append ? { append } : {}) };
332
+ }
333
+ if (!caller)
334
+ return base;
335
+ if (caller.includes(base))
336
+ return caller;
337
+ return [base, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, caller];
338
+ }
339
+ const pendingAsks = new Map();
340
+ /**
341
+ * ⚠ A TIMEOUT IS NOT AN ANSWER. It resolves with `outcome:'timeout'` so the
342
+ * agent can tell "they said no" from "they never saw it" — opposite situations,
343
+ * and collapsing them is how an agent decides silence means consent.
344
+ */
345
+ const ASK_TIMEOUT_MS = 10 * 60_000;
346
+ /** Answer a pending ask. Safe to call twice or with an unknown id — a late click
347
+ * after a timeout and a double tap both land here and must be no-ops. */
348
+ export function answerOmniAsk(askId, value, outcome = 'answered') {
349
+ const p = pendingAsks.get(askId);
350
+ if (!p)
351
+ return false;
352
+ clearTimeout(p.timer);
353
+ pendingAsks.delete(askId);
354
+ p.resolve({ value, outcome });
355
+ return true;
356
+ }
357
+ /** Settle every open ask. Called on session teardown, or the turn's await is
358
+ * orphaned inside a query that is being disposed. */
359
+ export function dismissOmniAsks() {
360
+ for (const id of [...pendingAsks.keys()])
361
+ answerOmniAsk(id, '', 'dismissed');
362
+ }
363
+ /**
364
+ * Build the principal context for a session and mount it.
365
+ *
366
+ * ⚠ ONE CALL, because the three halves must agree on the sessionId: `emit` tags
367
+ * events with it, `ask` tags questions with it, and a subscriber filters on it.
368
+ * Wiring them separately is how a two-conversation daemon starts rendering one
369
+ * session's questions into the other's panel.
370
+ */
371
+ export function mountPrincipalTools(opts) {
372
+ setPrincipalTools({
373
+ invokeRpc: opts.invokeRpc,
374
+ cwd: opts.cwd,
375
+ canOpenWindows: opts.canOpenWindows,
376
+ emit: (event) => {
377
+ emit({ type: 'agent:principal', sessionId: opts.sessionId(), event });
378
+ },
379
+ ask: (ask) => new Promise((resolve) => {
380
+ const askId = `ask_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
381
+ const timer = setTimeout(() => {
382
+ pendingAsks.delete(askId);
383
+ resolve({ value: '', outcome: 'timeout' });
384
+ }, ASK_TIMEOUT_MS);
385
+ // Never hold the daemon open for a question nobody is going to answer.
386
+ timer.unref?.();
387
+ pendingAsks.set(askId, { resolve, timer });
388
+ emit({ type: 'agent:ask', sessionId: opts.sessionId(), askId, ask });
389
+ }),
390
+ });
391
+ }
392
+ /** Whether the beehaven tools are mounted — for `omni:sessions` / diagnostics. */
393
+ export function clearlyToolsMounted() {
394
+ return !!_clearlyMcpServer;
395
+ }
396
+ // ── Idle session cleanup (#49) ───────────────────────────────────────
397
+ //
398
+ // Each composition opens an SDK session that lives in memory until the
399
+ // daemon process ends. Without cleanup, a user who opens 50 compositions
400
+ // over a day accumulates 50 warm SDK loops in the daemon — eats memory
401
+ // + risks file-descriptor exhaustion.
402
+ //
403
+ // Strategy: every minute, walk the session Map. Anything idle longer
404
+ // than IDLE_TIMEOUT_MS gets endSession()'d. The SDK's JSONL on disk
405
+ // survives — so when the user comes back to that composition, the
406
+ // next promptOmni call re-primes from disk via findLatestSdkSessionId.
407
+ // User-invisible reuse vs cold-start: a cold start costs one round-trip
408
+ // to read the JSONL (~30ms) on the first prompt.
409
+ //
410
+ // Disabling: set BEEHAVEN_OMNI_IDLE_TIMEOUT_MS=0 to keep all sessions
411
+ // alive forever (useful when debugging concurrency / cross-session
412
+ // pollution issues; never set in production).
413
+ const IDLE_TIMEOUT_MS = (() => {
414
+ const env = process.env.BEEHAVEN_OMNI_IDLE_TIMEOUT_MS;
415
+ if (env != null) {
416
+ const n = Number(env);
417
+ if (Number.isFinite(n) && n >= 0)
418
+ return n;
419
+ }
420
+ return 30 * 60_000; // 30 min default
421
+ })();
422
+ const CLEANUP_INTERVAL_MS = 60_000;
423
+ let cleanupTimer = null;
424
+ function reapIdleSessions() {
425
+ if (IDLE_TIMEOUT_MS === 0)
426
+ return 0;
427
+ const now = Date.now();
428
+ let reaped = 0;
429
+ for (const [id, sess] of sessions) {
430
+ if (sess.turnInFlight)
431
+ continue; // never reap a session mid-turn
432
+ if (sess.queuedPrompts.length > 0)
433
+ continue; // pending work pending
434
+ if (now - sess.lastActivityAt > IDLE_TIMEOUT_MS) {
435
+ endSession(id);
436
+ reaped++;
437
+ }
438
+ }
439
+ return reaped;
440
+ }
441
+ function ensureCleanupTimer() {
442
+ if (cleanupTimer)
443
+ return;
444
+ if (IDLE_TIMEOUT_MS === 0)
445
+ return; // cleanup disabled
446
+ cleanupTimer = setInterval(() => {
447
+ const reaped = reapIdleSessions();
448
+ if (reaped > 0) {
449
+ // eslint-disable-next-line no-console
450
+ console.log(`[omni] reaped ${reaped} idle session(s); ${sessions.size} remain`);
451
+ }
452
+ }, CLEANUP_INTERVAL_MS);
453
+ // Don't keep the daemon process alive just for the sweep timer.
454
+ if (typeof cleanupTimer?.unref === 'function')
455
+ cleanupTimer.unref();
456
+ }
457
+ // ── Subscriber registration ─────────────────────────────────────────
458
+ export function subscribe(fn) {
459
+ subscribers.add(fn);
460
+ return () => subscribers.delete(fn);
461
+ }
462
+ function emit(event) {
463
+ for (const fn of subscribers) {
464
+ try {
465
+ fn(event);
466
+ }
467
+ catch {
468
+ /* subscriber errors don't sink the loop */
469
+ }
470
+ }
471
+ }
472
+ // ── Composition cwd resolution (#47) ────────────────────────────────
473
+ /**
474
+ * Resolve + initialize a composition-scoped working directory.
475
+ *
476
+ * ~/.clearly/compositions/<compositionId>/
477
+ * ├── CLAUDE.md ← auto-seeded; tells Claude where it is
478
+ * └── .clearly-composition.json ← machine-readable metadata
479
+ *
480
+ * Idempotent. Creates the dir + seeds files only on first call for a
481
+ * given compositionId. Subsequent sessions for the same composition
482
+ * reuse what's already there.
483
+ *
484
+ * The seeded CLAUDE.md is loaded automatically by the SDK because we
485
+ * set `settingSources: ['user','project','local']` in the session
486
+ * options — that includes `project` which triggers CLAUDE.md discovery
487
+ * from the cwd upward. So Claude opens the session already knowing
488
+ * which composition it's in, no per-turn prompt overhead.
489
+ */
490
+ function resolveCompositionCwd(opts) {
491
+ const home = homedir();
492
+ // A composition bound to a real local repo wins: use it as the cwd
493
+ // verbatim and leave its CLAUDE.md untouched (the repo owns its own
494
+ // context). This is what makes a board 1:1 with the Claude Code project
495
+ // for that repo — the SDK runs in `<repo>` and its conversation lands in
496
+ // `~/.claude/projects/<encoded-repo-path>/`. Read fresh each call, so a
497
+ // new binding takes effect on the next session without a daemon restart.
498
+ // Unbound compositions fall through to the scratch-dir behavior below.
499
+ const boundRepo = resolveBoundRepo(opts.compositionId);
500
+ if (boundRepo)
501
+ return boundRepo;
502
+ const compDir = join(home, '.clearly', 'compositions', opts.compositionId);
503
+ try {
504
+ if (!existsSync(compDir)) {
505
+ mkdirSync(compDir, { recursive: true });
506
+ }
507
+ // Seed CLAUDE.md once. User edits OUTSIDE the AUTO-CONTEXT block
508
+ // are preserved — refresh only swaps the marker-delimited section.
509
+ const claudeMd = join(compDir, 'CLAUDE.md');
510
+ if (!existsSync(claudeMd)) {
511
+ writeFileSync(claudeMd, buildSeedClaudeMd(opts), 'utf8');
512
+ }
513
+ else if (opts.refresh) {
514
+ const current = (() => {
515
+ try {
516
+ return readFileSync(claudeMd, 'utf8');
517
+ }
518
+ catch {
519
+ return '';
520
+ }
521
+ })();
522
+ const updated = updateAutoContextBlock(current, opts);
523
+ if (updated !== current)
524
+ writeFileSync(claudeMd, updated, 'utf8');
525
+ }
526
+ // Refresh the metadata file every time — composition title can change,
527
+ // and consumers should see the latest. Small payload, safe to rewrite.
528
+ const meta = {
529
+ compositionId: opts.compositionId,
530
+ compositionTitle: opts.compositionTitle ?? null,
531
+ brand: opts.brand ?? null,
532
+ kind: 'clearly.composition',
533
+ version: 1,
534
+ updatedAt: new Date().toISOString(),
535
+ };
536
+ writeFileSync(join(compDir, '.clearly-composition.json'), JSON.stringify(meta, null, 2), 'utf8');
537
+ }
538
+ catch (err) {
539
+ // Don't fail the session if seeding fails — fall through to using
540
+ // the dir as-is or the default cwd. Worst case: Claude is missing
541
+ // the seeded CLAUDE.md but the session still works.
542
+ // eslint-disable-next-line no-console
543
+ console.warn(`[omni] composition cwd seed failed for ${opts.compositionId}: ${err?.message ?? err}`);
544
+ }
545
+ return compDir;
546
+ }
547
+ /**
548
+ * Refresh the per-composition CLAUDE.md from outside the session
549
+ * spawn path (#114). Called by the daemon when:
550
+ * • the worker broadcasts a brand-update for this composition
551
+ * • the user opens / focuses the composition (canvas-open IPC)
552
+ *
553
+ * Idempotent: noop when the AUTO-CONTEXT block matches what we'd write.
554
+ * Safe to call before the composition's cwd exists — `mkdirSync` is
555
+ * recursive.
556
+ */
557
+ export function refreshCompositionClaudeMd(opts) {
558
+ return resolveCompositionCwd({ ...opts, refresh: true });
559
+ }
560
+ const AUTO_CONTEXT_BEGIN = '<!-- BEGIN CLEARLY AUTO-CONTEXT -->';
561
+ const AUTO_CONTEXT_END = '<!-- END CLEARLY AUTO-CONTEXT -->';
562
+ /** Replace the AUTO-CONTEXT block in an existing CLAUDE.md. Preserves
563
+ * everything outside the markers. If the markers are missing (legacy
564
+ * seed format), prepend the block above the existing content. */
565
+ function updateAutoContextBlock(current, opts) {
566
+ const block = `${AUTO_CONTEXT_BEGIN}\n${buildAutoContext(opts)}\n${AUTO_CONTEXT_END}`;
567
+ const beginIdx = current.indexOf(AUTO_CONTEXT_BEGIN);
568
+ const endIdx = current.indexOf(AUTO_CONTEXT_END);
569
+ if (beginIdx >= 0 && endIdx > beginIdx) {
570
+ const before = current.slice(0, beginIdx);
571
+ const after = current.slice(endIdx + AUTO_CONTEXT_END.length);
572
+ return `${before}${block}${after}`;
573
+ }
574
+ return `${block}\n\n${current}`;
575
+ }
576
+ /**
577
+ * Find the most recent SDK conversation file for a given cwd.
578
+ *
579
+ * Claude Code's SDK writes session state to:
580
+ * ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
581
+ *
582
+ * The encoding strategy: replace each `/` in the absolute cwd with `-`.
583
+ * `/Users/gemini/.clearly/compositions/abc/` → `-Users-gemini--clearly-compositions-abc`
584
+ *
585
+ * We use this to explicitly `resume` the latest session on subsequent
586
+ * daemon starts. The SDK's `continue: true` is supposed to do this
587
+ * automatically, but in our AsyncIterable input-stream mode it
588
+ * inconsistently forks new sessions instead of resuming — passing an
589
+ * explicit `resume: <session-id>` is reliable.
590
+ *
591
+ * Returns the session UUID (filename without .jsonl extension) of the
592
+ * most recently modified JSONL in the project dir, or null if none.
593
+ */
594
+ function findLatestSdkSessionId(cwd) {
595
+ // Encode the cwd the same way Claude Code does: EVERY non-alphanumeric
596
+ // char becomes `-`, substituted independently (no collapsing of runs).
597
+ // Verified against ~/.claude/projects/:
598
+ // /Users/gemini/.clearly/compositions/abc → -Users-gemini--clearly-compositions-abc
599
+ // /Users/gemini/projects/motion_studio → -Users-gemini-projects-motion-studio
600
+ // NB: the earlier `/[\/.]/g` only mapped `/` and `.`, so it left `_`
601
+ // (and spaces, etc.) intact — for a repo path like `…/motion_studio`
602
+ // it computed `…-motion_studio` and never found the real `…-motion-studio`
603
+ // project dir, silently starting a fresh session instead of resuming.
604
+ // Broadening to all non-alphanumerics makes resume work for bound repos
605
+ // whose paths contain `_` and other special chars (#47b).
606
+ const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-');
607
+ const projectDir = join(homedir(), '.claude', 'projects', encoded);
608
+ if (!existsSync(projectDir))
609
+ return null;
610
+ try {
611
+ const jsonls = readdirSync(projectDir)
612
+ .filter((f) => f.endsWith('.jsonl'))
613
+ .map((f) => {
614
+ const full = join(projectDir, f);
615
+ try {
616
+ return { id: f.replace(/\.jsonl$/, ''), mtime: statSync(full).mtimeMs };
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ })
622
+ .filter((x) => x !== null)
623
+ .sort((a, b) => b.mtime - a.mtime);
624
+ return jsonls[0]?.id ?? null;
625
+ }
626
+ catch {
627
+ return null;
628
+ }
629
+ }
630
+ /** Build the AUTO-CONTEXT block content — the part of CLAUDE.md the
631
+ * daemon refreshes on brand-update + composition-open. User-edited
632
+ * content outside the markers is preserved. */
633
+ function buildAutoContext(opts) {
634
+ const titleLine = opts.compositionTitle
635
+ ? `# ${opts.compositionTitle}\n\nComposition id: \`${opts.compositionId}\``
636
+ : `# Composition \`${opts.compositionId}\``;
637
+ const brandSection = (() => {
638
+ if (!opts.brand)
639
+ return '';
640
+ const lines = ['\n**Active brand**'];
641
+ if (opts.brand.name)
642
+ lines.push(`- Name: ${opts.brand.name}`);
643
+ if (opts.brand.palette?.length)
644
+ lines.push(`- Palette: ${opts.brand.palette.slice(0, 8).join(' · ')}`);
645
+ if (opts.brand.typography?.heading || opts.brand.typography?.body) {
646
+ const parts = [];
647
+ if (opts.brand.typography.heading)
648
+ parts.push(`heading "${opts.brand.typography.heading}"`);
649
+ if (opts.brand.typography.body)
650
+ parts.push(`body "${opts.brand.typography.body}"`);
651
+ lines.push(`- Typography: ${parts.join(', ')}`);
652
+ }
653
+ if (opts.brand.voice)
654
+ lines.push(`- Voice: ${opts.brand.voice}`);
655
+ lines.push(`- Apply these to anything you produce (canvas blocks, exports, Remotion components).`);
656
+ return lines.join('\n') + '\n';
657
+ })();
658
+ const updatedAt = new Date().toISOString();
659
+ return `${titleLine}
660
+
661
+ You are inside a Clearly composition. This directory is the scratch
662
+ workspace for this composition only — files you Read/Write here persist
663
+ with it, separately from your other compositions.
664
+
665
+ **Where you are**
666
+ - This dir: \`~/.clearly/compositions/${opts.compositionId}/\`
667
+ - The user's other projects live elsewhere (typically \`~/projects/\`).
668
+ cd into them with Bash when the user references a specific project;
669
+ this composition's CLAUDE.md chain doesn't include those projects'
670
+ CLAUDE.mds unless you've cd'd there.
671
+
672
+ **The canvas**
673
+ The user is looking at a spatial editor canvas. Use the canvas RPCs
674
+ (canvas-add-text, canvas-add-document, canvas-add-album, etc.) via
675
+ \`beehaven call <action> '<json>'\` to add blocks, documents, and
676
+ albums that show up immediately for them.
677
+ ${brandSection}
678
+ **Persistence**
679
+ Your conversation persists across daemon restarts via the SDK's session
680
+ JSONL. Continuing a conversation: open this composition again — same
681
+ cwd, same session.
682
+
683
+ _Auto-context refreshed at ${updatedAt}. Anything you write OUTSIDE the
684
+ \`BEGIN/END CLEARLY AUTO-CONTEXT\` markers is preserved across refreshes._`;
685
+ }
686
+ /** Build the seed CLAUDE.md content for a fresh composition directory.
687
+ * Kept terse — the user's `~/.claude/CLAUDE.md` carries their global
688
+ * preferences; this file only adds composition-specific framing. The
689
+ * auto-context block lives between markers so daemon-side refreshes
690
+ * (brand-update, canvas-open) can swap it in place without clobbering
691
+ * user edits below. */
692
+ function buildSeedClaudeMd(opts) {
693
+ return `${AUTO_CONTEXT_BEGIN}
694
+ ${buildAutoContext(opts)}
695
+ ${AUTO_CONTEXT_END}
696
+
697
+ <!-- Your additions go below this line — they're preserved across
698
+ auto-context refreshes. -->
699
+ `;
700
+ }
701
+ /** Default max prompts queued per session before promptOmni rejects new
702
+ * prompts. Configurable per-session via StartOmniSessionOptions.maxQueuedPrompts. */
703
+ const DEFAULT_MAX_QUEUED_PROMPTS = 20;
704
+ /** Default max Claude<>tool turn-cycles per prompt. The SDK's own knob;
705
+ * we set a sane upper bound so a buggy tool loop doesn't burn budget. */
706
+ const DEFAULT_MAX_TURNS = 30;
707
+ /**
708
+ * Look up or create an Omni session. **Doesn't start the SDK** — that
709
+ * happens per-turn now. This function just sets up the per-session
710
+ * state (resolved cwd, captured config, turn counter). The actual
711
+ * SDK calls happen inside `runTurn()` below.
712
+ *
713
+ * If we're resuming a composition that has prior JSONL session files,
714
+ * the session's `sdkSessionId` is primed from the latest JSONL so the
715
+ * first turn after a daemon restart resumes the right conversation.
716
+ */
717
+ function getOrCreateSession(opts) {
718
+ const existing = sessions.get(opts.sessionId);
719
+ if (existing)
720
+ return existing;
721
+ // Cwd resolution order (#47):
722
+ // 1. compositionId → ~/.clearly/compositions/<id>/ (auto-seeded)
723
+ // 2. explicit opts.cwd
724
+ // 3. $HOME (default for non-composition sessions)
725
+ // 4. process.cwd() (only when HOME is unset — Windows/weird envs)
726
+ const cwd = opts.compositionId
727
+ ? resolveCompositionCwd({
728
+ compositionId: opts.compositionId,
729
+ compositionTitle: opts.compositionTitle,
730
+ })
731
+ : (opts.cwd ?? process.env.HOME ?? process.cwd());
732
+ // Resume the latest JSONL session for this cwd if one exists (#48).
733
+ // Survives daemon restarts: even though our in-memory `sessions`
734
+ // Map is empty after restart, the SDK's project dir on disk still
735
+ // holds the conversation. We pick up the most-recently-modified
736
+ // JSONL and thread its session_id as `resume:` on every turn.
737
+ let primedSdkSessionId = null;
738
+ if (opts.resumeSessionId) {
739
+ primedSdkSessionId = opts.resumeSessionId;
740
+ }
741
+ else if (opts.continueSession !== false && opts.compositionId) {
742
+ primedSdkSessionId = findLatestSdkSessionId(cwd);
743
+ }
744
+ const session = {
745
+ sessionId: opts.sessionId,
746
+ userId: opts.userId,
747
+ config: opts,
748
+ cwd,
749
+ sdkSessionId: primedSdkSessionId,
750
+ ended: false,
751
+ turnInFlight: false,
752
+ queuedPrompts: [],
753
+ currentText: '',
754
+ principalStreamIdx: null,
755
+ principalTextReader: null,
756
+ turnIndex: 0,
757
+ startedAt: Date.now(),
758
+ lastActivityAt: Date.now(),
759
+ };
760
+ sessions.set(opts.sessionId, session);
761
+ // First-session lazy-start of the idle-cleanup sweeper. Idempotent —
762
+ // subsequent calls no-op. The timer is `unref()`d so it doesn't
763
+ // pin the event loop.
764
+ ensureCleanupTimer();
765
+ // Auto-register default tool-gate policies (#60) on first session.
766
+ // Idempotent. New policies plug in here.
767
+ //
768
+ // (#62 "sandbox-write" is NOT among them. A `daemon-write-sandbox.ts`
769
+ // existed for months describing itself as the guard that stops a buggy
770
+ // agent trashing the user's filesystem — and nothing ever imported it.
771
+ // It was removed rather than left looking wired; recover it with
772
+ // `git show db8580c:packages/cli/src/daemon-write-sandbox.ts` if the
773
+ // sandbox-then-commit flow gets built for real. Note the open design
774
+ // problem it left: writes land in a sandbox and only reach disk on
775
+ // commit, so an un-wired commit path silently loses the user's work —
776
+ // which is why it needs the approval UI, not just this registration.)
777
+ ensureDefaultPoliciesRegistered();
778
+ // Wire compaction observability (#99). Idempotent.
779
+ ensureCompactionEmitterWired();
780
+ return session;
781
+ }
782
+ /**
783
+ * Build the SDK Options for a single turn. Called once per turn so
784
+ * the `resume` option reflects the LATEST captured sdkSessionId.
785
+ * Everything else (model, cwd, tools, settingSources, etc.) is static
786
+ * per session and just re-passed each call.
787
+ */
788
+ // Resolve once: write a `beehaven` shim into ~/.beehaven/bin and return that
789
+ // dir. Spawned Claude Code sessions can then run `beehaven …` from Bash even
790
+ // when the daemon's own PATH (a Finder-launched Mac app) doesn't include the
791
+ // npm-linked wrapper. The shim just execs the daemon's own cli.js with node.
792
+ let _beehavenBinDir;
793
+ function ensureBeehavenOnPath() {
794
+ if (_beehavenBinDir !== undefined)
795
+ return _beehavenBinDir;
796
+ try {
797
+ const cliJs = join(dirname(fileURLToPath(import.meta.url)), 'cli.js');
798
+ if (!existsSync(cliJs)) {
799
+ _beehavenBinDir = null;
800
+ return null;
801
+ }
802
+ const binDir = join(homedir(), '.beehaven', 'bin');
803
+ mkdirSync(binDir, { recursive: true });
804
+ const wrapper = join(binDir, 'beehaven');
805
+ writeFileSync(wrapper, `#!/usr/bin/env bash\nexec "${process.execPath}" "${cliJs}" "$@"\n`, {
806
+ mode: 0o755,
807
+ });
808
+ try {
809
+ chmodSync(wrapper, 0o755);
810
+ }
811
+ catch {
812
+ /* write mode usually suffices */
813
+ }
814
+ _beehavenBinDir = binDir;
815
+ return binDir;
816
+ }
817
+ catch {
818
+ _beehavenBinDir = null;
819
+ return null;
820
+ }
821
+ }
822
+ /**
823
+ * ⚠ `turnContext` IS PER TURN, EVERYTHING ELSE IS PER SESSION. Where the work stands changes
824
+ * between turns — somebody moves the card, a gate gets met — so it cannot be baked into the
825
+ * session's `appendSystemPrompt` at creation. It is passed in and composed after the identity.
826
+ */
827
+ function buildSdkOptions(session, claudeBinary, turnContext) {
828
+ const opts = session.config;
829
+ // Strip the nesting guard from env. The SDK refuses to spawn when
830
+ // CLAUDECODE=1 is set (its way of refusing to recurse). Daemon may
831
+ // have inherited it from whoever launched it.
832
+ const env = { ...process.env };
833
+ delete env.CLAUDECODE;
834
+ // Sign the spawned session in as this avatar's agent identity so its
835
+ // `beehaven speak` (and every `beehaven` call) runs AS that agent — which
836
+ // is what makes speak use the avatar's configured voice. Also ensure
837
+ // `beehaven` resolves on the child's PATH.
838
+ if (opts.agentName) {
839
+ env.BEEHAVEN_AGENT = opts.agentName;
840
+ const binDir = ensureBeehavenOnPath();
841
+ if (binDir)
842
+ env.PATH = `${binDir}:${env.PATH ?? ''}`;
843
+ }
844
+ return {
845
+ model: opts.model ?? 'claude-sonnet-4-6',
846
+ cwd: session.cwd,
847
+ env,
848
+ // Use the user's installed `claude` binary when available. Avoids
849
+ // the "Native CLI binary for {platform}-{arch} not found" error
850
+ // that fires when the bundled @anthropic-ai/claude-agent-sdk-*
851
+ // platform package isn't shipped with the Mac app, AND lets the
852
+ // SDK inherit the user's `claude login` auth from macOS Keychain
853
+ // automatically. When null (no local claude install), the SDK
854
+ // falls back to its own resolution and will error if the native
855
+ // package is also missing — that's the surface the caller needs
856
+ // to fix (install Claude Code, or set ANTHROPIC_API_KEY env var).
857
+ ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
858
+ tools: { type: 'preset', preset: 'claude_code' },
859
+ settingSources: ['user', 'project', 'local'],
860
+ skills: 'all',
861
+ // Omni's registered identity AS the system instruction, with the caller's
862
+ // per-call prompt layered after it. See `resolveSystemPrompt`.
863
+ // ⚠ The per-turn block follows the session's own modifier; both sit after the cache boundary,
864
+ // which is where volatile text belongs.
865
+ systemPrompt: resolveSystemPrompt([opts.appendSystemPrompt, turnContext].filter(Boolean).join('\n\n')),
866
+ ...(opts.allowedTools ? { allowedTools: opts.allowedTools } : {}),
867
+ ...(opts.additionalDirectories?.length
868
+ ? { additionalDirectories: opts.additionalDirectories }
869
+ : {}),
870
+ // First-party beehaven tools, then anything the caller layered on. The
871
+ // caller wins on a key collision so a session can still substitute its own
872
+ // `clearly` server (tests, a scoped surface) without editing this.
873
+ ...(() => {
874
+ const servers = {
875
+ ...(_clearlyMcpServer ? { clearly: _clearlyMcpServer } : {}),
876
+ // Omni's own surface, mounted when something is attached to render it.
877
+ ...(_principalMcpServer ? { principal: _principalMcpServer } : {}),
878
+ ...(opts.mcpServers ?? {}),
879
+ };
880
+ return Object.keys(servers).length ? { mcpServers: servers } : {};
881
+ })(),
882
+ // Resume / fork resolution:
883
+ // • forkFromSdkSessionId set → resume that id with forkSession:true
884
+ // so the SDK clones the prefix into a new session id. Used by
885
+ // branchSession (#79). After the first turn captures the fork's
886
+ // new session_id into sdkSessionId, the flag is cleared.
887
+ // • sdkSessionId set (no fork pending) → normal resume (#48).
888
+ // • neither → fresh session, SDK starts a new JSONL.
889
+ ...(session.forkFromSdkSessionId
890
+ ? { resume: session.forkFromSdkSessionId, forkSession: true }
891
+ : session.sdkSessionId
892
+ ? { resume: session.sdkSessionId }
893
+ : {}),
894
+ // canUseTool framework (#58). The SDK calls this before every tool
895
+ // execution. Our gate composes N policies (cost gates, tier checks,
896
+ // path transforms, FAB approval cards, …) — see daemon-omni-tool-
897
+ // gate.ts. Default policy set is empty; sibling modules register
898
+ // policies they care about (e.g. #60-63 plug in here).
899
+ //
900
+ // Note: when permissionMode is 'bypassPermissions', the SDK
901
+ // SHORT-CIRCUITS canUseTool calls — we still pass it through so the
902
+ // gate runs when callers switch to 'default' or 'dontAsk' mode.
903
+ canUseTool: buildToolGate({ sessionId: session.sessionId, userId: session.userId }),
904
+ // Default SDK hooks (#84 + future #83/#85). PostToolUse fires
905
+ // after each tool call; our hook sniffs Bash output for known
906
+ // error patterns and injects a remediation hint into Claude's
907
+ // next-turn context. See daemon-omni-hooks.ts.
908
+ hooks: getDefaultHooks(),
909
+ includePartialMessages: true,
910
+ // Permission mode + maxTurns are now caller-configurable (see
911
+ // StartOmniSessionOptions). Defaults:
912
+ // - permissionMode: 'default' (was 'bypassPermissions') — the SDK's
913
+ // built-in safety prompts run AND our canUseTool gate runs.
914
+ // bypassPermissions short-circuits the gate, so changing the
915
+ // default fixes the "no safety surface" concern raised in audit
916
+ // task #63.
917
+ // - maxTurns: 30 — prevents runaway tool-call loops billing the
918
+ // user without bound. Disable explicitly by passing 0.
919
+ permissionMode: opts.permissionMode ?? 'default',
920
+ ...(opts.permissionMode === 'bypassPermissions'
921
+ ? { allowDangerouslySkipPermissions: true }
922
+ : {}),
923
+ ...((opts.maxTurns ?? DEFAULT_MAX_TURNS) > 0
924
+ ? { maxTurns: opts.maxTurns ?? DEFAULT_MAX_TURNS }
925
+ : {}),
926
+ ...(opts.abortController ? { abortController: opts.abortController } : {}),
927
+ };
928
+ }
929
+ /**
930
+ * Run a single Claude turn. One-shot string prompt → one `query()`
931
+ * call → consume messages until `result`. Captures the SDK's
932
+ * session_id from the init message so subsequent turns can resume the
933
+ * same JSONL.
934
+ *
935
+ * Architectural note: we previously used the SDK's AsyncIterable input
936
+ * mode (`query({ prompt: asyncIterable })`) to keep one query() alive
937
+ * across turns. That mode silently broke `resume` — the SDK forked a
938
+ * new session_id on every turn. Switching to one query() per turn
939
+ * with explicit `resume: sdkSessionId` made cross-restart persistence
940
+ * work reliably. See #48.
941
+ */
942
+ async function runTurn(session, prompt) {
943
+ if (session.ended)
944
+ return;
945
+ session.turnInFlight = true;
946
+ session.currentText = '';
947
+ session.principalStreamIdx = null;
948
+ session.principalTextReader = null;
949
+ const turnStart = Date.now();
950
+ /**
951
+ * ⚠ ONE CONTROLLER PER TURN, held on the session so `interruptSession` can
952
+ * reach it.
953
+ *
954
+ * `abortController` was already an OPTION on the session config and nothing
955
+ * ever created one, so there was no way to stop a turn in flight — the only
956
+ * interrupt available was ending the session, which loses the conversation.
957
+ * A per-turn controller is the right lifetime: aborting turn 3 must not leave
958
+ * a tripped signal that kills turn 4 the moment it starts.
959
+ */
960
+ const abort = new AbortController();
961
+ session.abort = abort;
962
+ /**
963
+ * ⚠ WHICH SESSION THE PRINCIPAL TOOLS ARE SPEAKING FOR.
964
+ *
965
+ * A tool handler gets its arguments and nothing else — no session, no caller.
966
+ * So `reply_to_principal` cannot know which conversation it belongs to, and
967
+ * every principal event has to be tagged from here.
968
+ *
969
+ * The first version hardcoded `'omni'` at mount time. Sessions are keyed by
970
+ * the caller (`'default'` unless named), so every reply was broadcast under a
971
+ * session id no subscriber was listening for: the tool call succeeded, the
972
+ * event went out, and the chat showed nothing. Found by calling it.
973
+ */
974
+ _activeTurnSessionId = session.sessionId;
975
+ try {
976
+ const claudeBinary = await getClaudeBinary();
977
+ /**
978
+ * ⚠⚠ REFUSE HERE, IN WORDS SOMEBODY CAN ACT ON.
979
+ *
980
+ * The native binary is NOT bundled — the SDK's eight optional platform packages are not
981
+ * installed in this monorepo and no build copies one, because each is ~200 MB against a
982
+ * 561 MB app. So on a machine that has never installed Claude Code, `findClaudeBinary()`
983
+ * legitimately returns null and this is the normal, expected state — not a fault.
984
+ *
985
+ * ⚠ HANDING `null` TO THE SDK produces *"Native CLI binary for darwin-arm64 not found"*, which
986
+ * names an internal package and no action. The person reading it has done nothing wrong and
987
+ * cannot tell whether the product is broken or whether they are missing a prerequisite.
988
+ * A one-line install command is the whole difference.
989
+ */
990
+ if (!claudeBinary) {
991
+ emit({
992
+ type: 'agent:error',
993
+ sessionId: session.sessionId,
994
+ error: 'Claude Code is not installed on this machine, so there is nothing to run this turn. '
995
+ + 'Install it from https://claude.com/claude-code (or `npm i -g @anthropic-ai/claude-code`), '
996
+ + 'then run `claude login` once. Clearly uses your own Claude subscription or API key — '
997
+ + 'it does not ship a copy.',
998
+ });
999
+ return;
1000
+ }
1001
+ /**
1002
+ * ⚠⚠ READ THE BOARD, DO NOT ASSERT A STAGE. This is the pre-turn injection: where the active
1003
+ * card stands and what its next gate is, derived from `work-status`. A block that asserted a
1004
+ * stage could contradict the card the moment somebody moved it in the UI, and a model given
1005
+ * two contradictory inventories follows whichever it read last.
1006
+ *
1007
+ * ⚠ NON-FATAL AND SILENT ON FAILURE. No active card, an older worker without `work-status`, an
1008
+ * unreachable workspace — all produce no block rather than a wrong one. A turn with no work in
1009
+ * front of it is a conversation.
1010
+ */
1011
+ let turnContext = '';
1012
+ if (companyModeEnabled()) {
1013
+ try {
1014
+ const { activeWorkStatus } = await import('./daemon-omni-principal.js');
1015
+ turnContext = workContextBlock(await activeWorkStatus());
1016
+ }
1017
+ catch {
1018
+ turnContext = '';
1019
+ }
1020
+ }
1021
+ const sdkOptions = buildSdkOptions(session, claudeBinary, turnContext || undefined);
1022
+ const q = query({ prompt, options: { ...sdkOptions, abortController: abort } });
1023
+ for await (const message of q) {
1024
+ if (session.ended) {
1025
+ try {
1026
+ q.interrupt?.();
1027
+ }
1028
+ catch { }
1029
+ // ⚠ Breaking here ALWAYS leaks one unhandled rejection, and it cannot
1030
+ // be caught at this layer. The implicit `iterator.return()` runs the
1031
+ // SDK's cleanup from inside an async generator's `finally`
1032
+ // (`Query closed before response received`), by which point nothing is
1033
+ // awaiting it — so the surrounding try/catch never sees it.
1034
+ //
1035
+ // Measured, not assumed: pre-disposing with
1036
+ // `Promise.resolve(q.return()).catch(()=>{})` before the break was
1037
+ // tried and made NO difference — one rejection either way (A/B, same
1038
+ // cancel, count before vs after). It was removed rather than left in
1039
+ // as a comment claiming a fix it doesn't perform.
1040
+ //
1041
+ // The daemon's `unhandledRejection` handler is what actually keeps the
1042
+ // process alive through a cancel. That is the fix; this is the note
1043
+ // that stops the next person re-attempting this one.
1044
+ break;
1045
+ }
1046
+ // Capture the SDK's session_id from the init message. The same id
1047
+ // names the JSONL file in `~/.claude/projects/<encoded-cwd>/`.
1048
+ // We thread it as `resume` on the next turn. When this turn was
1049
+ // a fork (#79), the new id is the fork's id — clear the pending
1050
+ // forkFromSdkSessionId so subsequent turns just resume normally.
1051
+ if (message?.type === 'system' && message.session_id) {
1052
+ session.sdkSessionId = message.session_id;
1053
+ if (session.forkFromSdkSessionId) {
1054
+ session.forkFromSdkSessionId = undefined;
1055
+ }
1056
+ }
1057
+ handleSdkMessage(session, message);
1058
+ if (message?.type === 'result')
1059
+ break;
1060
+ }
1061
+ }
1062
+ catch (err) {
1063
+ emit({
1064
+ type: 'agent:error',
1065
+ sessionId: session.sessionId,
1066
+ error: err?.message ?? String(err),
1067
+ });
1068
+ }
1069
+ finally {
1070
+ session.turnInFlight = false;
1071
+ // Cleared so a later interrupt cannot abort a turn that is not running.
1072
+ if (session.abort === abort)
1073
+ session.abort = undefined;
1074
+ session.lastActivityAt = Date.now();
1075
+ const durationMs = Date.now() - turnStart;
1076
+ // The result event already fires inside handleSdkMessage but we
1077
+ // also emit done here for the turnIndex/durationMs accounting,
1078
+ // mirroring the old flow's per-turn summary.
1079
+ // (handleSdkMessage's done handler is the authoritative one when
1080
+ // a result message arrives; this branch only catches the path
1081
+ // where the loop exits without a result, e.g. interrupted turn.)
1082
+ if (durationMs > 0 && !session.ended && session.queuedPrompts.length === 0) {
1083
+ // turnIndex was bumped by handleSdkMessage's result handler.
1084
+ // Nothing extra to emit here for the happy path.
1085
+ }
1086
+ // Drain any queued prompts. Concurrent calls to promptOmni while
1087
+ // a turn was running get processed FIFO here.
1088
+ const next = session.queuedPrompts.shift();
1089
+ if (next && !session.ended) {
1090
+ void runTurn(session, next).catch((e) => {
1091
+ emit({
1092
+ type: 'agent:error',
1093
+ sessionId: session.sessionId,
1094
+ error: e?.message ?? String(e),
1095
+ });
1096
+ });
1097
+ }
1098
+ }
1099
+ }
1100
+ function handleSdkMessage(session, message) {
1101
+ /**
1102
+ * Stream-partial. Ordinary assistant text is private model output and keeps
1103
+ * its historical `agent:text-delta` channel. A `reply_to_principal` call is
1104
+ * different: its `text` argument is what the person is waiting to read, so
1105
+ * decode that argument and send a principal-scoped delta as soon as it is
1106
+ * available. The completed MCP tool still emits the authoritative `prose`
1107
+ * event below; the desktop adapter reconciles the two without duplicating it.
1108
+ */
1109
+ if (message?.type === 'stream_event') {
1110
+ const event = message.event;
1111
+ if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
1112
+ const name = String(event.content_block.name ?? '');
1113
+ if (name.endsWith('reply_to_principal')) {
1114
+ session.principalStreamIdx = Number(event.index);
1115
+ session.principalTextReader = new PrincipalTextReader();
1116
+ }
1117
+ }
1118
+ else if (event?.type === 'content_block_delta') {
1119
+ const delta = event.delta;
1120
+ if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
1121
+ session.currentText += delta.text;
1122
+ emit({ type: 'agent:text-delta', sessionId: session.sessionId, text: delta.text });
1123
+ }
1124
+ else if (delta?.type === 'input_json_delta'
1125
+ && Number(event.index) === session.principalStreamIdx
1126
+ && session.principalTextReader) {
1127
+ const text = session.principalTextReader.push(String(delta.partial_json ?? ''));
1128
+ if (text) {
1129
+ emit({
1130
+ type: 'agent:principal',
1131
+ sessionId: session.sessionId,
1132
+ event: { kind: 'prose-delta', text },
1133
+ });
1134
+ }
1135
+ }
1136
+ }
1137
+ else if (event?.type === 'content_block_stop' && Number(event.index) === session.principalStreamIdx) {
1138
+ session.principalStreamIdx = null;
1139
+ session.principalTextReader = null;
1140
+ }
1141
+ return;
1142
+ }
1143
+ // Assistant message — full turn body (text + tool_use blocks).
1144
+ // Tool calls fire here, before they execute. We mirror them as
1145
+ // tool-call events so the FAB can show "Claude is using Bash...".
1146
+ if (message?.type === 'assistant') {
1147
+ const content = message?.message?.content;
1148
+ if (Array.isArray(content)) {
1149
+ for (const block of content) {
1150
+ if (block?.type === 'tool_use') {
1151
+ emit({
1152
+ type: 'agent:tool-call',
1153
+ sessionId: session.sessionId,
1154
+ callId: block.id ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
1155
+ tool: block.name ?? 'unknown',
1156
+ input: block.input ?? {},
1157
+ });
1158
+ }
1159
+ }
1160
+ }
1161
+ return;
1162
+ }
1163
+ // Tool result — the SDK already ran the tool. We surface a small
1164
+ // preview (first 200 chars) so the FAB can show "ran successfully"
1165
+ // / "errored" without dumping the full output back through.
1166
+ if (message?.type === 'user') {
1167
+ const content = message?.message?.content;
1168
+ if (Array.isArray(content)) {
1169
+ for (const block of content) {
1170
+ if (block?.type === 'tool_result') {
1171
+ const raw = typeof block.content === 'string'
1172
+ ? block.content
1173
+ : Array.isArray(block.content)
1174
+ ? block.content.map((p) => p?.text ?? '').join('')
1175
+ : '';
1176
+ emit({
1177
+ type: 'agent:tool-result',
1178
+ sessionId: session.sessionId,
1179
+ callId: block.tool_use_id ?? 'unknown',
1180
+ tool: '', // ← human-readable name lives on the matching tool-call event
1181
+ ok: !block.is_error,
1182
+ preview: raw ? raw.slice(0, 200) : undefined,
1183
+ });
1184
+ }
1185
+ }
1186
+ }
1187
+ return;
1188
+ }
1189
+ // Result — the SDK's per-turn completion marker. We use it to:
1190
+ // (a) bump the turn counter
1191
+ // (b) emit the aggregated text for telemetry/transcript
1192
+ // (c) reset session.currentText for the next turn
1193
+ if (message?.type === 'result') {
1194
+ session.turnIndex += 1;
1195
+ const result = message?.result;
1196
+ emit({
1197
+ type: 'agent:turn-complete',
1198
+ sessionId: session.sessionId,
1199
+ turnIndex: session.turnIndex,
1200
+ });
1201
+ emit({
1202
+ type: 'agent:done',
1203
+ sessionId: session.sessionId,
1204
+ result: {
1205
+ text: session.currentText || (typeof result === 'string' ? result : undefined),
1206
+ turns: session.turnIndex,
1207
+ durationMs: Date.now() - session.startedAt,
1208
+ },
1209
+ });
1210
+ session.currentText = '';
1211
+ return;
1212
+ }
1213
+ // System messages (init, models, etc.) — emitted by SDK at startup.
1214
+ // We don't surface them; they're noise for the FAB.
1215
+ }
1216
+ /**
1217
+ * Push a user message into an Omni session. If the session doesn't
1218
+ * exist it's created and the SDK starts immediately. Resolves when
1219
+ * the SDK has consumed the message (not when the response completes —
1220
+ * subscribers see streaming events for that). Use this to drive a
1221
+ * multi-turn chat: call once per user message; each call reuses the
1222
+ * same SDK process if the sessionId is the same.
1223
+ */
1224
+ export async function promptOmni(input) {
1225
+ const session = getOrCreateSession({
1226
+ sessionId: input.sessionId,
1227
+ userId: input.userId,
1228
+ agentName: input.agentName,
1229
+ model: input.model,
1230
+ appendSystemPrompt: input.appendSystemPrompt,
1231
+ cwd: input.cwd,
1232
+ compositionId: input.compositionId,
1233
+ compositionTitle: input.compositionTitle,
1234
+ allowedTools: input.allowedTools,
1235
+ continueSession: input.continueSession,
1236
+ resumeSessionId: input.resumeSessionId,
1237
+ additionalDirectories: input.additionalDirectories,
1238
+ mcpServers: input.mcpServers,
1239
+ });
1240
+ if (session.ended) {
1241
+ throw new Error(`Omni session ${input.sessionId} has ended; start a new one.`);
1242
+ }
1243
+ // Concurrency model: one turn per session at a time. Subsequent
1244
+ // calls during an in-flight turn queue FIFO and drain when the
1245
+ // active turn finishes (see runTurn's finally block).
1246
+ if (session.turnInFlight) {
1247
+ // Queue cap (audit task #65). Without this, a client flooding the
1248
+ // daemon with prompts grows the queue without bound — OOM under
1249
+ // buggy clients or abuse.
1250
+ const maxQueued = session.config.maxQueuedPrompts ?? DEFAULT_MAX_QUEUED_PROMPTS;
1251
+ if (session.queuedPrompts.length >= maxQueued) {
1252
+ throw new Error(`QUEUE_FULL: session ${input.sessionId} has ${session.queuedPrompts.length} prompts queued (max ${maxQueued}). ` +
1253
+ `Wait for the current turn to finish or end this session.`);
1254
+ }
1255
+ session.queuedPrompts.push(input.prompt);
1256
+ }
1257
+ else {
1258
+ // Fire-and-forget — subscribers see streaming events. promptOmni
1259
+ // resolves immediately, the SDK runs in the background.
1260
+ void runTurn(session, input.prompt).catch((err) => {
1261
+ emit({
1262
+ type: 'agent:error',
1263
+ sessionId: session.sessionId,
1264
+ error: err?.message ?? String(err),
1265
+ });
1266
+ });
1267
+ }
1268
+ return { sessionId: input.sessionId };
1269
+ }
1270
+ /** Terminate a session — drops in-memory state. The SDK's JSONL on
1271
+ * disk survives, so the same compositionId can be reopened later
1272
+ * and the conversation picks up via findLatestSdkSessionId. */
1273
+ /**
1274
+ * Stop the turn in flight WITHOUT ending the session.
1275
+ *
1276
+ * ⚠ THE DISTINCTION FROM `endSession` IS THE WHOLE POINT. Ending a session drops
1277
+ * the conversation; this is the Esc key — the user changed their mind about what
1278
+ * they asked for, not about talking. It also settles any question the turn was
1279
+ * parked on, or the tool call awaiting an answer is orphaned inside a query that
1280
+ * is being torn down.
1281
+ */
1282
+ export async function interruptSession(sessionId) {
1283
+ const session = sessions.get(sessionId);
1284
+ if (!session?.turnInFlight || !session.abort)
1285
+ return false;
1286
+ dismissOmniAsks();
1287
+ try {
1288
+ session.abort.abort();
1289
+ }
1290
+ catch { /* already aborted */ }
1291
+ return true;
1292
+ }
1293
+ export function endSession(sessionId) {
1294
+ const session = sessions.get(sessionId);
1295
+ if (!session)
1296
+ return;
1297
+ session.ended = true;
1298
+ session.queuedPrompts.length = 0;
1299
+ sessions.delete(sessionId);
1300
+ }
1301
+ /**
1302
+ * Branch a session (#79). Forks the JSONL conversation at the most
1303
+ * recent turn — caller gets a new sessionId that starts from the
1304
+ * same history but diverges from here. The SDK's `forkSession: true`
1305
+ * (paired with `resume`) does the work — we just mint a new
1306
+ * omniSessionId, find the latest JSONL for the source session's cwd,
1307
+ * and start an SDK query() with both options.
1308
+ *
1309
+ * The first prompt to the new branch creates a fresh JSONL on disk.
1310
+ * Subsequent prompts to the same branch resume normally.
1311
+ *
1312
+ * v1 forks at the LATEST turn (SDK's native fork semantic). Mid-
1313
+ * conversation forking (at a specific turn N) requires JSONL
1314
+ * truncation — added later when the use case demands it.
1315
+ */
1316
+ export function branchSession(opts) {
1317
+ const source = sessions.get(opts.sourceSessionId);
1318
+ // Source session may not exist in-memory (daemon restart). We can
1319
+ // still branch by looking up the latest JSONL on disk if compositionId
1320
+ // is provided — the cwd derives the JSONL location.
1321
+ let sourceSdkSessionId = source?.sdkSessionId ?? null;
1322
+ if (!sourceSdkSessionId && opts.compositionId) {
1323
+ const cwd = resolveCompositionCwd({ compositionId: opts.compositionId });
1324
+ sourceSdkSessionId = findLatestSdkSessionId(cwd);
1325
+ }
1326
+ const newSessionId = `${opts.sourceSessionId}-branch-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1327
+ if (sourceSdkSessionId) {
1328
+ // Pre-register the new session with forkFromSdkSessionId set. Next
1329
+ // promptOmni({sessionId: newSessionId}) call: buildSdkOptions sees
1330
+ // the flag and emits resume+forkSession to the SDK. The fork's new
1331
+ // session_id is captured back into sdkSessionId on first response,
1332
+ // and the flag is cleared. Subsequent turns resume normally.
1333
+ const baseConfig = source?.config ?? {
1334
+ sessionId: newSessionId,
1335
+ userId: source?.userId ?? 'local',
1336
+ compositionId: opts.compositionId,
1337
+ };
1338
+ sessions.set(newSessionId, {
1339
+ sessionId: newSessionId,
1340
+ userId: source?.userId ?? 'local',
1341
+ config: { ...baseConfig, sessionId: newSessionId },
1342
+ cwd: source?.cwd ??
1343
+ (opts.compositionId
1344
+ ? resolveCompositionCwd({ compositionId: opts.compositionId })
1345
+ : (process.env.HOME ?? process.cwd())),
1346
+ sdkSessionId: null, // populated by fork response
1347
+ forkFromSdkSessionId: sourceSdkSessionId,
1348
+ ended: false,
1349
+ turnInFlight: false,
1350
+ queuedPrompts: [],
1351
+ currentText: '',
1352
+ principalStreamIdx: null,
1353
+ principalTextReader: null,
1354
+ turnIndex: 0,
1355
+ startedAt: Date.now(),
1356
+ lastActivityAt: Date.now(),
1357
+ });
1358
+ }
1359
+ return { newSessionId, sourceSdkSessionId };
1360
+ }
1361
+ /** Diagnostics — list active sessions for the daemon status menu. */
1362
+ export function listSessions() {
1363
+ const now = Date.now();
1364
+ return Array.from(sessions.values()).map((s) => ({
1365
+ sessionId: s.sessionId,
1366
+ userId: s.userId,
1367
+ turns: s.turnIndex,
1368
+ ageMs: now - s.startedAt,
1369
+ idleMs: now - s.lastActivityAt,
1370
+ sdkSessionId: s.sdkSessionId,
1371
+ cwd: s.cwd,
1372
+ turnInFlight: s.turnInFlight,
1373
+ }));
1374
+ }
1375
+ /**
1376
+ * Turn what a person typed into an absolute directory, or say why it is not one.
1377
+ *
1378
+ * ⚠ PURE AND EXPORTED SO IT CAN BE TESTED. Every trap in changing a cwd lives here — `~` that
1379
+ * nothing downstream expands, a path that does not exist, a path that is a FILE — and none of them
1380
+ * can be exercised through `setSessionCwd` without a live SDK session, which needs a real Claude
1381
+ * binary. Untestable validation is validation nobody checks.
1382
+ *
1383
+ * ⚠ IT VALIDATES UP FRONT rather than letting the SDK discover it. A typo'd cwd is otherwise not
1384
+ * caught until the next turn spawns, where it surfaces as an opaque tool failure far from the
1385
+ * moment somebody set it.
1386
+ */
1387
+ export function resolveCwdInput(cwd) {
1388
+ const next = String(cwd ?? '').trim();
1389
+ if (!next)
1390
+ return { ok: false, code: 'no-cwd', error: 'cwd is required' };
1391
+ // ⚠ `~` is not expanded by anything downstream; expanding it here is the difference between a
1392
+ // path a person can type and one only a shell can produce.
1393
+ const resolved = next === '~' || next.startsWith('~/')
1394
+ ? join(process.env.HOME ?? '', next.slice(1))
1395
+ : resolvePath(next);
1396
+ let stat;
1397
+ try {
1398
+ stat = statSync(resolved);
1399
+ }
1400
+ catch {
1401
+ return { ok: false, code: 'not-found', error: `no such directory: ${resolved}` };
1402
+ }
1403
+ if (!stat.isDirectory())
1404
+ return { ok: false, code: 'not-a-directory', error: `not a directory: ${resolved}` };
1405
+ return { ok: true, cwd: resolved };
1406
+ }
1407
+ /**
1408
+ * Move a session's working directory.
1409
+ *
1410
+ * ⚠⚠ THE SDK CONVERSATION DOES NOT COME WITH IT, AND SAYING SO IS THE POINT. Claude Code stores a
1411
+ * session's JSONL under a project directory derived from the cwd, and `findLatestSdkSessionId`
1412
+ * resolves the thread to resume BY cwd. Carrying `sdkSessionId` into a new directory either fails
1413
+ * or silently resumes a thread that belongs somewhere else — so it is CLEARED, the next turn starts
1414
+ * fresh in the new directory, and the caller is told (`threadReset: true`) so it can say so rather
1415
+ * than letting someone discover it by finding an agent with no memory of the last five minutes.
1416
+ *
1417
+ * ⚠ REFUSED MID-TURN. Changing the ground under a running turn means the tools it already decided
1418
+ * to call resolve against a directory it never saw. Refusing is recoverable; a half-moved turn is
1419
+ * not.
1420
+ *
1421
+ * ⚠ THE DIRECTORY MUST EXIST. A typo'd cwd is not caught until the SDK spawns, and then it fails
1422
+ * as an opaque tool error on the next turn rather than at the moment somebody set it.
1423
+ */
1424
+ export function setSessionCwd(sessionId, cwd) {
1425
+ const s = sessions.get(String(sessionId ?? ''));
1426
+ if (!s)
1427
+ return { ok: false, code: 'no-session', error: `no session ${sessionId}` };
1428
+ if (s.ended)
1429
+ return { ok: false, code: 'ended', error: 'session has ended' };
1430
+ if (s.turnInFlight)
1431
+ return { ok: false, code: 'busy', error: 'a turn is running — interrupt it or wait' };
1432
+ const r = resolveCwdInput(cwd);
1433
+ if (!r.ok)
1434
+ return r;
1435
+ const resolved = r.cwd;
1436
+ const previous = s.cwd;
1437
+ if (previous === resolved)
1438
+ return { ok: true, cwd: resolved, previous, threadReset: false };
1439
+ s.cwd = resolved;
1440
+ s.sdkSessionId = null;
1441
+ return { ok: true, cwd: resolved, previous, threadReset: true };
1442
+ }