@clearlysh/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,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-state — CLI-side storage + resolution of the active agent identity.
|
|
3
|
+
*
|
|
4
|
+
* Each agent identity the user mints is persisted to
|
|
5
|
+
* ~/.beehaven/agents/<name>.json (mode 600). The ACTIVE identity for a
|
|
6
|
+
* given terminal is selected by the `BEEHAVEN_AGENT` env var (inherently
|
|
7
|
+
* per-tty, so 4 windows can each be a different agent) or a per-call
|
|
8
|
+
* `--agent <name>` override.
|
|
9
|
+
*
|
|
10
|
+
* The signing secret never lives here — the daemon mints the token
|
|
11
|
+
* (`agent:mint`) and the CLI just stores the resulting record.
|
|
12
|
+
*/
|
|
13
|
+
import { deriveAgentScope } from './agent-scope.js';
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, chmodSync, } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
/**
|
|
18
|
+
* Read the claims out of an agent token without verifying it.
|
|
19
|
+
*
|
|
20
|
+
* ⚠⚠ THE TOKEN IS THE AUTHORITY ON ITS OWN LIFETIME, AND NOT READING IT COST US THE
|
|
21
|
+
* EXPIRY CHECK ENTIRELY. `agent login` has two mint paths: the CLOUD one (tried
|
|
22
|
+
* first, and the one that succeeds whenever the daemon is connected) hand-builds its
|
|
23
|
+
* result from six fields and omits `userId`, `issuedAt` and `expiresAt`; the LOCAL
|
|
24
|
+
* fallback returns all three. So a cloud identity stored `expiresAt: undefined`.
|
|
25
|
+
*
|
|
26
|
+
* That is not cosmetic. `resolveActiveAgent` decides staleness with
|
|
27
|
+
* `typeof record.expiresAt === 'number' && record.expiresAt <= Date.now()` — and
|
|
28
|
+
* `typeof undefined === 'number'` is false, so **every cloud-minted identity was
|
|
29
|
+
* permanently un-expirable**. The refusal `speak` shows for a stale identity could
|
|
30
|
+
* never fire for the identities it was written to protect. It failed open, silently,
|
|
31
|
+
* and `whoami` printed `NaNm left` as the only hint.
|
|
32
|
+
*
|
|
33
|
+
* ⚠ DECODING IS NOT VERIFYING, deliberately. Signature checking needs the daemon's
|
|
34
|
+
* secret, which the CLI does not hold. These claims drive local bookkeeping only —
|
|
35
|
+
* what `whoami` prints and when to suggest a refresh; every real authorisation is
|
|
36
|
+
* re-verified server-side against the signature. The worst a tampered `exp` buys is
|
|
37
|
+
* a wrong hint in this one CLI.
|
|
38
|
+
*/
|
|
39
|
+
export function decodeAgentTokenClaims(token) {
|
|
40
|
+
try {
|
|
41
|
+
const payload = String(token || '').split('.')[0];
|
|
42
|
+
if (!payload)
|
|
43
|
+
return null;
|
|
44
|
+
const json = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
45
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
46
|
+
return {
|
|
47
|
+
userId: typeof json?.userId === 'string' ? json.userId : undefined,
|
|
48
|
+
issuedAt: num(json?.iat),
|
|
49
|
+
expiresAt: num(json?.exp),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A token this cannot parse is not an error here — the record still saves, and
|
|
54
|
+
// the fields simply stay unknown rather than becoming wrong.
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const AGENTS_DIR = join(homedir(), '.beehaven', 'agents');
|
|
59
|
+
/** Agent names are used as filenames — constrain to a safe charset. */
|
|
60
|
+
export function isValidAgentName(name) {
|
|
61
|
+
return /^[a-zA-Z0-9._-]{1,64}$/.test(name);
|
|
62
|
+
}
|
|
63
|
+
export function agentsDir() {
|
|
64
|
+
return AGENTS_DIR;
|
|
65
|
+
}
|
|
66
|
+
function agentFilePath(name) {
|
|
67
|
+
return join(AGENTS_DIR, `${name}.json`);
|
|
68
|
+
}
|
|
69
|
+
/** Persist an identity atomically with owner-only permissions. */
|
|
70
|
+
export function saveAgent(name, record) {
|
|
71
|
+
if (!isValidAgentName(name))
|
|
72
|
+
throw new Error(`Invalid agent name: ${name}`);
|
|
73
|
+
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
74
|
+
const target = agentFilePath(name);
|
|
75
|
+
const tmp = `${target}.tmp`;
|
|
76
|
+
writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
77
|
+
renameSync(tmp, target);
|
|
78
|
+
try {
|
|
79
|
+
chmodSync(target, 0o600);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* best effort */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Load a stored identity by name. Returns null if missing or unparseable. */
|
|
86
|
+
export function loadAgent(name) {
|
|
87
|
+
if (!isValidAgentName(name))
|
|
88
|
+
return null;
|
|
89
|
+
const file = agentFilePath(name);
|
|
90
|
+
if (!existsSync(file))
|
|
91
|
+
return null;
|
|
92
|
+
try {
|
|
93
|
+
const rec = JSON.parse(readFileSync(file, 'utf8'));
|
|
94
|
+
/**
|
|
95
|
+
* ⚠ SELF-HEAL THE FIELDS THE CLOUD MINT LEFT OUT, IN MEMORY ONLY.
|
|
96
|
+
*
|
|
97
|
+
* Every identity minted before the fix has `userId`/`issuedAt`/`expiresAt`
|
|
98
|
+
* undefined, which makes it permanently un-expirable. Filling them from the
|
|
99
|
+
* token here means those records behave correctly immediately, without a
|
|
100
|
+
* migration and without asking anyone to re-login.
|
|
101
|
+
*
|
|
102
|
+
* ⚠ NO WRITE-BACK. `loadAgent` is called on almost every command; making it
|
|
103
|
+
* rewrite files on read would turn a lookup into a mutation and race two
|
|
104
|
+
* concurrent CLI invocations over the same file for no benefit. The next real
|
|
105
|
+
* `agent login` persists them properly.
|
|
106
|
+
*/
|
|
107
|
+
if (rec && rec.token && (rec.expiresAt === undefined || rec.userId === undefined)) {
|
|
108
|
+
const claims = decodeAgentTokenClaims(rec.token);
|
|
109
|
+
if (claims) {
|
|
110
|
+
if (rec.userId === undefined)
|
|
111
|
+
rec.userId = claims.userId;
|
|
112
|
+
if (rec.issuedAt === undefined)
|
|
113
|
+
rec.issuedAt = claims.issuedAt;
|
|
114
|
+
if (rec.expiresAt === undefined)
|
|
115
|
+
rec.expiresAt = claims.expiresAt;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return rec;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** List every stored identity (sorted by name). */
|
|
125
|
+
export function listAgents() {
|
|
126
|
+
if (!existsSync(AGENTS_DIR))
|
|
127
|
+
return [];
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const file of readdirSync(AGENTS_DIR)) {
|
|
130
|
+
if (!file.endsWith('.json'))
|
|
131
|
+
continue;
|
|
132
|
+
const name = file.slice(0, -5);
|
|
133
|
+
const record = loadAgent(name);
|
|
134
|
+
if (record)
|
|
135
|
+
out.push({ name, record });
|
|
136
|
+
}
|
|
137
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/** Remove a stored identity. Returns true if a file was deleted. */
|
|
141
|
+
export function deleteAgent(name) {
|
|
142
|
+
if (!isValidAgentName(name))
|
|
143
|
+
return false;
|
|
144
|
+
const file = agentFilePath(name);
|
|
145
|
+
if (!existsSync(file))
|
|
146
|
+
return false;
|
|
147
|
+
try {
|
|
148
|
+
unlinkSync(file);
|
|
149
|
+
// Also remove the login-materialized context file (operating guide + memory
|
|
150
|
+
// snapshot written on `agent login`), so logout leaves nothing behind.
|
|
151
|
+
try {
|
|
152
|
+
const ctx = join(AGENTS_DIR, `${name}.context.md`);
|
|
153
|
+
if (existsSync(ctx))
|
|
154
|
+
unlinkSync(ctx);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* best-effort */
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Local-first avatar collision guard: find a DIFFERENT stored agent that
|
|
167
|
+
* already claims the given avatarId. The durable guard is AgentRegistry's
|
|
168
|
+
* avatar pool; this covers the local 4-terminals demo without a cloud D1.
|
|
169
|
+
*/
|
|
170
|
+
export function findAgentByAvatar(avatarId, exceptName) {
|
|
171
|
+
for (const { name, record } of listAgents()) {
|
|
172
|
+
if (name === exceptName)
|
|
173
|
+
continue;
|
|
174
|
+
if (record.avatarId === avatarId)
|
|
175
|
+
return name;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* ⚠⚠ THE PROCESS SCOPE AN AGENT WAS LOGGED IN FROM — the binding that unifies the three
|
|
181
|
+
* things this codebase calls "the agent".
|
|
182
|
+
*
|
|
183
|
+
* Before this there were three unrelated handles:
|
|
184
|
+
* · the IDENTITY — `~/.beehaven/agents/<name>.json` (agentId, userId, token)
|
|
185
|
+
* · the SESSION — an `agent_sessions` row inside a workspace, its id stored above
|
|
186
|
+
* · the CURSOR — `target-<scope>.json`, which decides WHICH workspace
|
|
187
|
+
*
|
|
188
|
+
* `BEEHAVEN_AGENT` tied the first and third together, but only if a human remembered to
|
|
189
|
+
* export it — and `agent login` ended by PRINTING that instruction rather than doing
|
|
190
|
+
* anything. An agent that logged in and did not export was fully authenticated and still
|
|
191
|
+
* calling as nobody.
|
|
192
|
+
*
|
|
193
|
+
* Binding the login to the PROCESS SCOPE (`deriveAgentScope()`, which needs no cooperation)
|
|
194
|
+
* closes it: log in once, and every later call from that same session resolves to that
|
|
195
|
+
* agent — identity, workspace session and cursor together, with no env var to forget.
|
|
196
|
+
*
|
|
197
|
+
* ⚠ A LIST, NOT A FIELD. One agent may legitimately be logged in from several places (a
|
|
198
|
+
* terminal, the in-app shell, a cron), and one process scope may only ever mean ONE agent —
|
|
199
|
+
* which is why the reverse lookup below refuses on ambiguity rather than picking.
|
|
200
|
+
*/
|
|
201
|
+
export function bindAgentScope(name, scope, opts = {}) {
|
|
202
|
+
try {
|
|
203
|
+
const rec = loadAgent(name);
|
|
204
|
+
if (!rec)
|
|
205
|
+
return;
|
|
206
|
+
const prev = Array.isArray(rec.boundScopes) ? rec.boundScopes : [];
|
|
207
|
+
/**
|
|
208
|
+
* ⚠⚠ THE FAMILY IS RECORDED ALONGSIDE THE EXACT SCOPE, AND IT IS WHAT SURVIVES.
|
|
209
|
+
* A Claude Code scope is `claude-<session id>`, so it dies with the conversation: logging in
|
|
210
|
+
* bound an identity to a handle that could never be presented again, and the NEXT session was
|
|
211
|
+
* "not logged in" while holding a stored, valid, unexpired identity. Measured on this machine —
|
|
212
|
+
* `jinri-claude` was bound to `claude-507a5e19c44a` while the live session derived
|
|
213
|
+
* `claude-f400ac603290`.
|
|
214
|
+
*
|
|
215
|
+
* ⚠ A human terminal derives `''` and used to bind NOTHING, so `agent login` at a prompt could
|
|
216
|
+
* never take effect at all. It has a family (`terminal`) and now binds like everything else.
|
|
217
|
+
*/
|
|
218
|
+
/**
|
|
219
|
+
* ⚠ `family: false` binds THIS scope only. A login the workspace refused must still own the
|
|
220
|
+
* session it ran in (see `loginSessionOutcome`), but must not become the identity every
|
|
221
|
+
* future un-logged-in session of the same tool inherits. A human terminal (`''`) has no exact
|
|
222
|
+
* scope — its family IS its scope — so it keeps the family either way.
|
|
223
|
+
*/
|
|
224
|
+
const fam = opts.family === false && scope ? null : scopeFamilyKey(scope);
|
|
225
|
+
const add = [scope, fam].filter((s) => !!s && !prev.includes(s));
|
|
226
|
+
// `lastLoginAt` is what breaks a tie between two identities in one family; without it the
|
|
227
|
+
// fallback would have to refuse, which is the state being fixed.
|
|
228
|
+
const next = add.length ? [...add, ...prev].slice(0, 20) : prev;
|
|
229
|
+
saveAgent(name, { ...rec, boundScopes: next, lastLoginAt: Date.now() });
|
|
230
|
+
}
|
|
231
|
+
catch { /* binding is a convenience; a failed write must not fail the login */ }
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The stable half of a process scope — the TOOL rather than the conversation.
|
|
235
|
+
*
|
|
236
|
+
* ⚠ `pid-*` HAS NO FAMILY, deliberately. That scope is the "no marker, not a TTY" fallback, i.e.
|
|
237
|
+
* we do not know what is calling; pooling those would let two unrelated scripts inherit each
|
|
238
|
+
* other's identity, which is the misattribution the scope system exists to prevent.
|
|
239
|
+
*/
|
|
240
|
+
export function scopeFamilyKey(scope) {
|
|
241
|
+
if (!scope)
|
|
242
|
+
return 'family:terminal';
|
|
243
|
+
const m = /^(claude|codex|gemini)-/.exec(scope);
|
|
244
|
+
if (m)
|
|
245
|
+
return `family:${m[1]}`;
|
|
246
|
+
if (scope.startsWith('pid-'))
|
|
247
|
+
return null;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The agent bound to this process scope, or null.
|
|
252
|
+
*
|
|
253
|
+
* Exact scope first (refusing on ambiguity), then the tool FAMILY — see `bindAgentScope`.
|
|
254
|
+
*/
|
|
255
|
+
export function agentForScope(scope = '') {
|
|
256
|
+
try {
|
|
257
|
+
const all = listAgents();
|
|
258
|
+
const boundTo = (key) => all
|
|
259
|
+
.filter(({ record }) => Array.isArray(record.boundScopes)
|
|
260
|
+
&& record.boundScopes.includes(key));
|
|
261
|
+
/**
|
|
262
|
+
* ⚠⚠ TWO AGENTS ON ONE SCOPE MEANS SOMEONE LOGGED IN TWICE, AND THE SECOND LOGIN IS THE
|
|
263
|
+
* ANSWER. This used to refuse outright — "silently picking one is how a call runs as the wrong
|
|
264
|
+
* principal" — and that reasoning was written when there was no `lastLoginAt` to pick BY, so
|
|
265
|
+
* refusing was the only honest option. It is not any more.
|
|
266
|
+
*
|
|
267
|
+
* ⚠ AND THE REFUSAL WAS REPRODUCING THE BUG THIS FILE EXISTS TO FIX. Measured: after logging
|
|
268
|
+
* in as `claude` and later as `jinri-claude` from one session, every call reported *"you are an
|
|
269
|
+
* agent and have not logged in"* while holding two valid identities, the second minted seconds
|
|
270
|
+
* earlier. "Log in as X, then as Y" means you are Y everywhere else; refusing left the caller
|
|
271
|
+
* as NOBODY, which is strictly worse than the ambiguity it was avoiding.
|
|
272
|
+
*
|
|
273
|
+
* ⚠ AN UNDATABLE TIE STILL REFUSES. Two pre-`lastLoginAt` records on one scope cannot be
|
|
274
|
+
* ordered, and picking whichever the filesystem returned first IS the arbitrary choice the
|
|
275
|
+
* original note warned about.
|
|
276
|
+
*/
|
|
277
|
+
if (scope) {
|
|
278
|
+
const exact = boundTo(scope);
|
|
279
|
+
if (exact.length === 1)
|
|
280
|
+
return exact[0].name;
|
|
281
|
+
if (exact.length > 1) {
|
|
282
|
+
const byRecency = [...exact].sort((a, b) => Number(b.record.lastLoginAt || 0) - Number(a.record.lastLoginAt || 0));
|
|
283
|
+
const top = Number(byRecency[0].record.lastLoginAt || 0);
|
|
284
|
+
const second = Number(byRecency[1].record.lastLoginAt || 0);
|
|
285
|
+
return top > second ? byRecency[0].name : null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* ⚠⚠ THERE IS NO WORKSPACE-QUALIFIED BINDING, AND THAT IS A FINDING RATHER THAN AN OVERSIGHT.
|
|
290
|
+
* An identity IS per workspace — the token carries `agentId` + `userId` and NO workspace, the
|
|
291
|
+
* agent row lives in the workspace DO, and `_checkAgentScope` refuses a principal it has no row
|
|
292
|
+
* for. So keying the binding on the connected workspace is the obviously right design. It was
|
|
293
|
+
* built, and it deadlocks on an ordering constraint that already exists:
|
|
294
|
+
*
|
|
295
|
+
* cli-state.targetFileFor() → effectiveScope() → agentForScope()
|
|
296
|
+
*
|
|
297
|
+
* The CURSOR is keyed BY the agent (`target-<agent>.json`, so concurrent agents do not fight
|
|
298
|
+
* over one file), so the agent cannot be keyed by the cursor. Calling `getTarget()` from here
|
|
299
|
+
* runs during that module's own initialisation and answers `home` for every workspace —
|
|
300
|
+
* silently disabling the qualification while looking like it works. Measured: every binding was
|
|
301
|
+
* written `@home`, and two different workspaces resolved to the same agent.
|
|
302
|
+
*
|
|
303
|
+
* ⚠ SO THE PER-WORKSPACE RULE IS ENFORCED WHERE IT CAN BE — on the SERVER, which owns the
|
|
304
|
+
* roster and already refuses correctly. What this side owes is a RECOVERABLE refusal:
|
|
305
|
+
* `workspace.ts` turns `unknown agent 'X'` into the login that fixes it, and `BOOTSTRAP_ACTIONS`
|
|
306
|
+
* is what makes logging in to a fresh workspace possible at all.
|
|
307
|
+
*
|
|
308
|
+
* ⚠ DO NOT "FIX" THIS BY CACHING THE TARGET AT IMPORT TIME. The target changes with
|
|
309
|
+
* `beehaven connect` inside one process tree, so a cached value is wrong exactly when someone
|
|
310
|
+
* is moving between workspaces — the only case it would exist for.
|
|
311
|
+
*/
|
|
312
|
+
/**
|
|
313
|
+
* ⚠⚠ THE FAMILY FALLBACK RESOLVES AMBIGUITY BY RECENCY RATHER THAN REFUSING, and that is the
|
|
314
|
+
* opposite of the rule above ON PURPOSE. Refusing here would make the fallback useless the
|
|
315
|
+
* moment anyone has ever used two names from one tool — the ordinary case. It is safe because
|
|
316
|
+
* it can only ever apply to a session that has NOT logged in, which today resolves to nobody:
|
|
317
|
+
* the worst outcome is inheriting your own most recent identity instead of having none.
|
|
318
|
+
*/
|
|
319
|
+
const fam = scopeFamilyKey(scope);
|
|
320
|
+
if (!fam)
|
|
321
|
+
return null;
|
|
322
|
+
const kin = boundTo(fam);
|
|
323
|
+
if (!kin.length)
|
|
324
|
+
return null;
|
|
325
|
+
if (kin.length === 1)
|
|
326
|
+
return kin[0].name;
|
|
327
|
+
return [...kin]
|
|
328
|
+
.sort((a, b) => Number(b.record.lastLoginAt || 0)
|
|
329
|
+
- Number(a.record.lastLoginAt || 0))[0].name;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
export function loginSessionOutcome(started, thrown) {
|
|
336
|
+
if (thrown !== undefined) {
|
|
337
|
+
return { kind: 'unreachable', reason: String(thrown?.message ?? thrown) };
|
|
338
|
+
}
|
|
339
|
+
const payload = started?.result ?? started;
|
|
340
|
+
const id = payload?.session?.id;
|
|
341
|
+
if (typeof id === 'string' && id)
|
|
342
|
+
return { kind: 'started', sessionId: id, brief: payload?.brief };
|
|
343
|
+
const err = payload?.error ?? started?.error;
|
|
344
|
+
if (err) {
|
|
345
|
+
return {
|
|
346
|
+
kind: 'refused',
|
|
347
|
+
reason: typeof err === 'string' ? err : String(err?.message ?? JSON.stringify(err)),
|
|
348
|
+
code: String(payload?.code ?? started?.code ?? ''),
|
|
349
|
+
scope: String(payload?.scope ?? started?.scope ?? ''),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return { kind: 'refused', reason: 'the workspace returned no session', code: '', scope: '' };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The active agent name for this invocation: `--agent` flag, else $BEEHAVEN_AGENT, else the
|
|
356
|
+
* agent BOUND to this process scope by a previous `agent login`.
|
|
357
|
+
*/
|
|
358
|
+
export function activeAgentName(values) {
|
|
359
|
+
const fromFlag = values?.agent;
|
|
360
|
+
if (typeof fromFlag === 'string' && fromFlag.trim())
|
|
361
|
+
return fromFlag.trim();
|
|
362
|
+
const fromEnv = process.env.BEEHAVEN_AGENT;
|
|
363
|
+
if (fromEnv && fromEnv.trim())
|
|
364
|
+
return fromEnv.trim();
|
|
365
|
+
try {
|
|
366
|
+
return agentForScope(deriveAgentScope());
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Resolve the active agent identity for this invocation, or null when no
|
|
374
|
+
* agent is selected. Used by the call path to attach the token + route the
|
|
375
|
+
* avatar instance.
|
|
376
|
+
*/
|
|
377
|
+
export function resolveActiveAgent(values) {
|
|
378
|
+
const name = activeAgentName(values);
|
|
379
|
+
if (!name)
|
|
380
|
+
return null;
|
|
381
|
+
const record = loadAgent(name);
|
|
382
|
+
if (!record)
|
|
383
|
+
return null;
|
|
384
|
+
return {
|
|
385
|
+
name,
|
|
386
|
+
token: record.token,
|
|
387
|
+
...(record.localToken ? { localToken: record.localToken } : {}),
|
|
388
|
+
agentId: record.agentId,
|
|
389
|
+
avatarId: record.avatarId,
|
|
390
|
+
record,
|
|
391
|
+
expired: typeof record.expiresAt === 'number' && record.expiresAt <= Date.now(),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The agent's CURRENT session id, stored beside its identity.
|
|
396
|
+
*
|
|
397
|
+
* Without this the session id exists only in the login output — so resuming means an agent (or
|
|
398
|
+
* a person) copying a uuid out of scrollback, which is exactly the kind of friction that ends
|
|
399
|
+
* with everyone starting a fresh session every time and the history fragmenting into singles.
|
|
400
|
+
*/
|
|
401
|
+
export function saveAgentSession(name, sessionId) {
|
|
402
|
+
try {
|
|
403
|
+
const rec = loadAgent(name);
|
|
404
|
+
if (!rec)
|
|
405
|
+
return;
|
|
406
|
+
saveAgent(name, { ...rec, sessionId });
|
|
407
|
+
}
|
|
408
|
+
catch { /* the session is still live server-side; this is a convenience */ }
|
|
409
|
+
}
|
|
410
|
+
/** Clear active session metadata from disk when the agent logs out. */
|
|
411
|
+
export function clearAgentSession(name) {
|
|
412
|
+
try {
|
|
413
|
+
const rec = loadAgent(name);
|
|
414
|
+
if (!rec)
|
|
415
|
+
return;
|
|
416
|
+
const raw = { ...rec };
|
|
417
|
+
delete raw.sessionId;
|
|
418
|
+
delete raw.session;
|
|
419
|
+
delete raw.lastLoginAt;
|
|
420
|
+
saveAgent(name, raw);
|
|
421
|
+
}
|
|
422
|
+
catch { /* best-effort cleanup */ }
|
|
423
|
+
}
|
|
424
|
+
/** The last session id we started for this agent, if any. */
|
|
425
|
+
export function loadAgentSession(name) {
|
|
426
|
+
try {
|
|
427
|
+
return loadAgent(name)?.sessionId ?? null;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* WHICH AGENT THIS INSTALL ACTS AS — the desktop binding, read from disk.
|
|
435
|
+
*
|
|
436
|
+
* ⚠⚠ THIS IS A THIRD MIRROR OF ONE FILE FORMAT AND THAT IS THE HAZARD IT HAS TO ANSWER FOR.
|
|
437
|
+
* `apps/electron/electron/agent-binding.ts` OWNS the format and writes the file; the avatar
|
|
438
|
+
* building (`Avatar.ts:boundPrincipal`) reads it because it runs inside the daemon and cannot
|
|
439
|
+
* reach Electron's store; this exists for the same reason, one layer over — the daemon's own
|
|
440
|
+
* in-process MCP tools need the identity that the daemon's CLI spawns already carry.
|
|
441
|
+
*
|
|
442
|
+
* A reader pointed at the wrong path does not throw. It silently returns the fallback and every
|
|
443
|
+
* screen still looks right, while one install wears two identities in the activity log —
|
|
444
|
+
* `verify-agent-binding` reads all three files and fails when the path, the shape test or the
|
|
445
|
+
* fallback drift apart. Do not "simplify" any of the three in isolation.
|
|
446
|
+
*
|
|
447
|
+
* ⚠ IT NEVER THROWS. A missing file is the ordinary state of an install that has never opened the
|
|
448
|
+
* binding menu, and a settings read must not be able to fail a turn.
|
|
449
|
+
*/
|
|
450
|
+
export function desktopPrincipal() {
|
|
451
|
+
try {
|
|
452
|
+
const p = join(homedir(), '.beehaven', 'desktop-agent.json');
|
|
453
|
+
const id = String(JSON.parse(readFileSync(p, 'utf8'))?.agent ?? '').trim();
|
|
454
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id) ? id : 'clearly-desktop';
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return 'clearly-desktop';
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The identity the DAEMON's own tools should act as, or `null` if it has none usable.
|
|
462
|
+
*
|
|
463
|
+
* ⚠⚠ `null` MUST BE A REFUSAL AT THE CALL SITE, NEVER A FALL-THROUGH TO A RAW DISPATCH. That is
|
|
464
|
+
* the exact shape deleted from `beehaven call` on 2026-08-28, and the reason is worth repeating
|
|
465
|
+
* because it is counter-intuitive: an EXPIRED identity used to make privilege go **UP**. An agent
|
|
466
|
+
* bound by a read-only `tool_allowlist` whose token lapsed silently became the account owner and
|
|
467
|
+
* kept working — the one state where a credential must do LESS, it did more.
|
|
468
|
+
*
|
|
469
|
+
* ⚠ EXPIRY IS CHECKED HERE so a caller cannot forget to. `resolveActiveAgent` returns the record
|
|
470
|
+
* WITH an `expired` flag, which is right for a CLI that wants to print a specific message and
|
|
471
|
+
* wrong for a tool boundary that has one decision to make.
|
|
472
|
+
*/
|
|
473
|
+
export function daemonToolIdentity() {
|
|
474
|
+
const name = desktopPrincipal();
|
|
475
|
+
const record = loadAgent(name);
|
|
476
|
+
if (!record?.agentId)
|
|
477
|
+
return null;
|
|
478
|
+
if (typeof record.expiresAt === 'number' && record.expiresAt <= Date.now())
|
|
479
|
+
return null;
|
|
480
|
+
const sessionId = loadAgentSession(name);
|
|
481
|
+
return { agentId: record.agentId, name, ...(sessionId ? { sessionId } : {}) };
|
|
482
|
+
}
|