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,529 @@
1
+ /**
2
+ * `beehaven omni` — DAEMON OMNI: Claude Code hosted in the daemon, with the
3
+ * beehaven surface as first-party tools.
4
+ *
5
+ * ## How this differs from `beehaven claude`
6
+ *
7
+ * `beehaven claude` runs the Agent SDK in the CLI's OWN process — one Claude
8
+ * per terminal invocation, dying with the command, with no beehaven tools and
9
+ * no way for anything else to see it. That is a fine shape for a one-shot task
10
+ * and a bad one for a companion.
11
+ *
12
+ * This drives the session living in the DAEMON. It is the same runtime the
13
+ * avatar's chat panel talks to, so a turn started here is visible there, and
14
+ * both share one warm session, one conversation, one set of tools. The daemon
15
+ * is also the only process holding the relay socket and the local buildings —
16
+ * which is what lets `mcp__clearly__*` be a real in-process tool call instead
17
+ * of a shell-out.
18
+ *
19
+ * ## Local only, and that is deliberate
20
+ *
21
+ * Everything here rides the IPC socket, which is chmod 0600 — the kernel
22
+ * proving the caller is this UNIX user, driving their own Claude subscription.
23
+ * Cloud→daemon Claude orchestration stays disabled (see relay.ts, Anthropic
24
+ * credential-use policy). Nothing in this file opens that path.
25
+ *
26
+ * beehaven omni "add a red rectangle to my canvas"
27
+ * beehaven omni --session build "run the tests" # a named thread
28
+ * beehaven omni sessions # what's live
29
+ * beehaven omni end [--session id]
30
+ */
31
+ import { IpcClient } from '../ipc-client.js';
32
+ import { color } from '../spinner.js';
33
+ const DEFAULT_SESSION = 'default';
34
+ export async function cmdOmni(positionals, values) {
35
+ // `positionals[0]` is the verb itself ('omni') — the CLI passes the full
36
+ // argv slice, matching `commands/claude.ts`. Reading index 0 as the
37
+ // subcommand makes `beehaven omni sessions` prompt Claude with the literal
38
+ // words "omni sessions", which it will then dutifully go and research.
39
+ const sub = positionals[1];
40
+ const sessionId = String(values.session ?? DEFAULT_SESSION);
41
+ const client = new IpcClient();
42
+ try {
43
+ await client.connect();
44
+ }
45
+ catch {
46
+ console.error(color.red(' ✗ Cannot reach the daemon.'));
47
+ console.error(color.dim(' Start it with `beehaven start --headless`, or open the Mac app.'));
48
+ process.exitCode = 1;
49
+ return;
50
+ }
51
+ try {
52
+ if (sub === 'sessions') {
53
+ const res = await client.omni('sessions');
54
+ const list = res?.sessions ?? [];
55
+ console.log(` beehaven tools: ${res?.beehavenTools ? color.green('mounted') : color.yellow('not mounted')}`);
56
+ if (!list.length) {
57
+ console.log(color.dim(' No live sessions.'));
58
+ return;
59
+ }
60
+ for (const s of list) {
61
+ console.log(` ${color.cyan(s.sessionId)} turns=${s.turns ?? 0} cwd=${color.dim(s.cwd ?? '')}`);
62
+ }
63
+ return;
64
+ }
65
+ /**
66
+ * ⚠⚠ WHERE IS IT WORKING, AND MOVE IT. The cwd decides which files the agent reads and writes
67
+ * and which SDK conversation resumes — and until this there was no way to see it or change it,
68
+ * so a session started in the wrong directory stayed there for its whole life.
69
+ *
70
+ * beehaven omni cwd → print it
71
+ * beehaven omni cwd ~/projects/foo → move it
72
+ */
73
+ /**
74
+ * ⚠⚠ COMPANY MODE — READ ITS STATE, AND SWITCH IT.
75
+ *
76
+ * `companyDiagnostics` was written to catch the two failures that produce no error and no
77
+ * output: the mode silently off because the env var never reached the DAEMON, and the daemon
78
+ * drifted onto the wrong workspace because `beehaven connect` is machine-global. Both had
79
+ * happened. Neither could be seen, because the diagnostic had no caller at all — the daemon
80
+ * handled `omni:company-status`, `ipc-server` routed it, and the documented invocation
81
+ * (`beehaven call --ipc …`) is not a real flag. This is that caller.
82
+ *
83
+ * beehaven omni company → the full report
84
+ * beehaven omni company on [--workspace <id>] → persisted, survives a daemon restart
85
+ * beehaven omni company off
86
+ */
87
+ if (sub === 'company') {
88
+ const verb = String(positionals[2] ?? '').trim().toLowerCase();
89
+ if (verb === 'on' || verb === 'off') {
90
+ /**
91
+ * ⚠ WRITTEN BY THE CLI, READ BY THE DAEMON — one file, two processes, and no restart. That
92
+ * is the whole point: an env var could only be changed by respawning the daemon, which is
93
+ * why the mode had never actually been on.
94
+ */
95
+ const { writeCompanyMode, COMPANY_MODE_FILE } = await import('../daemon-omni-company.js');
96
+ const wsId = String(values.workspace ?? '').trim();
97
+ const rec = writeCompanyMode({ enabled: verb === 'on', ...(wsId ? { workspaceId: wsId } : {}) });
98
+ console.log(` ${color.green('✓')} company mode ${rec.enabled ? color.green('ON') : color.yellow('OFF')}`);
99
+ if (rec.workspaceId)
100
+ console.log(color.dim(` workspace ${rec.workspaceId}`));
101
+ console.log(color.dim(` ${COMPANY_MODE_FILE}`));
102
+ /**
103
+ * ⚠ THE DAEMON READS THIS PER TURN, so it takes effect on the next one — no restart. Said
104
+ * out loud because every other switch in this CLI has needed one, and silently working is
105
+ * indistinguishable from silently not.
106
+ */
107
+ console.log(color.dim(' Takes effect on the next turn. `beehaven omni company` to confirm.'));
108
+ return;
109
+ }
110
+ if (verb && verb !== 'status') {
111
+ console.error(color.red(` ✗ unknown: beehaven omni company ${verb}`));
112
+ console.error(color.dim(' Try: beehaven omni company [status|on|off]'));
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ const d = await client.omni('company-status');
117
+ const on = d?.mode === 'on';
118
+ console.log(` Company mode: ${on ? color.green('ON') : color.yellow('OFF')}${d?.source ? color.dim(` (${d.source})`) : ''}`);
119
+ const w = d?.workspace ?? {};
120
+ // ⚠ THREE STATES, NOT TWO. `matches === null` means "could not be checked" and must not be
121
+ // drawn as a mismatch — reporting a fault it cannot actually see is the worse failure.
122
+ const mark = w.matches === true ? color.green('✓') : w.matches === false ? color.red('✗') : color.yellow('?');
123
+ console.log(` Workspace: ${mark} expected ${color.cyan(w.expected ?? '?')}, connected ${w.connected ? color.cyan(w.connected) : color.dim('(unknown)')}`);
124
+ console.log(` Identity: ${d?.identityRegistered ? color.green('registered') : color.red('MISSING')}`);
125
+ console.log(` Claude: ${d?.claudeBinary ? color.dim(d.claudeBinary) : color.red('not found')}`);
126
+ const p = d?.prompt ?? {};
127
+ console.log(color.dim(` Prompt: ${p.totalBytes ?? 0} B (identity ${p.identityBytes ?? 0} + addon ${p.addonBytes ?? 0}) ≈ ${p.approxTokens ?? 0} tok/turn`));
128
+ /**
129
+ * ⚠⚠ THE ADDON NAMES VERBS, AND A NAME IS NOT A VERB. This check exists because the addon
130
+ * shipped telling the agent to call `ticket-link` — which does not exist; the action is
131
+ * `ticket-link-create`. Nothing could have caught that: the prompt is a string, so it
132
+ * type-checks, reads correctly, and fails only when a model is mid-sprint and gets
133
+ * "Unknown action" back. `describe-action` refuses an unregistered name, so asking it once
134
+ * per verb is a real existence check.
135
+ *
136
+ * ⚠ IT NEEDS A CONNECTED WORKSPACE, and says so rather than reporting a false absence — a
137
+ * diagnostic that prints "12 verbs missing" because the daemon is pointed at nothing is
138
+ * worse than one that prints nothing.
139
+ */
140
+ const verbs = Array.isArray(d?.requiredVerbs) ? d.requiredVerbs : [];
141
+ if (verbs.length) {
142
+ const checks = await Promise.all(verbs.map(async (v) => {
143
+ try {
144
+ const r = await client.call('describe-action', { name: v }, 15_000);
145
+ // ⚠ An `unknown_action` code is the refusal; anything else that answers is real.
146
+ return { v, ok: !(r?.code === 'unknown_action' || /^Unknown action/i.test(String(r?.error ?? ''))), reached: true };
147
+ }
148
+ catch {
149
+ return { v, ok: false, reached: false };
150
+ }
151
+ }));
152
+ const unreachable = checks.filter((c) => !c.reached).length;
153
+ const missing = checks.filter((c) => c.reached && !c.ok).map((c) => c.v);
154
+ if (unreachable === checks.length) {
155
+ console.log(color.dim(` Verbs: ${verbs.length} named by the addon (not checked — no workspace reachable)`));
156
+ }
157
+ else if (missing.length) {
158
+ console.log(` Verbs: ${color.red(`${missing.length} of ${verbs.length} DO NOT EXIST`)}`);
159
+ for (const m of missing)
160
+ console.log(` ${color.red('✗')} ${m}`);
161
+ console.log(color.dim(' The addon tells the agent to call these. Fix the name in COMPANY_REQUIRED_VERBS and the addon text.'));
162
+ }
163
+ else {
164
+ console.log(` Verbs: ${color.green(`${verbs.length}/${verbs.length}`)} named by the addon, all reachable`);
165
+ }
166
+ }
167
+ for (const warn of d?.warnings ?? [])
168
+ console.log(` ${color.yellow('!')} ${warn}`);
169
+ if (!(d?.warnings ?? []).length)
170
+ console.log(` ${color.green('✓')} nothing to report`);
171
+ return;
172
+ }
173
+ if (sub === 'cwd') {
174
+ const target = positionals.slice(2).join(' ').trim();
175
+ if (!target) {
176
+ const res = await client.omni('sessions');
177
+ const row = (res?.sessions ?? []).find((r) => r.sessionId === sessionId);
178
+ if (!row) {
179
+ console.log(color.dim(` no session "${sessionId}" yet — it is created on the first turn`));
180
+ return;
181
+ }
182
+ console.log(` ${color.cyan(sessionId)} ${row.cwd ?? color.dim('(unknown)')}`);
183
+ return;
184
+ }
185
+ const r = await client.omni('set-cwd', { sessionId, cwd: target });
186
+ if (!r?.ok) {
187
+ console.error(color.red(` ✗ ${r?.error ?? 'could not change the working directory'}`));
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ console.log(` ${color.green('✓')} ${r.cwd}`);
192
+ /**
193
+ * ⚠ SAID OUT LOUD. Claude Code keys a conversation to a project directory derived from the
194
+ * cwd, so moving it starts a new thread. Someone who is not told simply finds an agent with
195
+ * no memory of the last five minutes.
196
+ */
197
+ if (r.threadReset)
198
+ console.log(color.dim(' new conversation — the thread is tied to the directory'));
199
+ else
200
+ console.log(color.dim(' unchanged — the conversation is kept'));
201
+ return;
202
+ }
203
+ if (sub === 'end') {
204
+ await client.omni('end', { sessionId });
205
+ console.log(color.dim(` session ${sessionId} ended`));
206
+ return;
207
+ }
208
+ // ── A turn ────────────────────────────────────────────────────────
209
+ const prompt = positionals.slice(sub === 'run' ? 2 : 1).join(' ').trim();
210
+ if (!prompt) {
211
+ console.error(' Usage: beehaven omni "<task>" · beehaven omni sessions|end');
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ // Subscribe BEFORE prompting. The daemon starts emitting the moment the
216
+ // turn is accepted, and a subscription set up afterwards races the first
217
+ // tokens — you would lose the opening of every reply.
218
+ let printedAny = false;
219
+ let lastLineWasTool = false;
220
+ /**
221
+ * callId → tool name. The daemon deliberately leaves `tool` EMPTY on
222
+ * `agent:tool-result` ("the human-readable name lives on the matching
223
+ * tool-call event"), so a result can only be labelled by correlating on
224
+ * `callId` — which matters now that results render, because calls interleave
225
+ * and an unlabelled ✓ under the wrong step is worse than no label.
226
+ */
227
+ const toolNames = new Map();
228
+ /** The most recent call, so a result printed right under it can stay unlabelled. */
229
+ let lastCallId = '';
230
+ const done = new Promise((resolve) => {
231
+ client.on('broadcast', (msg) => {
232
+ if (!msg?.omni || msg.sessionId !== sessionId)
233
+ return;
234
+ switch (msg.type) {
235
+ /**
236
+ * ⚠ THE REPLY. `reply_to_principal` and the other principal tools —
237
+ * this is what Omni deliberately chose to say.
238
+ *
239
+ * Rendering this was the whole parity gap. Without it a terminal user
240
+ * saw `agent:text-delta` (below) and nothing else, which is exactly
241
+ * backwards: that stream is Omni's PRIVATE working text, which the
242
+ * desktop chat hides on purpose, while the actual replies went
243
+ * nowhere. A CLI user was reading the agent's notes and missing its
244
+ * answers.
245
+ */
246
+ case 'agent:principal': {
247
+ const p = msg.event ?? {};
248
+ if (lastLineWasTool) {
249
+ process.stdout.write('\n');
250
+ lastLineWasTool = false;
251
+ }
252
+ if (p.kind === 'prose') {
253
+ process.stdout.write(`\n${p.text ?? ''}\n`);
254
+ printedAny = true;
255
+ }
256
+ else if (p.kind === 'workspace') {
257
+ // Cards, design docs, memories — real changes, but not the answer.
258
+ console.log(color.dim(` · ${p.text ?? ''}`));
259
+ }
260
+ else if (p.kind === 'preview') {
261
+ // No window to open, so hand over the URL. This is the one place a
262
+ // terminal is allowed the thing the desktop role forbids — there,
263
+ // "go and open this" hands work back; here it IS the surface.
264
+ console.log(` ${color.cyan(String(p.url ?? ''))} ${color.dim(String(p.caption ?? ''))}`);
265
+ }
266
+ else if (p.kind === 'image') {
267
+ console.log(color.dim(` [image] ${String(p.caption ?? '')}`));
268
+ }
269
+ else if (p.kind === 'window') {
270
+ /**
271
+ * ⚠ FLOATING WINDOWS ARE THE ONE THING A CLI USER DOES NOT GET,
272
+ * and it says so rather than failing silently. The daemon emits the
273
+ * request; only the Mac app can perform it.
274
+ */
275
+ console.log(color.dim(` · (window ${p.op}: ${p.target} — not available in the terminal)`));
276
+ }
277
+ break;
278
+ }
279
+ /**
280
+ * A question the turn is BLOCKED on. Answered inline, because the
281
+ * alternative is a terminal that hangs for ten minutes and then
282
+ * reports a timeout the user had no way to prevent.
283
+ */
284
+ case 'agent:ask':
285
+ void answerInteractively(client, msg.askId, msg.ask);
286
+ break;
287
+ /**
288
+ * ⚠ NOT THE REPLY — Omni's own working text, STREAMED as it is produced.
289
+ *
290
+ * Dimmed rather than hidden, and no longer behind `--verbose`. On a
291
+ * terminal there is no work-log drawer to open, so a two-minute coding
292
+ * turn that prints nothing until it finishes is indistinguishable from a
293
+ * hang — which is the single most common reason someone Ctrl-Cs live
294
+ * work. The dimming is what keeps it from being mistaken for the answer.
295
+ *
296
+ * `--quiet` suppresses it for scripts, where interleaved thinking is
297
+ * noise rather than reassurance.
298
+ */
299
+ case 'agent:text-delta':
300
+ if (!values.quiet) {
301
+ if (lastLineWasTool) {
302
+ process.stdout.write('\n');
303
+ lastLineWasTool = false;
304
+ }
305
+ process.stdout.write(color.dim(msg.text ?? ''));
306
+ printedAny = true;
307
+ }
308
+ break;
309
+ /**
310
+ * ⚠ STEPS RENDER INLINE — the name, then EVERY argument, then the result.
311
+ *
312
+ * This used to be one line per call: the tool name plus `summarize()`,
313
+ * which picked a single string field and cut it at 80 chars. So
314
+ * `canvas_act {action:'canvas.compose', args:{kind:'hero',…}}` rendered
315
+ * as the composition id and nothing else — you could see THAT a step
316
+ * happened and never what it did. Reading back a session meant guessing.
317
+ */
318
+ case 'agent:tool-call': {
319
+ const callName = String(msg.tool ?? '');
320
+ // Recorded even when quiet, so a result that DOES print (a failure)
321
+ // can still name its tool.
322
+ toolNames.set(String(msg.callId ?? ''), callName);
323
+ if (values.quiet)
324
+ break;
325
+ lastCallId = String(msg.callId ?? '');
326
+ process.stdout.write(`\n ${color.dim('▪')} ${color.cyan(callName)}\n`);
327
+ /**
328
+ * ⚠ SOME TOOLS' ARGUMENTS ARE THE OUTPUT, and printing both says it
329
+ * twice. `reply_to_principal`'s `text` IS the answer, which arrives a
330
+ * moment later as `agent:principal` and renders in full — so dumping
331
+ * the arg first shows the whole reply dimmed, then again brightly. The
332
+ * step line still prints, because knowing Omni chose to reply is
333
+ * useful; only the echo is dropped.
334
+ */
335
+ if (ECHOED_BY_EVENT.has(callName)) {
336
+ lastLineWasTool = false;
337
+ break;
338
+ }
339
+ for (const line of describeInput(msg.input ?? {})) {
340
+ // ⚠ THE NEWLINE STAYS OUTSIDE THE COLOUR. Wrapping it puts the reset
341
+ // sequence AFTER the line break, so the next line starts mid-escape
342
+ // and a piped log shows a stray `[0m` at every line start.
343
+ process.stdout.write(`${color.dim(` ${line}`)}\n`);
344
+ }
345
+ lastLineWasTool = false;
346
+ break;
347
+ }
348
+ /**
349
+ * ⚠ SUCCESSES ARE SHOWN TOO, WHICH THEY WERE NOT.
350
+ *
351
+ * The old rule was failures-only, on the reasoning that "a successful
352
+ * grep's stdout would bury the reply". That reasoning was sound and the
353
+ * conclusion was too strong: the daemon already caps `preview` at 200
354
+ * chars before it ever leaves the DO, so the flood it guarded against
355
+ * cannot reach here. What the rule actually cost was the ability to see
356
+ * whether a step did what it claimed — a tool that returns `{ok:false}`
357
+ * INSIDE a successful call (which every canvas RPC does) was invisible.
358
+ */
359
+ case 'agent:tool-result': {
360
+ // ⚠ FAILURES SURVIVE `--quiet`. A script that suppresses the step log
361
+ // still needs to see the step that broke; silence there turns a failed
362
+ // run into a mysteriously empty one.
363
+ if (values.quiet && msg.ok)
364
+ break;
365
+ /**
366
+ * ⚠ THE NAME IS ONLY PRINTED WHEN IT IS NOT ALREADY OBVIOUS.
367
+ *
368
+ * A result normally lands directly under its own call, where repeating
369
+ * the tool name is pure noise on every single step. It stops being
370
+ * obvious when calls INTERLEAVE — parallel tool use means the result
371
+ * above may belong to a different step — so the label appears exactly
372
+ * then, which is the only time it carries information.
373
+ */
374
+ const callId = String(msg.callId ?? '');
375
+ const name = callId === lastCallId ? '' : (toolNames.get(callId) ?? '');
376
+ const mark = msg.ok ? color.green('✓') : color.red('✗');
377
+ const body = String(msg.preview ?? '').replace(/\s+/g, ' ').trim();
378
+ const shown = body.length > 160 ? `${body.slice(0, 159)}…` : body;
379
+ const tint = msg.ok ? color.dim : color.red;
380
+ process.stdout.write(` ${mark} ${tint(shown || (msg.ok ? 'ok' : 'failed'))}${name ? color.dim(` (${name})`) : ''}\n`);
381
+ lastLineWasTool = false;
382
+ break;
383
+ }
384
+ case 'agent:error':
385
+ console.error(`\n ${color.red('✗')} ${msg.error}`);
386
+ resolve();
387
+ break;
388
+ case 'agent:done':
389
+ process.stdout.write('\n');
390
+ resolve();
391
+ break;
392
+ }
393
+ });
394
+ });
395
+ await client.omni('prompt', { prompt, sessionId, cwd: values.cwd ?? process.cwd() });
396
+ // No timeout on the wait: a coding turn legitimately runs for minutes and
397
+ // the user can always Ctrl-C. A timer here would abandon live work.
398
+ await done;
399
+ if (!printedAny)
400
+ console.log(color.dim(' (no text in that turn)'));
401
+ }
402
+ catch (err) {
403
+ console.error(color.red(` ✗ ${err?.message ?? err}`));
404
+ process.exitCode = 1;
405
+ }
406
+ finally {
407
+ client.disconnect();
408
+ }
409
+ }
410
+ /**
411
+ * Render a blocked question and send the answer back.
412
+ *
413
+ * ⚠ THE TURN IS PARKED WHILE THIS IS OPEN. `ask_principal` and `propose_plan`
414
+ * sit inside a tool call in the daemon waiting for `omni:answer`, so a terminal
415
+ * that printed the question and moved on would hang for the full ten-minute
416
+ * timeout and then be told nobody answered.
417
+ *
418
+ * ⚠ FAILS AS A DISMISSAL, NOT AS AN ANSWER. If stdin is not a TTY (a script, a
419
+ * pipe, CI) there is nobody to ask, and answering with an empty string would
420
+ * read to the agent as a choice. `dismissed` is the honest outcome and the agent
421
+ * is instructed to treat it as "not now" rather than consent.
422
+ */
423
+ async function answerInteractively(client, askId, ask) {
424
+ const opts = Array.isArray(ask?.options) ? ask.options : [];
425
+ try {
426
+ console.log(`\n ${color.yellow('?')} ${ask?.question ?? 'Omni has a question'}`);
427
+ if (ask?.detail)
428
+ console.log(color.dim(` ${String(ask.detail).split('\n').join('\n ')}`));
429
+ if (!process.stdin.isTTY) {
430
+ console.log(color.dim(' (not a terminal — skipping)'));
431
+ await client.omni('answer', { askId, value: '', outcome: 'dismissed' });
432
+ return;
433
+ }
434
+ const { createInterface } = await import('node:readline/promises');
435
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
436
+ try {
437
+ let value = '';
438
+ if (opts.length) {
439
+ opts.forEach((o, i) => {
440
+ console.log(` ${color.cyan(String(i + 1))}. ${o.label}${o.detail ? color.dim(` — ${o.detail}`) : ''}`);
441
+ });
442
+ const raw = (await rl.question(' Choose [1]: ')).trim();
443
+ // Empty means the first option, which is where Omni is told to put its
444
+ // recommendation — so pressing return agrees with the recommendation.
445
+ const idx = raw === '' ? 0 : Number(raw) - 1;
446
+ value = opts[Number.isFinite(idx) && idx >= 0 && idx < opts.length ? idx : 0].value;
447
+ }
448
+ else {
449
+ value = (await rl.question(' ')).trim();
450
+ }
451
+ await client.omni('answer', { askId, value, outcome: value ? 'answered' : 'dismissed' });
452
+ }
453
+ finally {
454
+ rl.close();
455
+ }
456
+ }
457
+ catch {
458
+ // Never let a prompt failure strand the turn — dismiss so it can continue.
459
+ try {
460
+ await client.omni('answer', { askId, value: '', outcome: 'dismissed' });
461
+ }
462
+ catch { /* gone */ }
463
+ }
464
+ }
465
+ /**
466
+ * Every argument of a tool call, one `key: value` per line.
467
+ *
468
+ * ⚠ REPLACES `summarize()`, which returned ONE line built from the first string
469
+ * field it recognised. That was fine for `Bash{command}` and actively misleading
470
+ * for a structured call: `canvas_act{action, args, compositionId}` printed only
471
+ * the composition id, so the step that composed a hero block and the step that
472
+ * deleted a node looked identical.
473
+ *
474
+ * Bounded on BOTH axes on purpose — a `Write` call carries an entire file and a
475
+ * `MultiEdit` carries a list of them, so an unbounded dump would push the reply
476
+ * off screen, which is the failure the old one-liner was over-correcting for.
477
+ * Long values are cut at 240 chars; long inputs stop after 12 keys and say how
478
+ * many were withheld rather than trailing off silently.
479
+ */
480
+ const MAX_VALUE_CHARS = 240;
481
+ const MAX_KEYS = 12;
482
+ /**
483
+ * Tools whose ARGUMENTS are re-rendered by a dedicated event, so echoing them as
484
+ * a step would print the same content twice.
485
+ *
486
+ * Each of these emits an `agent:principal` payload that the renderer above draws
487
+ * properly — prose, a URL, an image caption, a window notice. `ask_principal` is
488
+ * here for the same reason via `agent:ask`, which prints the question AND the
489
+ * choices before blocking on an answer.
490
+ */
491
+ const ECHOED_BY_EVENT = new Set([
492
+ 'mcp__principal__reply_to_principal',
493
+ 'mcp__principal__ask_principal',
494
+ 'mcp__principal__show_preview',
495
+ 'mcp__principal__show_image',
496
+ 'mcp__principal__open_window',
497
+ ]);
498
+ function describeInput(input) {
499
+ const entries = Object.entries(input ?? {});
500
+ if (!entries.length)
501
+ return [];
502
+ const lines = [];
503
+ for (const [k, v] of entries.slice(0, MAX_KEYS)) {
504
+ let s;
505
+ if (typeof v === 'string')
506
+ s = v;
507
+ else if (v === null || v === undefined)
508
+ s = String(v);
509
+ else {
510
+ // Objects/arrays are the whole reason this exists — serialize rather than
511
+ // letting them render as "[object Object]", the exact bug this repo already
512
+ // records for `json` columns that are a type hint and not a serializer.
513
+ try {
514
+ s = JSON.stringify(v);
515
+ }
516
+ catch {
517
+ s = String(v);
518
+ }
519
+ }
520
+ s = s.replace(/\s+/g, ' ').trim();
521
+ if (s.length > MAX_VALUE_CHARS)
522
+ s = `${s.slice(0, MAX_VALUE_CHARS - 1)}…`;
523
+ lines.push(`${k}: ${s}`);
524
+ }
525
+ const hidden = entries.length - MAX_KEYS;
526
+ if (hidden > 0)
527
+ lines.push(`… ${hidden} more field${hidden === 1 ? '' : 's'}`);
528
+ return lines;
529
+ }