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.
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- 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
|
+
}
|