beehaven 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,1310 @@
1
+ /**
2
+ * `beehaven agent <sub>` — manage per-agent identities.
3
+ *
4
+ * An agent identity lets a runtime (a terminal Claude Code session, the
5
+ * daemon's harness, Clearly AI) "sign in" as a distinct principal and
6
+ * drive its own avatar. The signing secret lives only in the daemon, so
7
+ * `login` / `rebind` mint tokens over IPC (`agent:mint`); this command
8
+ * just stores the resulting record under ~/.beehaven/agents/<name>.json.
9
+ *
10
+ * Subcommands:
11
+ * login <name> [--label <work>] [--client <client>] [--avatar <id>] [--avatar-skin <skin>] [--kind <k>]
12
+ * use <name> — prints `export BEEHAVEN_AGENT=<name>` (eval it)
13
+ * list — list stored identities, mark the active one
14
+ * whoami — show the active identity
15
+ * logout [<name>] — end a session, keep the identity (default: active)
16
+ * delete <name> — archive the current workspace projection + remove this device copy
17
+ * delete <name> --account --yes
18
+ * — delete the portable account identity (all workspaces) + this device copy
19
+ * rebind <name> --avatar <id> — re-bind to a different avatar
20
+ *
21
+ * Per-terminal activation is via the BEEHAVEN_AGENT env var (inherently
22
+ * per-tty → N windows, N agents); `--agent <name>` overrides per call.
23
+ */
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
25
+ import { join, dirname } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+ import { rpcOnce } from '../ipc-client.js';
28
+ import { saveAgent, loadAgent, listAgents, deleteAgent, findAgentByAvatar, isValidAgentName, resolveActiveAgent, activeAgentName, saveAgentSession, clearAgentSession, decodeAgentTokenClaims, } from '../agent-state.js';
29
+ const VALID_KINDS = ['claude-code', 'daemon-agent', 'clearly-ai'];
30
+ export const FREE_SKINS = [
31
+ 'classic',
32
+ 'midnight',
33
+ 'moss',
34
+ 'ember',
35
+ 'tuck-classic',
36
+ 'tuck-midnight',
37
+ 'tuck-moss',
38
+ 'tuck-ember',
39
+ 'lop',
40
+ 'smudge',
41
+ 'blot',
42
+ 'dab',
43
+ 'pip',
44
+ ];
45
+ export function pickRandomOwnedSkinCli(excludeSkins) {
46
+ const excluded = new Set(excludeSkins ?? []);
47
+ const unassigned = FREE_SKINS.filter((s) => !excluded.has(s));
48
+ if (unassigned.length > 0) {
49
+ return unassigned[Math.floor(Math.random() * unassigned.length)];
50
+ }
51
+ return FREE_SKINS[Math.floor(Math.random() * FREE_SKINS.length)];
52
+ }
53
+ /** Native Mwah skin ids accepted by `--avatar-skin`; the legacy `--avatar` flag also accepts
54
+ * these values for backwards compatibility. */
55
+ function normalizeCliAvatarSkin(raw) {
56
+ const value = raw.trim().toLowerCase();
57
+ if (value === 'nib')
58
+ return 'classic';
59
+ if (value === 'tuck')
60
+ return 'tuck-classic';
61
+ return FREE_SKINS.includes(value) ? value : null;
62
+ }
63
+ /** End the workspace session before removing its local handle. Closing the daemon's presence
64
+ * socket alone cannot update the Durable Object row, so logout used to leave an `active` session
65
+ * behind until the server's idle sweep noticed it. Best-effort keeps offline logout usable; the
66
+ * 30-minute server reaper is the fallback when the relay is unavailable. */
67
+ async function endWorkspaceSession(record, summary) {
68
+ const sessionId = String(record?.sessionId ?? '').trim();
69
+ if (!sessionId)
70
+ return false;
71
+ try {
72
+ const response = await rpcOnce('agent-session-end', {
73
+ id: sessionId,
74
+ ...(record?.agentId ? { agentId: record.agentId } : {}),
75
+ summary,
76
+ }, 5_000);
77
+ const result = response?.result ?? response;
78
+ return result?.ok !== false && !result?.error;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
85
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
86
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
87
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
88
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
89
+ export async function cmdAgentSub(positionals, values) {
90
+ // positionals[0] === 'agent'; positionals[1] === subcommand
91
+ const sub = positionals[1];
92
+ const rest = positionals.slice(2);
93
+ switch (sub) {
94
+ case 'help':
95
+ return printAgentUsage();
96
+ case 'login':
97
+ return agentLogin(rest, values);
98
+ case 'use':
99
+ return agentUse(rest);
100
+ case 'list':
101
+ case 'ls':
102
+ return agentList(values);
103
+ case 'whoami':
104
+ return agentWhoami(values);
105
+ case 'status':
106
+ return agentStatus(rest, values);
107
+ case 'logout':
108
+ return agentLogout(rest, values);
109
+ case 'delete':
110
+ case 'remove':
111
+ return agentDelete(rest, values);
112
+ case 'rebind':
113
+ return agentRebind(rest, values);
114
+ case 'soul':
115
+ return agentSoulCommand(rest, values);
116
+ case 'doodle-space':
117
+ return agentDoodleSpaceCommand(rest, values);
118
+ case 'home':
119
+ case 'agent-home':
120
+ return agentHomeCommand(rest, values);
121
+ default:
122
+ console.error(` Unknown agent subcommand: ${sub ?? '(none)'}`);
123
+ printAgentUsage();
124
+ process.exit(1);
125
+ }
126
+ }
127
+ /** Whether positionals[1] is an identity subcommand (vs. a mwah message). */
128
+ export function isAgentIdentitySub(sub) {
129
+ return (sub === 'login' ||
130
+ sub === 'help' ||
131
+ sub === 'use' ||
132
+ sub === 'list' ||
133
+ sub === 'ls' ||
134
+ sub === 'whoami' ||
135
+ sub === 'status' ||
136
+ sub === 'logout' ||
137
+ sub === 'delete' ||
138
+ sub === 'remove' ||
139
+ sub === 'rebind' ||
140
+ sub === 'soul' ||
141
+ sub === 'doodle-space'
142
+ || sub === 'home' ||
143
+ sub === 'agent-home');
144
+ }
145
+ function printAgentUsage() {
146
+ console.log('');
147
+ console.log(' Usage: beehaven agent <command>');
148
+ console.log('');
149
+ console.log(' Identity and work-session workflow:');
150
+ console.log(' beehaven env');
151
+ console.log(' beehaven connect home');
152
+ console.log(' beehaven agent login <name> --label "<work>" --client cli');
153
+ console.log('');
154
+ console.log(' login mints or refreshes the account-level agent identity, binds this');
155
+ console.log(' terminal, starts ONE workspace work session, and prints its brief.');
156
+ console.log(' Do not immediately call `agent-login`: that RPC is the advanced alias');
157
+ console.log(' of `agent-session-start` and would create another session.');
158
+ console.log('');
159
+ console.log(' login <name> [--label <work>] [--client <client>] [--avatar <id>] [--avatar-skin <skin>] [--kind <k>]');
160
+ console.log(' use <name> activate in this terminal (eval the output)');
161
+ console.log(' list list stored identities');
162
+ console.log(' whoami show the active identity');
163
+ console.log(' status [<name>] --message <text> set the persistent desktop status label');
164
+ console.log(' logout [<name>] end the session, KEEP the identity (default: active)');
165
+ console.log(' delete <name> archive this workspace projection + remove this device copy');
166
+ console.log(' delete <name> --account --yes delete the portable account identity + this device copy');
167
+ console.log(' rebind <name> --avatar <id> re-bind to a different avatar');
168
+ console.log(' soul get [--agent <name>] read the opt-in Avatar Soul + baseline');
169
+ console.log(' soul update --svg <svg|@file> [--state <state>]');
170
+ console.log(' soul draft get/save read or save a freeform pre-mint draft');
171
+ console.log(' doodle-space get [--session <id>]');
172
+ console.log(' doodle-space draw --svg <svg|@file> [--session <id>] [--title <t>]');
173
+ console.log(' home get [--json] open the persistent grid apartment');
174
+ console.log(' home layout --operations <json> arrange rooms, doors, windows, and furniture');
175
+ console.log(' home memory --value <text> [--key <k>] place a durable memory object');
176
+ console.log(' home journal --entry <text> add a private journal object');
177
+ console.log(' home ad --title <text> [--tagline <t>] create a mini-ad in the Doodle Studio');
178
+ console.log(' home avatar --room <id> --gx <n> --gy <n> move yourself inside the Home');
179
+ console.log(' (requires AGENT_SOUL_SPACE on staging)');
180
+ console.log('');
181
+ }
182
+ function actionPayload(raw) {
183
+ return raw?.result ?? raw;
184
+ }
185
+ function svgArgument(raw) {
186
+ const value = String(raw ?? '').trim();
187
+ if (!value) {
188
+ console.error(' --svg is required. Pass inline SVG or @/path/to/drawing.svg.');
189
+ process.exit(1);
190
+ }
191
+ if (!value.startsWith('@'))
192
+ return value;
193
+ try {
194
+ return readFileSync(value.slice(1), 'utf8').trim();
195
+ }
196
+ catch (err) {
197
+ console.error(` Could not read SVG file ${value.slice(1)}: ${err?.message ?? err}`);
198
+ process.exit(1);
199
+ }
200
+ }
201
+ function soulAgentId(values) {
202
+ const id = String(values.agent ?? '').trim();
203
+ return id || undefined;
204
+ }
205
+ function actionNumber(values, key) {
206
+ const raw = values[key];
207
+ if (raw === undefined || raw === null || String(raw).trim() === '')
208
+ return undefined;
209
+ const value = Number(raw);
210
+ if (!Number.isFinite(value)) {
211
+ console.error(` --${key} must be a finite number.`);
212
+ process.exit(1);
213
+ }
214
+ return value;
215
+ }
216
+ function printSoulAction(raw, fallback, asJson = false) {
217
+ const payload = actionPayload(raw);
218
+ if (payload?.ok === false || payload?.error || raw?.error) {
219
+ console.error(` ${payload?.error ?? raw?.error ?? fallback}`);
220
+ process.exit(1);
221
+ }
222
+ if (asJson)
223
+ console.log(JSON.stringify(payload, null, 2));
224
+ else if (payload?.compositionId)
225
+ console.log(` ${green('✓')} ${fallback}: ${cyan(String(payload.compositionId))}`);
226
+ else if (payload?.nodeId)
227
+ console.log(` ${green('✓')} ${fallback}: ${cyan(String(payload.nodeId))}`);
228
+ else
229
+ console.log(JSON.stringify(payload, null, 2));
230
+ }
231
+ async function agentSoulCommand(args, values) {
232
+ const op = args[0] ?? 'get';
233
+ if (op === 'help') {
234
+ console.log(' Usage: beehaven agent soul get [--agent <name>]');
235
+ console.log(' beehaven agent soul update --svg <svg|@file> [--state <state>] [--note <text>]');
236
+ console.log(' beehaven agent soul draft get [--agent <name>]');
237
+ console.log(' beehaven agent soul draft save --svg <svg|@file> [--agent <name>]');
238
+ return;
239
+ }
240
+ if (op === 'draft') {
241
+ return agentSoulDraftCommand(args.slice(1), values);
242
+ }
243
+ const agentId = soulAgentId(values);
244
+ if (op === 'get') {
245
+ const response = await rpcOnce('agent-soul-get', agentId ? { agentId } : {}, 15_000);
246
+ const payload = actionPayload(response);
247
+ if (payload?.ok === false || payload?.error || response?.error) {
248
+ console.error(` ${payload?.error ?? response?.error ?? 'Could not read Avatar Soul.'}`);
249
+ process.exit(1);
250
+ }
251
+ if (values.json)
252
+ console.log(JSON.stringify(payload, null, 2));
253
+ else {
254
+ console.log(` ${bold('Avatar Soul')} · ${payload.displayName ?? payload.agentId}`);
255
+ console.log(` ${payload.appearance ?? 'No appearance description returned.'}`);
256
+ console.log(` Baseline: ${payload.baseline?.svg ? 'ready' : 'missing'} · revision ${payload.revision ?? 0}`);
257
+ if (payload.current?.state)
258
+ console.log(` Current: ${payload.current.state}`);
259
+ if (payload.desktopAppearance) {
260
+ const d = payload.desktopAppearance;
261
+ console.log(` Desktop: ${d.effectiveMode ?? d.requestedMode ?? 'preset'}${d.agentDrawnEnabled ? ' · agent drawn enabled' : ' · preset fallback'}`);
262
+ }
263
+ console.log(dim(' Use --json when you need the full SVG and soul record.'));
264
+ }
265
+ return;
266
+ }
267
+ if (op === 'update') {
268
+ const svg = svgArgument(values.svg);
269
+ const response = await rpcOnce('agent-soul-avatar-update', {
270
+ ...(agentId ? { agentId } : {}),
271
+ svg,
272
+ ...(values.state ? { state: String(values.state) } : {}),
273
+ ...(values.note ? { note: String(values.note) } : {}),
274
+ ...(values.session ? { sessionId: String(values.session) } : {}),
275
+ }, 15_000);
276
+ printSoulAction(response, 'Avatar Soul updated', !!values.json);
277
+ return;
278
+ }
279
+ console.error(` Unknown soul operation: ${op}`);
280
+ console.error(' Use: get | update');
281
+ process.exit(1);
282
+ }
283
+ async function agentSoulDraftCommand(args, values) {
284
+ const op = args[0] ?? 'get';
285
+ const agentId = soulAgentId(values);
286
+ if (op === 'help') {
287
+ console.log(' Usage: beehaven agent soul draft get [--agent <name>]');
288
+ console.log(' beehaven agent soul draft save --svg <svg|@file> [--agent <name>] [--appearance <text>]');
289
+ return;
290
+ }
291
+ if (op === 'get') {
292
+ const response = await rpcOnce('agent-soul-draft-get', agentId ? { agentId } : {}, 15_000);
293
+ const payload = actionPayload(response);
294
+ if (payload?.ok === false || payload?.error || response?.error) {
295
+ console.error(` ${payload?.error ?? response?.error ?? 'Could not read the Avatar Soul draft.'}`);
296
+ process.exit(1);
297
+ }
298
+ if (values.json)
299
+ console.log(JSON.stringify(payload, null, 2));
300
+ else if (payload?.draft) {
301
+ console.log(` ${bold('Avatar Soul draft')} · ${payload.agentId}`);
302
+ console.log(` ${payload.draft.appearance ?? 'Freeform drawing with no description yet.'}`);
303
+ console.log(` SVG: ready · can seed baseline: ${payload.canSeedBaseline ? 'yes' : 'no'}`);
304
+ }
305
+ else {
306
+ console.log(` No pre-mint Avatar Soul draft for ${payload.agentId}.`);
307
+ }
308
+ return;
309
+ }
310
+ if (op === 'save') {
311
+ const svg = svgArgument(values.svg);
312
+ const response = await rpcOnce('agent-soul-draft-save', {
313
+ ...(agentId ? { agentId } : {}),
314
+ svg,
315
+ ...(values['display-name'] ? { displayName: String(values['display-name']) } : {}),
316
+ ...(values.appearance ? { appearance: String(values.appearance) } : {}),
317
+ ...(values.note ? { note: String(values.note) } : {}),
318
+ }, 15_000);
319
+ printSoulAction(response, 'Avatar Soul draft saved', !!values.json);
320
+ return;
321
+ }
322
+ console.error(` Unknown soul draft operation: ${op}`);
323
+ console.error(' Use: get | save');
324
+ process.exit(1);
325
+ }
326
+ async function agentDoodleSpaceCommand(args, values) {
327
+ const op = args[0] ?? 'get';
328
+ const agentId = soulAgentId(values);
329
+ if (op === 'help') {
330
+ console.log(' Usage: beehaven agent doodle-space get [--session <id>]');
331
+ console.log(' beehaven agent doodle-space draw --svg <svg|@file> [--session <id>] [--title <text>]');
332
+ return;
333
+ }
334
+ if (op === 'get') {
335
+ const response = await rpcOnce('agent-doodle-space-get', {
336
+ ...(agentId ? { agentId } : {}),
337
+ ...(values.session ? { sessionId: String(values.session) } : {}),
338
+ ...(values.title ? { title: String(values.title) } : {}),
339
+ }, 15_000);
340
+ const payload = actionPayload(response);
341
+ if (payload?.ok === false || payload?.error || response?.error) {
342
+ console.error(` ${payload?.error ?? response?.error ?? 'Could not open Doodle Space.'}`);
343
+ process.exit(1);
344
+ }
345
+ if (values.json)
346
+ console.log(JSON.stringify(payload, null, 2));
347
+ else if (payload?.compositionId) {
348
+ console.log(` ${green('✓')} Doodle Space: ${cyan(String(payload.compositionId))}`);
349
+ if (payload.viewBox)
350
+ console.log(` ViewBox: ${payload.viewBox}`);
351
+ if (payload.latestDoodle?.svg)
352
+ console.log(` Latest drawing: ready · ${payload.desktopAppearance?.doodleBannerEnabled === false ? 'banner hidden' : 'banner available'}`);
353
+ }
354
+ else
355
+ console.log(` No Doodle Space exists for session ${payload?.sessionId ?? 'default'}.`);
356
+ return;
357
+ }
358
+ if (op === 'draw') {
359
+ const svg = svgArgument(values.svg);
360
+ const x = actionNumber(values, 'x');
361
+ const y = actionNumber(values, 'y');
362
+ const w = actionNumber(values, 'w');
363
+ const h = actionNumber(values, 'h');
364
+ const response = await rpcOnce('agent-doodle-space-draw', {
365
+ ...(agentId ? { agentId } : {}),
366
+ svg,
367
+ ...(values.session ? { sessionId: String(values.session) } : {}),
368
+ ...(values.title ? { title: String(values.title) } : {}),
369
+ ...(values.state ? { state: String(values.state) } : {}),
370
+ ...(values.note ? { note: String(values.note) } : {}),
371
+ ...(x !== undefined ? { x } : {}),
372
+ ...(y !== undefined ? { y } : {}),
373
+ ...(w !== undefined ? { w } : {}),
374
+ ...(h !== undefined ? { h } : {}),
375
+ }, 15_000);
376
+ printSoulAction(response, 'Doodle added', !!values.json);
377
+ return;
378
+ }
379
+ console.error(` Unknown doodle-space operation: ${op}`);
380
+ console.error(' Use: get | draw');
381
+ process.exit(1);
382
+ }
383
+ function jsonArgument(raw, label) {
384
+ const value = String(raw ?? '').trim();
385
+ if (!value) {
386
+ console.error(` --${label} is required.`);
387
+ process.exit(1);
388
+ }
389
+ try {
390
+ return JSON.parse(value);
391
+ }
392
+ catch (err) {
393
+ console.error(` --${label} must be valid JSON: ${err?.message ?? err}`);
394
+ process.exit(1);
395
+ }
396
+ }
397
+ async function agentHomeCommand(args, values) {
398
+ const op = args[0] ?? 'get';
399
+ const agentId = soulAgentId(values);
400
+ const call = async (action, input) => {
401
+ const response = await rpcOnce(action, { ...(agentId ? { agentId } : {}), ...input }, 20_000);
402
+ const payload = actionPayload(response);
403
+ if (payload?.ok === false || payload?.error || response?.error) {
404
+ console.error(` ${payload?.error ?? response?.error ?? `Could not run ${action}.`}`);
405
+ process.exit(1);
406
+ }
407
+ if (values.json)
408
+ console.log(JSON.stringify(payload, null, 2));
409
+ else {
410
+ const id = payload?.compositionId || payload?.memoryId || payload?.id;
411
+ console.log(` ${green('✓')} ${action}${id ? `: ${cyan(String(id))}` : ''}`);
412
+ if (payload?.grid?.unit)
413
+ console.log(` Grid: ${payload.grid.unit}px · ${payload.grid.columns}×${payload.grid.rows} cells`);
414
+ if (payload?.roomId)
415
+ console.log(` Room: ${payload.roomId}`);
416
+ if (payload?.revision != null)
417
+ console.log(` Revision: ${payload.revision}`);
418
+ }
419
+ };
420
+ if (op === 'help') {
421
+ console.log(' Usage: beehaven agent home get [--agent <name>]');
422
+ console.log(' beehaven agent home layout --operations <json> [--revision <n>]');
423
+ console.log(' beehaven agent home memory --value <text> [--key <key>] [--room <id>]');
424
+ console.log(' beehaven agent home journal --entry <text> [--title <text>] [--room <id>]');
425
+ console.log(' beehaven agent home ad --title <text> [--tagline <text>]');
426
+ console.log(' beehaven agent home avatar --room <id> --gx <n> --gy <n>');
427
+ return;
428
+ }
429
+ if (op === 'get') {
430
+ await call('agent-home-get', {
431
+ ...(values.title ? { title: String(values.title) } : {}),
432
+ ...(values.project ? { projectId: String(values.project) } : {}),
433
+ ...(values.create !== undefined ? { create: values.create !== 'false' } : {}),
434
+ });
435
+ return;
436
+ }
437
+ if (op === 'layout') {
438
+ const revision = actionNumber(values, 'revision');
439
+ await call('agent-home-layout', {
440
+ operations: jsonArgument(values.operations, 'operations'),
441
+ ...(revision !== undefined ? { expectedRevision: revision } : {}),
442
+ });
443
+ return;
444
+ }
445
+ if (op === 'memory') {
446
+ await call('agent-home-memory', {
447
+ value: String(values.value ?? ''),
448
+ ...(values.key ? { key: String(values.key) } : {}),
449
+ ...(values.room ? { roomId: String(values.room) } : {}),
450
+ ...(values.category ? { category: String(values.category) } : {}),
451
+ ...(values.visibility ? { visibility: String(values.visibility) } : {}),
452
+ ...(['gx', 'gy', 'gw', 'gh'].reduce((out, key) => { const n = actionNumber(values, key); if (n !== undefined)
453
+ out[key] = n; return out; }, {})),
454
+ });
455
+ return;
456
+ }
457
+ if (op === 'journal') {
458
+ await call('agent-home-journal', {
459
+ entry: String(values.entry ?? ''),
460
+ ...(values.title ? { title: String(values.title) } : {}),
461
+ ...(values.key ? { key: String(values.key) } : {}),
462
+ ...(values.room ? { roomId: String(values.room) } : {}),
463
+ });
464
+ return;
465
+ }
466
+ if (op === 'ad') {
467
+ await call('agent-home-ad', {
468
+ ...(values.title ? { title: String(values.title) } : {}),
469
+ ...(values.tagline ? { tagline: String(values.tagline) } : {}),
470
+ ...(values.accent ? { accent: String(values.accent) } : {}),
471
+ ...(values.room ? { roomId: String(values.room) } : {}),
472
+ ...(values.svg ? { svg: svgArgument(values.svg) } : {}),
473
+ });
474
+ return;
475
+ }
476
+ if (op === 'avatar') {
477
+ const gx = actionNumber(values, 'gx');
478
+ const gy = actionNumber(values, 'gy');
479
+ await call('agent-home-avatar-location', { ...(values.room ? { roomId: String(values.room) } : {}), ...(gx !== undefined ? { gx } : {}), ...(gy !== undefined ? { gy } : {}) });
480
+ return;
481
+ }
482
+ console.error(` Unknown home operation: ${op}`);
483
+ console.error(' Use: get | layout | memory | journal | ad | avatar');
484
+ process.exit(1);
485
+ }
486
+ // ── login ────────────────────────────────────────────
487
+ async function agentLogin(args, values) {
488
+ const name = args[0];
489
+ if (!name) {
490
+ console.error(' Usage: beehaven agent login <name> [--label <work>] [--client <client>] [--avatar <id>] [--avatar-skin <skin>] [--kind <k>]');
491
+ process.exit(1);
492
+ }
493
+ if (!isValidAgentName(name)) {
494
+ console.error(` Invalid agent name "${name}" — use letters, digits, '.', '_', '-' (max 64).`);
495
+ process.exit(1);
496
+ }
497
+ const requestedAvatarId = String(values.avatar ?? '').trim();
498
+ const requestedSkinRaw = String(values['avatar-skin'] ?? '').trim();
499
+ const requestedSkin = requestedSkinRaw
500
+ ? normalizeCliAvatarSkin(requestedSkinRaw)
501
+ : (requestedAvatarId ? normalizeCliAvatarSkin(requestedAvatarId) : null);
502
+ if (requestedSkinRaw && !requestedSkin) {
503
+ console.error(` Unknown avatar skin "${requestedSkinRaw}". Use: ${FREE_SKINS.join(', ')}`);
504
+ process.exit(1);
505
+ }
506
+ const existingRecord = loadAgent(name);
507
+ const kind = VALID_KINDS.includes(values.kind) ? values.kind : 'claude-code';
508
+ const displayName = String(values['display-name'] ?? name);
509
+ // Optional voice binding: --agent-voice <provider[:voiceId]> (e.g. openai:sage,
510
+ // kokoro:claudette, elevenlabs:EXAVITQu4vr4xnSDxMaL). Bound to the identity
511
+ // so this agent's avatar speaks in its own voice — carried in the token's
512
+ // (non-secret) meta and hydrated by the avatar building on each call.
513
+ /**
514
+ * ⚠ `--agent-voice` IS THE REAL FLAG; `--voice` IS KEPT ONLY AS A TOLERATED ALIAS.
515
+ * See the declaration in cli.ts — `--voice` is a boolean (push-to-talk), so the
516
+ * string branch here is unreachable through normal parsing and exists purely so a
517
+ * programmatic caller passing `{ voice: 'qwen3-mlx:ethan' }` still works.
518
+ */
519
+ const voiceRaw = typeof values['agent-voice'] === 'string' && values['agent-voice'].trim()
520
+ ? String(values['agent-voice']).trim()
521
+ : (typeof values.voice === 'string' ? values.voice.trim() : '');
522
+ let voice;
523
+ if (voiceRaw) {
524
+ const { parseVoiceBinding, isKnownVoiceProvider, VOICE_CHOICES } = await import('../agent-onboard.js');
525
+ const parsed = parseVoiceBinding(voiceRaw);
526
+ /**
527
+ * ⚠⚠ A MISSPELLED PROVIDER USED TO BIND SILENTLY AND FAIL AT SPEAK TIME.
528
+ *
529
+ * This took any string, so `--voice kokoru:claudette` minted a perfectly valid
530
+ * identity whose voice named an engine that does not exist. Nothing complained
531
+ * until the agent spoke — and even then the avatar fell through to its default
532
+ * provider, so the symptom was "my agent sounds wrong", noticed much later,
533
+ * with the cause sitting in a token nobody re-reads.
534
+ *
535
+ * ⚠ THE VOICE ID IS DELIBERATELY NOT VALIDATED. Valid ids differ per provider,
536
+ * change over time (ElevenLabs ids are account-scoped) and include names this
537
+ * file cannot know — a Qwen3 cloned-voice profile is created by the user after
538
+ * login. The provider is a closed set; the id is not.
539
+ */
540
+ if (!parsed || !isKnownVoiceProvider(parsed.provider)) {
541
+ console.error(` Unknown voice provider "${parsed?.provider ?? voiceRaw}".`);
542
+ console.error(` Expected one of: ${VOICE_CHOICES.map((v) => v.provider).join(' | ')}`);
543
+ console.error(` Example: beehaven agent login ${name} --agent-voice qwen3-mlx:ethan`);
544
+ process.exit(1);
545
+ }
546
+ voice = parsed;
547
+ }
548
+ // Determine the persisted Mwah skin before minting so the account registry and this device use
549
+ // the same choice. A new identity uses its selected/auto-assigned skin as its avatar id; a
550
+ // re-login preserves the identity's existing routing id unless the caller explicitly changes it.
551
+ let chosenSkin = requestedSkin || existingRecord?.avatarSkin;
552
+ if (!chosenSkin) {
553
+ const boundSkins = new Set();
554
+ for (const { name: otherName, record: otherRecord } of listAgents()) {
555
+ if (otherName === name)
556
+ continue;
557
+ if (otherRecord.avatarSkin)
558
+ boundSkins.add(otherRecord.avatarSkin);
559
+ else if (otherRecord.avatarId && FREE_SKINS.includes(otherRecord.avatarId)) {
560
+ boundSkins.add(otherRecord.avatarId);
561
+ }
562
+ }
563
+ try {
564
+ const p = join(homedir(), 'Library', 'Application Support', 'clearly-desktop', 'config.json');
565
+ if (existsSync(p)) {
566
+ const c = JSON.parse(readFileSync(p, 'utf8'));
567
+ for (const [k, v] of Object.entries(c)) {
568
+ if (k.startsWith('avatarSkin_') && typeof v === 'string')
569
+ boundSkins.add(v);
570
+ }
571
+ }
572
+ }
573
+ catch { }
574
+ chosenSkin = pickRandomOwnedSkinCli(boundSkins);
575
+ }
576
+ const avatarId = requestedAvatarId || existingRecord?.avatarId || chosenSkin || name;
577
+ // Local-first avatar-collision guard (durable guard is the cloud pool).
578
+ const collision = findAgentByAvatar(avatarId, name);
579
+ if (collision) {
580
+ console.error(` Avatar "${avatarId}" is already claimed by agent "${collision}".`);
581
+ console.error(` Pick another: beehaven agent login ${name} --avatar <other-id>`);
582
+ process.exit(1);
583
+ }
584
+ let minted = null;
585
+ let sessionOpen = false;
586
+ /**
587
+ * ⚠⚠ ACCOUNT-LEVEL AND CLOUD-ONLY. THE LOCAL MINT FALLBACK IS GONE, DELIBERATELY.
588
+ *
589
+ * This used to try `agent-mint-token` (which resolves the agent in whatever WORKSPACE the call
590
+ * landed on) and, on any failure, fall through to a LOCAL `agent:mint`. That fallback is what
591
+ * made "✓ Signed in" a lie: the local token is not cloud-verifiable, so the workspace answered
592
+ * `unknown agent` on every subsequent call while the CLI insisted you were logged in. Measured
593
+ * on a workspace with no agents — login reported success and a session, and nothing worked.
594
+ *
595
+ * An identity now comes from `agent-account-mint`: cloud-signed, owned by the ACCOUNT, recorded
596
+ * in the D1 `AgentRegistry` the SDK already describes as *"sign in anywhere, get the same
597
+ * agent"*. It creates no workspace row and spends no agent slot — joining a workspace is
598
+ * `agent-admit`, which `beehaven connect` performs.
599
+ *
600
+ * ⚠ A FAILURE HERE IS FATAL AND SAYS WHY. Half a login is worse than none: it is the state that
601
+ * costs someone an afternoon, because every symptom points at the workspace rather than at the
602
+ * credential.
603
+ */
604
+ try {
605
+ const cloud = await rpcOnce('agent-account-mint', { agentId: name, avatarId }, 15_000);
606
+ const tok = cloud?.token ?? cloud?.result?.token;
607
+ if (tok) {
608
+ minted = {
609
+ token: tok,
610
+ agentId: cloud.agentId ?? cloud.result?.agentId ?? name,
611
+ avatarId: cloud.avatarId ?? cloud.result?.avatarId ?? avatarId,
612
+ displayName: cloud.displayName ?? cloud.result?.displayName ?? displayName,
613
+ kind,
614
+ // Full context injected on login — the agent's memory bundle
615
+ // (pinned + top memories, agent-scoped + shared) rides in here.
616
+ memory: cloud.memory ?? cloud.result?.memory,
617
+ // Opt-in Avatar Soul capability. The server omits this entirely while
618
+ // AGENT_SOUL_SPACE is disabled, so the login message never implies a
619
+ // second avatar system is available when the flag is off.
620
+ soul: cloud.soul ?? cloud.result?.soul,
621
+ };
622
+ try {
623
+ const conn = await rpcOnce('agent:connect', { agentId: minted.agentId, token: tok }, 15_000);
624
+ sessionOpen = !!(conn?.ok ?? conn?.result?.ok);
625
+ }
626
+ catch {
627
+ /* session is best-effort — the identity is still saved below */
628
+ }
629
+ }
630
+ }
631
+ catch (err) {
632
+ console.error(` Could not mint identity: ${err?.message ?? err}`);
633
+ console.error(' Is the daemon connected? Check with: beehaven status');
634
+ process.exit(1);
635
+ }
636
+ /**
637
+ * ⚠ NO SILENT DEGRADE. There is nothing to fall back to any more, and that is the point — see
638
+ * the note above. An identity that the cloud did not sign cannot be attributed, cannot be
639
+ * revoked from the account, and is refused by every workspace.
640
+ */
641
+ if (!minted?.token) {
642
+ console.error(` Could not mint identity: ${minted?.error ?? 'the server returned no token'}`);
643
+ if (String(minted?.code) === 'not-configured') {
644
+ console.error(' The deployment is missing AGENT_IDENTITY_SECRET — this is a server-side fix.');
645
+ }
646
+ process.exit(1);
647
+ }
648
+ if (!minted?.token) {
649
+ console.error(` Mint failed: ${minted?.error ?? 'no token returned'}`);
650
+ process.exit(1);
651
+ }
652
+ /**
653
+ * The workspace token and the desktop token have different verifiers. The former is signed by
654
+ * the staging/prod worker so it can travel through workspace RPCs; the latter is signed by the
655
+ * local daemon's keychain secret so `app-rpc` can safely drive a local avatar. Keeping the two
656
+ * credentials separate prevents a cloud-signed token from being presented to a verifier that
657
+ * cannot know the worker's secret — the failure used to surface as AGENT_INVALID after every
658
+ * Mac app restart, even though `agent login` itself succeeded.
659
+ */
660
+ let localToken;
661
+ try {
662
+ const local = await rpcOnce('agent:mint', {
663
+ agentId: minted.agentId,
664
+ kind,
665
+ displayName,
666
+ avatarId: minted.avatarId,
667
+ permissions: ['avatar:say', 'avatar:speak', 'avatar:emote'],
668
+ ...(voice ? { meta: { voice } } : {}),
669
+ }, 15_000);
670
+ localToken = local?.token ?? local?.result?.token;
671
+ if (!localToken)
672
+ console.error(dim(' ⚠ local avatar credential was not returned; workspace calls remain available'));
673
+ }
674
+ catch (err) {
675
+ // A headless/cloud-only CLI can still use the cloud identity without a local daemon. Preserve
676
+ // that workflow, but make the missing local avatar leg visible instead of silently sending a
677
+ // token the daemon cannot verify.
678
+ console.error(dim(` ⚠ local avatar credential unavailable: ${err?.message ?? err}`));
679
+ }
680
+ /**
681
+ * ⚠ THE BIRTHDAY IS CARRIED FORWARD, NOT RE-STAMPED.
682
+ *
683
+ * `agent login` is also how an existing identity refreshes its token, so this
684
+ * runs over records that already exist. Taking `new Date()` unconditionally
685
+ * would reset every agent's age on a routine refresh — and because `issuedAt`
686
+ * already moves, nothing else would be left that remembers when the agent
687
+ * actually began. `existingRecord` is read before the write for exactly this.
688
+ */
689
+ const birthday = existingRecord?.birthday ?? new Date().toISOString();
690
+ const previousSessionEnded = await endWorkspaceSession(existingRecord, 'Replaced by a new login session for this agent identity.');
691
+ /**
692
+ * ⚠⚠ FILL THE LIFETIME FIELDS FROM THE TOKEN — the cloud mint path does not
693
+ * return them and a record without `expiresAt` can never be seen as expired.
694
+ * See `decodeAgentTokenClaims` for the full failure. Envelope values win when
695
+ * present (the local mint returns them from verified claims); the token is the
696
+ * fallback, and it is the same data either way.
697
+ */
698
+ const tokenClaims = decodeAgentTokenClaims(minted.token) ?? {};
699
+ const userId = minted.userId ?? tokenClaims.userId;
700
+ const issuedAt = minted.issuedAt ?? tokenClaims.issuedAt;
701
+ const expiresAt = minted.expiresAt ?? tokenClaims.expiresAt;
702
+ const record = {
703
+ agentId: minted.agentId,
704
+ kind: minted.kind,
705
+ displayName: minted.displayName,
706
+ avatarId: minted.avatarId,
707
+ ...(chosenSkin ? { avatarSkin: chosenSkin } : {}),
708
+ userId,
709
+ birthday,
710
+ token: minted.token,
711
+ ...(localToken ? { localToken } : {}),
712
+ issuedAt,
713
+ expiresAt,
714
+ ...(voice ? { voice } : {}),
715
+ /**
716
+ * ⚠⚠ THE BINDINGS SURVIVE THE RE-MINT — the same rule `birthday` is carried forward for,
717
+ * applied to every DURABLE field rather than to one.
718
+ *
719
+ * This object is built from scratch on each login, so `boundScopes` was DESTROYED by it:
720
+ * log in from a second terminal and the first silently reverted to "not logged in" while
721
+ * holding a stored, valid, unexpired identity. Measured — a login here dropped an existing
722
+ * `claude-507a5e19c44a` binding on the floor.
723
+ *
724
+ * ⚠ `sessionId` too. `saveAgentSession` normally rewrites it moments later, so the loss only
725
+ * surfaces when the workspace is unreachable — offline, where losing the session you were
726
+ * resuming is least recoverable.
727
+ */
728
+ ...(existingRecord?.boundScopes ? { boundScopes: existingRecord.boundScopes } : {}),
729
+ ...(!previousSessionEnded && existingRecord?.sessionId ? { sessionId: existingRecord.sessionId } : {}),
730
+ ...(existingRecord?.lastLoginAt ? { lastLoginAt: existingRecord.lastLoginAt } : {}),
731
+ };
732
+ saveAgent(name, record);
733
+ /**
734
+ * ⚠⚠ THIS SESSION IS `name` FROM HERE ON, WHATEVER THE WORKSPACE SAYS NEXT.
735
+ *
736
+ * The binding used to happen only after `agent-session-start` succeeded. When the workspace
737
+ * refused (an archived agent), nothing was bound, and every later call resolved through the
738
+ * FAMILY fallback to a sibling identity — `claude` logged in, `jinri-claude` did the work. An
739
+ * explicit login must own its own scope, so a refused identity is refused LOUDLY on each call
740
+ * instead of silently replaced. The family (what un-logged-in sessions inherit) is claimed
741
+ * below, only once a session actually starts.
742
+ */
743
+ const { deriveAgentScope } = await import('../agent-scope.js');
744
+ const { bindAgentScope, agentForScope, loginSessionOutcome } = await import('../agent-state.js');
745
+ const scope = deriveAgentScope();
746
+ bindAgentScope(name, scope, { family: false });
747
+ // Sync to Electron store if present
748
+ try {
749
+ const p = join(homedir(), 'Library', 'Application Support', 'clearly-desktop', 'config.json');
750
+ if (existsSync(p)) {
751
+ const c = JSON.parse(readFileSync(p, 'utf8'));
752
+ if (chosenSkin && c[`avatarSkin_${record.agentId}`] !== chosenSkin) {
753
+ c[`avatarSkin_${record.agentId}`] = chosenSkin;
754
+ writeFileSync(p, JSON.stringify(c, null, 2), 'utf8');
755
+ }
756
+ }
757
+ }
758
+ catch { }
759
+ // Materialize the injected context so the agent's Claude Code session can
760
+ // read (and `cat`) its personal notes/memories from login. The bundle rode
761
+ // in on the cloud mint response; the agent can also pull live via the
762
+ // clearly_memory_recall / clearly_memory_search MCP tools.
763
+ let injectedCount = 0;
764
+ let contextPath = '';
765
+ try {
766
+ const mem = minted.memory;
767
+ if (mem?.markdown) {
768
+ contextPath = join(homedir(), '.beehaven', 'agents', `${name}.context.md`);
769
+ mkdirSync(dirname(contextPath), { recursive: true });
770
+ writeFileSync(contextPath, mem.markdown, { mode: 0o600 });
771
+ injectedCount = mem.count ?? 0;
772
+ }
773
+ }
774
+ catch {
775
+ /* context file is best-effort — memory is still live via MCP */
776
+ }
777
+ const voiceLabel = record.voice
778
+ ? `, voice: ${record.voice.provider}${record.voice.voiceId ? `:${record.voice.voiceId}` : ''}`
779
+ : '';
780
+ const skinLabel = record.avatarSkin ? `, skin: ${record.avatarSkin}` : '';
781
+ console.log('');
782
+ console.log(` ${green('✓')} Signed in as agent ${cyan(name)} (avatar: ${record.avatarId}${skinLabel}, kind: ${record.kind}${voiceLabel})`);
783
+ if (minted?.soul?.enabled) {
784
+ const appearance = String(minted.soul.appearance ?? '').trim();
785
+ console.log(` ${green('✦')} Avatar Soul ready — baseline minted${appearance ? `. ${appearance}` : '.'}`);
786
+ console.log(dim(' Soul: agent-soul-get · agent-soul-avatar-update · agent-soul-draft-get/save · agent-doodle-space-get/draw'));
787
+ console.log(dim(' Home: agent-home-get · agent-home-layout · agent-home-memory/journal · agent-home-ad · agent-home-avatar-location (24px grid)'));
788
+ }
789
+ if (sessionOpen)
790
+ console.log(` ${green('◆')} Live agent presence connected.`);
791
+ if (contextPath) {
792
+ const what = injectedCount > 0
793
+ ? `operating guide + ${injectedCount} ${injectedCount === 1 ? 'memory' : 'memories'}`
794
+ : 'operating guide';
795
+ console.log(` ${green('✎')} Context injected (${what}) → ${cyan(`~/.beehaven/agents/${name}.context.md`)}`);
796
+ console.log(dim(` Read it at the start of every session — it explains the full platform + loads ${name}'s memory.`));
797
+ }
798
+ // ── THE SESSION, AND THE DIGEST ────────────────────────────────────────────────
799
+ //
800
+ // Logging in is exactly the moment an agent needs to know what it already did here, and
801
+ // exactly the moment it has the least context to think of asking. So the session start
802
+ // rides on login and PRINTS its brief — continuity you have to remember to request is
803
+ // continuity most agents will not have.
804
+ //
805
+ // Best-effort for an UNREACHABLE workspace: an identity that mints but cannot reach it is still
806
+ // a usable identity. ⚠ A workspace that ANSWERS and refuses is a different state — see
807
+ // `loginSessionOutcome` — and is reported, never swallowed.
808
+ let started;
809
+ let thrown;
810
+ try {
811
+ started = await rpcOnce('agent-session-start', {
812
+ agentId: record.agentId,
813
+ client: String(values.client ?? 'cli').trim() || 'cli',
814
+ label: String(values.label ?? '').trim() || undefined,
815
+ expiresAt: record.expiresAt,
816
+ }, 15_000);
817
+ }
818
+ catch (err) {
819
+ thrown = err ?? new Error('agent-session-start failed');
820
+ }
821
+ const outcome = loginSessionOutcome(started, thrown);
822
+ if (outcome.kind === 'started') {
823
+ saveAgentSession(name, outcome.sessionId);
824
+ /**
825
+ * ⚠⚠ CLAIM THE FAMILY — the step that makes the export optional.
826
+ *
827
+ * This function used to end by PRINTING `export BEEHAVEN_AGENT=<name>` and hoping.
828
+ * An agent that logged in and did not export was fully authenticated and still
829
+ * calling as nobody: identity minted, workspace session open, and every subsequent
830
+ * `beehaven call` attributed to the user.
831
+ *
832
+ * The exact scope is already bound above; this adds the tool family, so a later session of
833
+ * the same tool that has not logged in inherits an identity the workspace actually admits.
834
+ */
835
+ try {
836
+ bindAgentScope(name, deriveAgentScope());
837
+ }
838
+ catch { /* binding is a convenience — never fail a good login over it */ }
839
+ console.log('');
840
+ console.log(` ${green('◷')} Workspace work session ${cyan(outcome.sessionId.slice(0, 8))} started.`);
841
+ printBrief(outcome.brief);
842
+ }
843
+ else if (outcome.kind === 'refused') {
844
+ console.log('');
845
+ console.error(` ${yellow('⚠')} Signed in, but this workspace refused ${cyan(name)}: ${outcome.reason}`);
846
+ if (/revoked|not active|unknown agent|not admitted/i.test(`${outcome.scope} ${outcome.reason}`)) {
847
+ console.error(dim(` The agent is archived or not admitted here. Restore it in Settings → Agent access,`));
848
+ console.error(dim(` or log in as an active agent (beehaven call agent-list '{}' shows the roster).`));
849
+ }
850
+ console.error(dim(` Calls from this session run as ${name} and will be refused — they will NOT fall back to another agent.`));
851
+ process.exitCode = 1;
852
+ }
853
+ console.log('');
854
+ {
855
+ // ⚠ Say whether the binding took — CHECKED, not assumed. This line used to print
856
+ // unconditionally, including after the binding had been skipped, which is how a session
857
+ // "bound to claude" spent an afternoon writing as `jinri-claude`.
858
+ if (scope) {
859
+ const resolved = agentForScope(scope);
860
+ if (resolved === name) {
861
+ console.log(` ${green('✓')} Bound to this session (${cyan(scope)}) — every call here now runs as ${cyan(name)}.`);
862
+ console.log(dim(' Other terminals: export BEEHAVEN_AGENT=' + name));
863
+ }
864
+ else {
865
+ console.error(` ${yellow('⚠')} Could not bind this session (${scope}) to ${name}; calls resolve to ${resolved ?? 'nobody'}.`);
866
+ console.error(dim(` Pin it explicitly: export BEEHAVEN_AGENT=${name}`));
867
+ process.exitCode = 1;
868
+ }
869
+ }
870
+ else {
871
+ console.log(' Activate in THIS terminal:');
872
+ console.log(` ${cyan(`eval "$(beehaven agent use ${name})"`)}`);
873
+ console.log(dim(` or: export BEEHAVEN_AGENT=${name}`));
874
+ }
875
+ }
876
+ console.log('');
877
+ }
878
+ /**
879
+ * The login digest.
880
+ *
881
+ * Everything here answers a question a returning colleague asks before touching anything, and
882
+ * NOTHING here is printed when it is empty — a brief padded with "0 sessions · 0 memories ·
883
+ * nothing assigned" is a wall an agent learns to skip, which costs the one thing the digest
884
+ * has to buy: being read.
885
+ */
886
+ function printBrief(brief) {
887
+ if (!brief)
888
+ return;
889
+ const sessions = brief.sessions?.recent ?? [];
890
+ const assigned = brief.assignedToMe ?? [];
891
+ const pending = brief.awaitingApproval ?? [];
892
+ const memories = brief.memories ?? [];
893
+ const focus = brief.focus ?? [];
894
+ /**
895
+ * ⚠ SS-41 — ADDRESSED TO YOU, AND PRINTED FIRST. Voicemail and @mentions are somebody speaking
896
+ * TO this agent; everything else in the brief is ambient. Both stay until acknowledged.
897
+ */
898
+ const mentions = brief.workspace?.mentions ?? [];
899
+ const voicemail = brief.workspace?.voicemail ?? [];
900
+ if (mentions.length || voicemail.length) {
901
+ console.log('');
902
+ console.log(` ${bold('Addressed to you')} ${dim(`(${mentions.length + voicemail.length})`)}`);
903
+ for (const v of voicemail.slice(0, 3)) {
904
+ console.log(` ${dim('·')} ${cyan('voicemail')} from ${v.from}: ${String(v.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`);
905
+ }
906
+ for (const m of mentions.slice(0, 5)) {
907
+ const on = m.on ?? {};
908
+ console.log(` ${dim('·')} ${cyan('@mention')} from ${m.from} on ${on.kind || 'item'} “${on.title || on.id || ''}” ${dim(String(m.id).slice(0, 8))}`);
909
+ console.log(` ${String(m.text ?? '').replace(/\s+/g, ' ').slice(0, 120)}`);
910
+ }
911
+ if (mentions.length)
912
+ console.log(dim(` Act, reply with \`beehaven comment <ref> "…"\`, then \`beehaven mention ack <id>\`.`));
913
+ }
914
+ if (!sessions.length && !assigned.length && !pending.length && !memories.length && !focus.length) {
915
+ if (!mentions.length && !voicemail.length)
916
+ console.log(dim(' First visit — nothing to catch up on.'));
917
+ return;
918
+ }
919
+ const ago = (ts) => {
920
+ const d = Date.now() - ts;
921
+ if (d < 60_000)
922
+ return 'just now';
923
+ if (d < 3_600_000)
924
+ return `${Math.round(d / 60_000)}m ago`;
925
+ if (d < 86_400_000)
926
+ return `${Math.round(d / 3_600_000)}h ago`;
927
+ return `${Math.round(d / 86_400_000)}d ago`;
928
+ };
929
+ if (sessions.length) {
930
+ console.log('');
931
+ console.log(` ${bold('Last time you were here')}`);
932
+ for (const s of sessions.slice(0, 3)) {
933
+ const when = s.startedAt ? ago(s.startedAt) : '';
934
+ const counts = `${s.actions} action${s.actions === 1 ? '' : 's'}${s.errors ? `, ${s.errors} error${s.errors === 1 ? '' : 's'}` : ''}`;
935
+ console.log(` ${dim('·')} ${s.label || '(unlabelled)'} ${dim(`— ${when}, ${counts}`)}`);
936
+ // The summary is the payload. It is the one thing carrying reasoning rather than counts.
937
+ if (s.summary)
938
+ console.log(` ${s.summary}`);
939
+ }
940
+ }
941
+ if (focus.length) {
942
+ console.log('');
943
+ console.log(` ${bold('You were working on')}`);
944
+ for (const f of focus.slice(0, 3)) {
945
+ console.log(` ${dim('·')} ${f.title || f.id} ${dim(`(${f.kind}, ${f.selected.length} selected)`)}`);
946
+ }
947
+ }
948
+ if (assigned.length) {
949
+ console.log('');
950
+ console.log(` ${bold('Assigned to you')} ${dim(`(${assigned.length})`)}`);
951
+ for (const t of assigned.slice(0, 5)) {
952
+ console.log(` ${dim('·')} ${t.key ? cyan(t.key) + ' ' : ''}${t.title} ${dim(`[${t.status}]`)}`);
953
+ }
954
+ }
955
+ if (pending.length) {
956
+ console.log('');
957
+ console.log(` ${bold('Waiting on a human')} ${dim(`(${pending.length})`)}`);
958
+ for (const c of pending.slice(0, 4)) {
959
+ console.log(` ${dim('·')} ${c.title || c.id} ${dim(c.kind ? `(${c.kind})` : '')}`);
960
+ }
961
+ }
962
+ if (memories.length) {
963
+ console.log('');
964
+ console.log(` ${bold('You noted')}`);
965
+ for (const m of memories.slice(0, 4)) {
966
+ console.log(` ${dim('·')} ${cyan(m.key)}: ${m.value.slice(0, 90)}${m.value.length > 90 ? '…' : ''}`);
967
+ }
968
+ }
969
+ }
970
+ // ── use (prints the export line for eval) ────────────
971
+ async function agentUse(args) {
972
+ const name = args[0];
973
+ if (!name) {
974
+ // Usage goes to STDERR so a stray `eval "$(beehaven agent use)"` doesn't eval junk.
975
+ console.error(' Usage: eval "$(beehaven agent use <name>)"');
976
+ process.exit(1);
977
+ }
978
+ const record = loadAgent(name);
979
+ if (!record) {
980
+ console.error(` No such agent "${name}". Create it: beehaven agent login ${name}`);
981
+ process.exit(1);
982
+ }
983
+ // The ONLY thing on STDOUT is the eval-able export line.
984
+ process.stdout.write(`export BEEHAVEN_AGENT=${name}\n`);
985
+ // Everything human goes to STDERR so `eval "$(...)"` captures just the export.
986
+ console.error(dim(`# agent ${name} active (avatar ${record.avatarId})`));
987
+ }
988
+ // ── list ─────────────────────────────────────────────
989
+ async function agentList(values) {
990
+ const agents = listAgents();
991
+ const active = activeAgentName(values);
992
+ const localNames = new Set(agents.map((a) => a.name));
993
+ const localIds = new Set(agents.map((a) => a.record.agentId));
994
+ // The WORKSPACE is the source of truth — pull its agents so the list reflects
995
+ // identities that exist there but aren't signed in on THIS device.
996
+ let workspace = [];
997
+ try {
998
+ const r = await rpcOnce('agent-list', {}, 8_000);
999
+ const raw = r?.agents ?? r?.result?.agents ?? [];
1000
+ workspace = raw
1001
+ .map((x) => ({
1002
+ id: String(x.id ?? x.agentId ?? ''),
1003
+ name: String(x.name ?? x.id ?? ''),
1004
+ }))
1005
+ .filter((x) => x.id && x.id !== 'omni');
1006
+ }
1007
+ catch {
1008
+ /* daemon down / offline — show local only */
1009
+ }
1010
+ if (!agents.length && !workspace.length) {
1011
+ console.log(' No agent identities yet.');
1012
+ console.log(dim(' Create one: beehaven agent login <name>'));
1013
+ return;
1014
+ }
1015
+ console.log('');
1016
+ console.log(' Agent identities');
1017
+ console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
1018
+ if (agents.length) {
1019
+ console.log(dim(' Signed in on this device:'));
1020
+ for (const { name, record } of agents) {
1021
+ const marker = name === active ? green(' ●') : ' ';
1022
+ const ttl = formatRemaining(record.expiresAt);
1023
+ console.log(`${marker} ${cyan(name.padEnd(16))} avatar:${record.avatarId.padEnd(14)} ${dim(record.kind.padEnd(13))} ${ttl}`);
1024
+ }
1025
+ }
1026
+ const remoteOnly = workspace.filter((w) => !localIds.has(w.id) && !localNames.has(w.name));
1027
+ if (remoteOnly.length) {
1028
+ console.log('');
1029
+ console.log(dim(' In the workspace — not signed in here (`agent login <name>` to use):'));
1030
+ for (const w of remoteOnly)
1031
+ console.log(` ${cyan(w.name.padEnd(20))} ${dim(w.id)}`);
1032
+ }
1033
+ console.log('');
1034
+ console.log(dim(` active = $BEEHAVEN_AGENT${active ? ` (${active})` : ' (none)'} · workspace = source of truth`));
1035
+ console.log('');
1036
+ }
1037
+ // ── whoami ───────────────────────────────────────────
1038
+ function agentWhoami(values) {
1039
+ const active = resolveActiveAgent(values);
1040
+ if (!active) {
1041
+ console.log(' No active agent.');
1042
+ console.log(dim(' Activate one: eval "$(beehaven agent use <name>)" · or pass --agent <name>'));
1043
+ return;
1044
+ }
1045
+ const r = active.record;
1046
+ console.log('');
1047
+ console.log(` Agent ${cyan(active.name)}${active.expired ? ' ' + '\x1b[31m(expired)\x1b[0m' : ''}`);
1048
+ console.log(` agentId: ${r.agentId}`);
1049
+ console.log(` kind: ${r.kind}`);
1050
+ console.log(` avatar: ${r.avatarId}`);
1051
+ console.log(` displayName: ${r.displayName}`);
1052
+ console.log(` owner: ${r.userId}`);
1053
+ // ⚠ "unknown" for pre-birthday identities — see the field's note. Showing today's
1054
+ // date would date every legacy agent to whenever someone first ran whoami.
1055
+ console.log(` birthday: ${r.birthday ? formatBirthday(r.birthday) : 'unknown (created before birthdays)'}`);
1056
+ console.log(` voice: ${r.voice?.provider ? (r.voice.voiceId ? `${r.voice.provider}:${r.voice.voiceId}` : `${r.voice.provider} (base voice)`) : 'unbound — speak picks whatever is reachable'}`);
1057
+ console.log(` expires: ${formatRemaining(r.expiresAt)}`);
1058
+ if (active.expired)
1059
+ console.log(dim(` refresh: beehaven agent login ${active.name}`));
1060
+ console.log('');
1061
+ }
1062
+ async function agentStatus(args, values) {
1063
+ const active = resolveActiveAgent(values);
1064
+ const name = args[0] ?? active?.name;
1065
+ const record = name ? loadAgent(name) : null;
1066
+ const message = String(values.message ?? args.slice(name ? 1 : 0).join(' ') ?? '').trim();
1067
+ if (!record || !name) {
1068
+ console.error(' No active agent. Usage: beehaven agent status [<name>] --message "…"');
1069
+ process.exit(1);
1070
+ }
1071
+ if (!message) {
1072
+ console.error(' --message is required. Pass an empty message only through `beehaven call agent-status-set`.');
1073
+ process.exit(1);
1074
+ }
1075
+ const raw = await rpcOnce('agent-status-set', { agentId: record.agentId, message }, 8_000);
1076
+ const result = raw?.result ?? raw;
1077
+ if (result?.ok === false || result?.error) {
1078
+ console.error(` ${result.error || 'Could not update agent status.'}`);
1079
+ process.exit(1);
1080
+ }
1081
+ console.log(` ${green('✓')} ${cyan(name)} status: ${String(result.message ?? message)}`);
1082
+ }
1083
+ // ── logout ───────────────────────────────────────────
1084
+ async function agentLogout(args, values) {
1085
+ const name = args[0] ?? activeAgentName(values);
1086
+ if (!name) {
1087
+ console.error(' Specify an agent name, or set $BEEHAVEN_AGENT.');
1088
+ console.error(' Usage: beehaven agent logout [<name>]');
1089
+ process.exit(1);
1090
+ }
1091
+ const record = loadAgent(name);
1092
+ if (!record) {
1093
+ console.log(` No such agent "${name}" on this device.`);
1094
+ return;
1095
+ }
1096
+ // End the live workspace session (best-effort). The identity is NOT deleted —
1097
+ // it stays in the workspace (source of truth) AND on this device, so you can
1098
+ // log back in. To remove the current workspace projection use `agent delete <name>`;
1099
+ // to remove the portable account identity use `agent delete <name> --account --yes`.
1100
+ await endWorkspaceSession(record, 'Agent explicitly logged out.');
1101
+ try {
1102
+ await rpcOnce('agent:disconnect', { agentId: record.agentId ?? name }, 5_000);
1103
+ }
1104
+ catch {
1105
+ /* daemon down — nothing to close */
1106
+ }
1107
+ clearAgentSession(name);
1108
+ console.log(` ${green('✓')} Signed out agent "${name}" — session ended, identity kept.`);
1109
+ console.log(dim(` Log back in: beehaven agent login ${name} · workspace remove: beehaven agent delete ${name}`));
1110
+ if (activeAgentName(values) === name && !values.agent) {
1111
+ console.log(dim(` This was your active agent — run: unset BEEHAVEN_AGENT`));
1112
+ }
1113
+ }
1114
+ // ── delete (destructive — removes the identity from the workspace + device) ──
1115
+ async function agentDelete(args, values) {
1116
+ const name = args[0] ?? activeAgentName(values);
1117
+ if (!name) {
1118
+ console.error(' Specify an agent name to delete.');
1119
+ console.error(' Usage: beehaven agent delete <name>');
1120
+ process.exit(1);
1121
+ }
1122
+ if (values.account === true)
1123
+ return agentAccountDelete(args, values);
1124
+ const record = loadAgent(name);
1125
+ await endWorkspaceSession(record, 'Agent identity deleted.');
1126
+ // 1. Remove from the WORKSPACE (source of truth) — the `agent-delete` RPC
1127
+ // soft-deletes (archived). Resolve by the record's agentId, else the name.
1128
+ let cloud = false;
1129
+ try {
1130
+ const r = await rpcOnce('agent-delete', { id: record?.agentId ?? name }, 10_000);
1131
+ cloud = !!(r?.ok ?? r?.result?.ok ?? (!r?.error && !r?.result?.error));
1132
+ }
1133
+ catch {
1134
+ /* daemon down / not a workspace agent — local removal still proceeds */
1135
+ }
1136
+ // 2. Close any live session, then remove the LOCAL credential + context file.
1137
+ try {
1138
+ await rpcOnce('agent:disconnect', { agentId: record?.agentId ?? name }, 5_000);
1139
+ }
1140
+ catch {
1141
+ /* ignore */
1142
+ }
1143
+ const removedLocal = deleteAgent(name);
1144
+ if (!cloud && !removedLocal) {
1145
+ console.log(` No such agent "${name}".`);
1146
+ return;
1147
+ }
1148
+ const parts = [cloud ? 'workspace' : '', removedLocal ? 'this device' : '']
1149
+ .filter(Boolean)
1150
+ .join(' + ');
1151
+ console.log(` ${green('✓')} Deleted agent "${name}" from ${parts}.`);
1152
+ if (record)
1153
+ console.log(dim(` Avatar ${record.avatarId} released.`));
1154
+ }
1155
+ // ── account delete (destructive — removes the portable identity) ─────────
1156
+ async function agentAccountDelete(args, values) {
1157
+ const name = args[0] ?? activeAgentName(values);
1158
+ if (!name) {
1159
+ console.error(' Specify an agent name to delete from the account.');
1160
+ console.error(' Usage: beehaven agent delete <name> --account --yes');
1161
+ process.exit(1);
1162
+ }
1163
+ if (values.yes !== true) {
1164
+ console.error(` Account deletion permanently removes the portable identity "${name}" from the account.`);
1165
+ console.error(' It also releases the account avatar and cannot be undone.');
1166
+ console.error(' Re-run with: beehaven agent delete ' + name + ' --account --yes');
1167
+ process.exit(1);
1168
+ }
1169
+ const record = loadAgent(name);
1170
+ const agentId = record?.agentId ?? name;
1171
+ let raw;
1172
+ try {
1173
+ raw = await rpcOnce('agent-account-delete', { agentId }, 15_000);
1174
+ }
1175
+ catch (err) {
1176
+ console.error(` Could not delete account agent "${name}": ${err?.message ?? err}`);
1177
+ process.exit(1);
1178
+ }
1179
+ const result = actionPayload(raw);
1180
+ if (result?.ok === false || result?.error) {
1181
+ console.error(` ${result.error || `Could not delete account agent "${name}".`}`);
1182
+ process.exit(1);
1183
+ }
1184
+ await endWorkspaceSession(record, 'Account agent identity deleted.');
1185
+ try {
1186
+ await rpcOnce('agent:disconnect', { agentId }, 5_000);
1187
+ }
1188
+ catch {
1189
+ /* ignore */
1190
+ }
1191
+ const removedLocal = deleteAgent(name);
1192
+ const parts = ['account', removedLocal ? 'this device' : ''].filter(Boolean).join(' + ');
1193
+ console.log(` ${green('✓')} Deleted agent "${name}" from ${parts}.`);
1194
+ console.log(dim(' This is account-wide. Use `agent delete <name>` without --account for workspace-only removal.'));
1195
+ }
1196
+ // ── rebind ───────────────────────────────────────────
1197
+ async function agentRebind(args, values) {
1198
+ const name = args[0];
1199
+ const newAvatar = values.avatar ? String(values.avatar).trim() : undefined;
1200
+ if (!name || !newAvatar) {
1201
+ console.error(' Usage: beehaven agent rebind <name> --avatar <id>');
1202
+ process.exit(1);
1203
+ }
1204
+ const record = loadAgent(name);
1205
+ if (!record) {
1206
+ console.error(` No such agent "${name}". Create it: beehaven agent login ${name}`);
1207
+ process.exit(1);
1208
+ }
1209
+ if (newAvatar === record.avatarId) {
1210
+ console.log(` Agent "${name}" already drives avatar "${newAvatar}".`);
1211
+ return;
1212
+ }
1213
+ const collision = findAgentByAvatar(newAvatar, name);
1214
+ if (collision) {
1215
+ console.error(` Avatar "${newAvatar}" is already claimed by agent "${collision}".`);
1216
+ process.exit(1);
1217
+ }
1218
+ // Refresh the portable workspace token and the device-local app-rpc token separately. The
1219
+ // daemon cannot verify the worker-signed token, and the worker cannot verify the device token.
1220
+ let cloudMinted;
1221
+ try {
1222
+ cloudMinted = await rpcOnce('agent-account-mint', {
1223
+ agentId: record.agentId,
1224
+ displayName: record.displayName,
1225
+ avatarId: newAvatar,
1226
+ }, 15_000);
1227
+ }
1228
+ catch (err) {
1229
+ console.error(` Could not refresh workspace identity: ${err.message}`);
1230
+ process.exit(1);
1231
+ }
1232
+ if (!cloudMinted?.token) {
1233
+ console.error(` Workspace refresh failed: ${cloudMinted?.error ?? 'no token returned'}`);
1234
+ process.exit(1);
1235
+ }
1236
+ let localToken = record.localToken;
1237
+ try {
1238
+ const local = await rpcOnce('agent:mint', {
1239
+ agentId: record.agentId,
1240
+ kind: record.kind,
1241
+ displayName: record.displayName,
1242
+ avatarId: newAvatar,
1243
+ permissions: ['avatar:say', 'avatar:speak', 'avatar:emote'],
1244
+ ...(record.voice ? { meta: { voice: record.voice } } : {}),
1245
+ }, 15_000);
1246
+ localToken = local?.token ?? local?.result?.token ?? localToken;
1247
+ }
1248
+ catch (err) {
1249
+ if (record.localToken) {
1250
+ console.error(dim(` ⚠ local avatar rebind unavailable: ${err?.message ?? err}`));
1251
+ process.exit(1);
1252
+ }
1253
+ }
1254
+ const updated = {
1255
+ ...record,
1256
+ avatarId: cloudMinted.avatarId ?? newAvatar,
1257
+ avatarSkin: newAvatar,
1258
+ token: cloudMinted.token,
1259
+ ...(localToken ? { localToken } : {}),
1260
+ issuedAt: cloudMinted.issuedAt ?? record.issuedAt,
1261
+ expiresAt: cloudMinted.expiresAt ?? record.expiresAt,
1262
+ };
1263
+ saveAgent(name, updated);
1264
+ try {
1265
+ const p = join(homedir(), 'Library', 'Application Support', 'clearly-desktop', 'config.json');
1266
+ if (existsSync(p)) {
1267
+ const c = JSON.parse(readFileSync(p, 'utf8'));
1268
+ c[`avatarSkin_${updated.agentId}`] = newAvatar;
1269
+ writeFileSync(p, JSON.stringify(c, null, 2), 'utf8');
1270
+ }
1271
+ }
1272
+ catch { }
1273
+ console.log(` ${green('✓')} Rebound agent "${name}" → avatar ${cyan(updated.avatarId)} (was ${record.avatarId}).`);
1274
+ }
1275
+ // ── helpers ──────────────────────────────────────────
1276
+ /**
1277
+ * "2026-08-24 (3 days old)" — the date plus how long ago, because the age is the
1278
+ * part anyone actually wants and subtracting dates in your head is friction.
1279
+ *
1280
+ * ⚠ Returns the raw string for anything unparseable rather than throwing or
1281
+ * printing "Invalid Date": a corrupt field should not take `whoami` down with it.
1282
+ */
1283
+ function formatBirthday(iso) {
1284
+ const t = Date.parse(iso);
1285
+ if (!Number.isFinite(t))
1286
+ return iso;
1287
+ const days = Math.floor((Date.now() - t) / 86_400_000);
1288
+ const date = new Date(t).toISOString().slice(0, 10);
1289
+ if (days <= 0)
1290
+ return `${date} (today)`;
1291
+ if (days === 1)
1292
+ return `${date} (1 day old)`;
1293
+ if (days < 60)
1294
+ return `${date} (${days} days old)`;
1295
+ const months = Math.floor(days / 30);
1296
+ return `${date} (${months} month${months === 1 ? '' : 's'} old)`;
1297
+ }
1298
+ function formatRemaining(expiresAt) {
1299
+ const ms = expiresAt - Date.now();
1300
+ if (ms <= 0)
1301
+ return '\x1b[31mexpired\x1b[0m';
1302
+ const days = Math.floor(ms / 86_400_000);
1303
+ if (days >= 1)
1304
+ return dim(`${days}d left`);
1305
+ const hours = Math.floor(ms / 3_600_000);
1306
+ if (hours >= 1)
1307
+ return dim(`${hours}h left`);
1308
+ const mins = Math.floor(ms / 60_000);
1309
+ return dim(`${mins}m left`);
1310
+ }