quoxcli 0.0.0-stage → 0.2.0

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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,35 @@
1
+ import { DEMO_STEPS } from './demo.fixtures.js';
2
+
3
+ const sleep = (ms, signal) => new Promise((res) => {
4
+ const done = () => { clearTimeout(t); signal?.removeEventListener('abort', done); res(); };
5
+ const t = setTimeout(done, ms);
6
+ signal?.addEventListener('abort', done, { once: true });
7
+ });
8
+
9
+ /** A fake api with the same surface as createApi(), driven by DEMO_STEPS. */
10
+ export function createDemoApi({ speed = 1 } = {}) {
11
+ let gate = null;
12
+ let decision = 'approve';
13
+ return {
14
+ orgId: () => 'demo-org',
15
+ async *stream({ turnId }, signal) {
16
+ decision = 'approve';
17
+ for (const step of DEMO_STEPS(turnId)) {
18
+ if (signal?.aborted) return;
19
+ if (step.when && step.when !== decision) continue;
20
+ if (step.gate) { await new Promise((res) => { gate = res; signal?.addEventListener('abort', res, { once: true }); }); continue; }
21
+ await sleep(step.wait / speed, signal);
22
+ if (signal?.aborted) return;
23
+ yield step.ev;
24
+ }
25
+ },
26
+ async steer() { return { ok: false, reason: 'unsupported' }; },
27
+ async interrupt() { return { ok: false }; },
28
+ async health() { return { ok: true, url: 'demo' }; },
29
+ async approvals() { return []; },
30
+ async skills() { return [{ id: 'sk1', name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }]; },
31
+ async skill(id) { return { id, name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }; },
32
+ async conversationExists() { return { exists: true }; },
33
+ async respond(_id, action) { decision = action === 'reject' ? 'reject' : 'approve'; const g = gate; gate = null; g?.(); return { ok: true }; }
34
+ };
35
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Dragon's Eye launch Easter egg (CLI-QUOX). On launch the settled Q opens its eye and blinks, rarely:
3
+ * 1 in EGG_ODDS launches, never twice in a row, never in --print, under reduced motion or off a TTY.
4
+ * /eye plays it on demand, and the cinematic motion level always may.
5
+ *
6
+ * pickLaunchEgg is pure; launchEgg adds the one bit of persistence (did the last launch show the eye?) in
7
+ * ~/.config/quox/motion.json. QUOX_EGG=eye forces it (demos, captures); QUOX_EGG=off disables it.
8
+ */
9
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { join, dirname } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+
13
+ export const EGG_ODDS = 20;
14
+
15
+ /** Pure decision. */
16
+ export function pickLaunchEgg({ rand = Math.random, lastWasEye = false, print = false, reduced = false, tty = true, force = null } = {}) {
17
+ if (print || reduced || !tty) return false;
18
+ if (force === 'off') return false;
19
+ if (force === 'eye') return true;
20
+ if (lastWasEye) return false;
21
+ return rand() < 1 / EGG_ODDS;
22
+ }
23
+
24
+ export const eggStatePath = (env = process.env) => join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'quox', 'motion.json');
25
+
26
+ /** Decide once for this launch and remember the outcome. Never throws: an unreadable file means "no eye last time". */
27
+ export function launchEgg({ env = process.env, tty = Boolean(process.stdout?.isTTY), print = false, rand = Math.random, path = eggStatePath(env) } = {}) {
28
+ let last = false;
29
+ try { last = Boolean(JSON.parse(readFileSync(path, 'utf8')).lastEye); } catch { /* first launch */ }
30
+ const reduced = env.QUOX_REDUCED_MOTION === '1' || Boolean(env.QUOX_TUI_LITE);
31
+ const eye = pickLaunchEgg({ rand, lastWasEye: last, print, reduced, tty, force: env.QUOX_EGG || null });
32
+ if (!print && tty) {
33
+ try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify({ lastEye: eye }) + '\n'); } catch { /* read-only home: fine */ }
34
+ }
35
+ return eye;
36
+ }
@@ -0,0 +1,458 @@
1
+ /**
2
+ * QUOXON conversation state reducer. Consumes BOTH the legacy /chat/stream
3
+ * events (connected, thinking, chunk, done, error) and the v1 contract
4
+ * (docs/QUOXON_EVENTS_v1.md): turn.*, text.delta, tool.*, approval.*, trace,
5
+ * steer.accepted. Pure: no I/O, no clocks (time comes in on actions).
6
+ *
7
+ * Shape
8
+ * history : settled items, rendered once via <Static>
9
+ * blocks : the live turn's ordered blocks; a settled PREFIX is flushed to
10
+ * history (text followed by something, tools that finished)
11
+ * turn : null | { id, status: running|interrupting, startedAt, stage, activity }
12
+ */
13
+
14
+ import { splitSettled } from './markdown.js';
15
+ import { nextActivity, actionActivity } from './activity.js';
16
+ import { normUsage, turnTokens, fmtTokens, fmtCost } from './indicator.js';
17
+
18
+ export const TOOL_STATUSES = ['ok', 'failed', 'denied', 'effect_unknown'];
19
+ export const APPROVAL_ARM_MS = 300;
20
+ export const KEY_REPEAT_MS = 400;
21
+ export const HELD_KEY_MS = 1500;
22
+
23
+ export const initialState = (over = {}) => ({
24
+ history: [],
25
+ blocks: [],
26
+ turn: null,
27
+ ctx: { agent: null, model: null, provider: null, endpoint: null, hat: null, conversationId: null },
28
+ pending: [], // client-side queue (Tab)
29
+ steering: [], // steer messages not yet accepted
30
+ approvals: [], // approval cards waiting for a human, first is shown; survive the turn that raised them
31
+ running: [], // approved from this terminal, result arrives asynchronously (polled)
32
+ inbox: [], // last /approvals listing
33
+ verbose: 'on',
34
+ doneCalls: {},
35
+ background: 0,
36
+ notify: { approvals: null, error: null },
37
+ seenInbox: {}, // inbox item ids already shown as a card (event or poll), so a poll never re-raises one
38
+ autoDrain: false,
39
+ seq: 0,
40
+ ...over
41
+ });
42
+
43
+ /**
44
+ * The server mirrors every legacy `chunk` as a `text.delta` (chunk first). Per turn, text comes from
45
+ * text.delta once the stream is known to be v1 (turn.started or any text.delta); legacy-only
46
+ * streams keep using `chunk`. Returns { append, st } where st is { v1, lastChunk }.
47
+ */
48
+ export function textGate(st, ev) {
49
+ const g = st || { v1: false, lastChunk: null };
50
+ if (ev.type === 'turn.started') return { append: false, st: { ...g, v1: true } };
51
+ if (ev.type === 'chunk') {
52
+ if (g.v1) return { append: false, st: g };
53
+ return { append: Boolean(ev.content), st: { ...g, lastChunk: ev.content ?? null } };
54
+ }
55
+ if (ev.type === 'text.delta') {
56
+ const mirrored = !g.v1 && g.lastChunk != null && g.lastChunk === ev.content;
57
+ return { append: Boolean(ev.content) && !mirrored, st: { v1: true, lastChunk: null } };
58
+ }
59
+ return { append: false, st: g };
60
+ }
61
+
62
+ const tms = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? t : null; };
63
+
64
+ /**
65
+ * A pending inbox approval found by polling -> approval card, or null when it does not qualify.
66
+ * Qualifies when created after `since` (the turn start, else app start), or raised on behalf of this
67
+ * user, or in this conversation. The inbox list is already scoped to the signed-in user and org.
68
+ */
69
+ export function cardFromInboxItem(it, { since = 0, userId = null, conversationId = null, now = 0 } = {}) {
70
+ if (!it || !it.id) return null;
71
+ const c = it.payload?.approval_context || {};
72
+ const m = c.method || {};
73
+ const created = tms(it.created_at ?? it.createdAt);
74
+ const mine = Boolean(userId && c.on_behalf_of?.user_id && String(c.on_behalf_of.user_id) === String(userId));
75
+ const here = Boolean(conversationId && c.on_behalf_of?.conversation_id === conversationId);
76
+ if (!(created != null && created >= since) && !mine && !here) return null;
77
+ return {
78
+ callId: `inbox:${it.id}`, inboxItemId: it.id, approvalId: it.payload?.approval_id || it.approval_id || it.approvalId || null,
79
+ tool: m.tool || String(it.title || it.id), target: m.target || null, scope: null, risk: c.risk || null,
80
+ url: it.dashboard_url || null, shownAt: now, undescribable: !m.tool || !m.target
81
+ };
82
+ }
83
+
84
+ let uid = 0;
85
+ const nid = () => ++uid;
86
+
87
+ const settled = (b) => b.kind === 'tool' ? b.status !== 'running' : b.kind !== 'text';
88
+
89
+ /** Move the settled prefix of live blocks into history. */
90
+ function flush(s, force = false) {
91
+ const blocks = [...s.blocks];
92
+ const out = [];
93
+ while (blocks.length) {
94
+ const b = blocks[0];
95
+ const done = force || settled(b) || (b.kind === 'text' && blocks.length > 1);
96
+ if (!done) break;
97
+ blocks.shift();
98
+ if (b.kind === 'text' && !b.text.trim()) continue;
99
+ out.push(b);
100
+ }
101
+ return out.length || blocks.length !== s.blocks.length ? { ...s, history: [...s.history, ...out], blocks } : s;
102
+ }
103
+
104
+ function note(s, text, tone = 'dim') {
105
+ return { ...s, history: [...s.history, { id: nid(), kind: 'note', text, tone }] };
106
+ }
107
+
108
+ function addBlock(s, b) { return flush({ ...s, blocks: [...s.blocks, { id: nid(), ...b }] }); }
109
+
110
+ // Raw markdown of each answer, kept for /copy (history holds only the split, rendered blocks).
111
+ function recordRaw(s, content) {
112
+ const id = s.turn?.id ?? '_';
113
+ const a = s.answers || [];
114
+ const last = a[a.length - 1];
115
+ const next = last && last.id === id ? a.slice(0, -1).concat({ id, text: last.text + content }) : a.concat({ id, text: String(content) }).slice(-20);
116
+ return { ...s, answers: next };
117
+ }
118
+
119
+ function appendText(s00, content) {
120
+ const s0 = recordRaw(s00, content);
121
+ const s = s0.turn && !s0.turn.sawText ? { ...s0, turn: { ...s0.turn, sawText: true } } : s0;
122
+ const last = s.blocks[s.blocks.length - 1];
123
+ if (last && last.kind === 'text') {
124
+ let text = last.text + content;
125
+ let history = s.history;
126
+ // Completed markdown blocks leave the live region (-> <Static>); only the unfinished tail re-renders.
127
+ if (s.blocks.length === 1 && text.length > 1) {
128
+ const { settled, rest } = splitSettled(text);
129
+ if (settled.trim()) { history = [...history, { id: nid(), kind: 'text', text: settled }]; text = rest; }
130
+ }
131
+ return { ...s, history, blocks: s.blocks.slice(0, -1).concat({ ...last, text }) };
132
+ }
133
+ return addBlock(s, { kind: 'text', text: content });
134
+ }
135
+
136
+ function upsertTool(s, ev, patch) {
137
+ const i = s.blocks.findIndex(b => b.kind === 'tool' && b.callId === ev.call_id);
138
+ if (i >= 0) {
139
+ const cur = s.blocks[i];
140
+ // started after finished (out of order): never downgrade a settled row
141
+ const p = cur.status !== 'running' && patch.status === 'running' ? { summary: cur.summary || patch.summary, tool: cur.tool || patch.tool } : patch;
142
+ const blocks = s.blocks.slice();
143
+ blocks[i] = { ...cur, ...p };
144
+ const done = p.status && p.status !== 'running' ? { ...s.doneCalls, [ev.call_id]: true } : s.doneCalls;
145
+ return flush({ ...s, blocks, doneCalls: done });
146
+ }
147
+ // A row that already settled and left the live region: a late tool.started (out of order) or a duplicate finish is dropped.
148
+ if (s.doneCalls[ev.call_id]) return s;
149
+ let n = patch.status !== 'running' ? { ...s, doneCalls: { ...s.doneCalls, [ev.call_id]: true } } : s;
150
+ if (n.turn) n = { ...n, turn: { ...n.turn, tools: n.turn.tools + 1 } };
151
+ return addBlock(n, { kind: 'tool', callId: ev.call_id, tool: ev.tool, ...patch });
152
+ }
153
+
154
+ function finishRunning(s, status, detail) {
155
+ const blocks = s.blocks.map(b => b.kind === 'tool' && b.status === 'running'
156
+ ? { ...b, status, resultSummary: detail } : b);
157
+ return { ...s, blocks };
158
+ }
159
+
160
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
161
+
162
+ /** One grey closing line from facts only (durations, counts, receipt ids). */
163
+ export function summarizeTurn(s, status, now) {
164
+ const t = s.turn;
165
+ const secs = t && now != null && t.startedAt != null ? Math.max(0, Math.round((now - t.startedAt) / 1000)) : null;
166
+ const finished = t?.finishedTools || 0;
167
+ const unknown = s.blocks.filter(b => b.kind === 'tool' && (b.status === 'running' || b.status === 'effect_unknown')).length;
168
+ if (status === 'unconfirmed') return 'Ended without confirmation from the server';
169
+ const head = status === 'interrupted' ? 'Interrupted' : status === 'failed' ? 'Failed' : 'Thought';
170
+ const parts = [secs == null ? head : `${head} ${status === 'completed' ? 'for' : 'after'} ${secs}s`];
171
+ if (t?.model) parts.push(t.model.length > 32 ? t.model.slice(0, 31) + '…' : t.model); // which model answered, as reported
172
+ // Real server usage when it arrived; otherwise the estimate from streamed text; nothing when neither exists.
173
+ const tk = turnTokens(t);
174
+ if (tk > 0) parts.push(`${fmtTokens(tk)} tokens`);
175
+ const cost = fmtCost(t?.usage?.cost ?? normUsage(null, t?.doneCost).cost);
176
+ if (cost) parts.push(cost);
177
+ if (status === 'interrupted' || status === 'failed') {
178
+ if (t?.tools) parts.push(`${plural(finished, 'tool')} finished`);
179
+ if (unknown) parts.push(`${unknown} effect unknown`);
180
+ } else if (t?.tools) parts.push(plural(t.tools, 'tool'));
181
+ if (t?.approvals) parts.push(plural(t.approvals, 'approval'));
182
+ const r = t?.receipts || [];
183
+ if (r.length === 1) parts.push(`receipt ward:${r[0]}`);
184
+ else if (r.length > 1) parts.push(`${r.length} receipts, last ward:${r[r.length - 1]}`);
185
+ return parts.join(' · ');
186
+ }
187
+
188
+ function endTurn(s, status, now) {
189
+ let n = s;
190
+ if (status === 'interrupted' || status === 'failed' || status === 'unconfirmed') {
191
+ n = finishRunning(n, 'effect_unknown', 'dispatched, no result received; verify before retrying');
192
+ } else {
193
+ n = finishRunning(n, 'effect_unknown', 'no result reported');
194
+ }
195
+ n = flush(n, true);
196
+ const summary = summarizeTurn(s, status, now);
197
+ if (status === 'interrupted') n = note(n, 'Interrupted. Nothing completed was undone.', 'warn');
198
+ // unaccepted steers become queued items, in front
199
+ let pending = n.pending;
200
+ let steering = n.steering;
201
+ if (steering.length) {
202
+ pending = [...steering.map(x => ({ id: x.id, text: x.text })), ...pending];
203
+ steering = [];
204
+ n = note(n, 'Steering was not applied before the turn ended; queued instead.', 'warn');
205
+ }
206
+ n = { ...n, history: [...n.history, { id: nid(), kind: 'summary', text: summary }] };
207
+ return {
208
+ ...n, turn: null, pending, steering,
209
+ // Only a confirmed-completed turn drains the queue; failed, interrupted or unconfirmed turns leave
210
+ // the pending items visible for the human to send.
211
+ autoDrain: status === 'completed' && pending.length > 0
212
+ };
213
+ }
214
+
215
+ function reduceInner(s, a) {
216
+ switch (a.type) {
217
+ case 'user': // a submitted prompt
218
+ return { ...s, history: [...s.history, { id: nid(), kind: 'user', text: a.text, queued: !!a.queued }] };
219
+ case 'turn.begin':
220
+ return { ...s, turn: { id: a.turnId, status: 'running', startedAt: a.now, stage: null, activity: null, tools: 0, finishedTools: 0, approvals: 0, receipts: [], chars: 0, usage: null }, blocks: [], autoDrain: false };
221
+ case 'note':
222
+ return note(s, a.text, a.tone);
223
+ case 'notify':
224
+ return { ...s, notify: { ...s.notify, ...a.patch } };
225
+ case 'welcome':
226
+ return { ...s, history: [...s.history, { id: nid(), kind: 'welcome', version: a.version }] };
227
+ case 'help':
228
+ return { ...s, history: [...s.history, { id: nid(), kind: 'help', model: a.model }] };
229
+ case 'object': // inline object view (objects/): pure data, rebuilt at render width
230
+ return { ...s, history: [...s.history, { id: nid(), kind: 'object', obj: a.obj }] };
231
+ case 'error':
232
+ return flush(addBlock(s, { kind: 'error', text: a.text }), false);
233
+ case 'verbose':
234
+ return { ...s, verbose: a.mode };
235
+ case 'history.add': // restored turns (/resume): ordinary history items, plus their answers for /copy
236
+ return { ...s, history: [...s.history, ...(a.items || []).map(i => ({ id: nid(), ...i }))], answers: [...(s.answers || []), ...(a.answers || [])].slice(-20) };
237
+ case 'history.reset': // /clear: a fresh screen; nothing on the server is touched
238
+ return s.turn ? s : { ...s, history: [], blocks: [], answers: [] };
239
+ case 'ctx':
240
+ return { ...s, ctx: { ...s.ctx, ...a.ctx } };
241
+ case 'resume': { // QUOXON J2: open an existing conversation; its saved turns become the visible history
242
+ const items = [];
243
+ for (const t of a.turns || []) {
244
+ if (t.role === 'user') items.push({ id: nid(), kind: 'user', text: String(t.content ?? ''), queued: false });
245
+ else if (t.role === 'assistant') items.push({ id: nid(), kind: 'text', text: String(t.content ?? '') });
246
+ }
247
+ const n = items.filter((x) => x.kind === 'user').length;
248
+ const s2 = { ...s, ctx: { ...s.ctx, conversationId: a.conversationId }, history: [...s.history, ...items] };
249
+ return note(s2, `Resumed conversation ${a.conversationId} (${n} earlier turn${n === 1 ? '' : 's'}). New messages continue it.`);
250
+ }
251
+ case 'inbox':
252
+ return { ...s, inbox: a.items };
253
+ case 'queue.add':
254
+ return { ...s, pending: [...s.pending, { id: nid(), text: a.text }] };
255
+ case 'queue.remove':
256
+ return { ...s, pending: s.pending.filter(p => p.id !== a.id) };
257
+ case 'queue.shift':
258
+ return { ...s, pending: s.pending.slice(1), autoDrain: false };
259
+ case 'queue.pop': // Up pulls the last item back to the composer
260
+ return { ...s, pending: s.pending.slice(0, -1) };
261
+ case 'steer.add':
262
+ return { ...s, steering: [...s.steering, { id: a.id, text: a.text }] };
263
+ case 'steer.converted': {
264
+ const item = s.steering.find(x => x.id === a.id);
265
+ if (!item) return s;
266
+ const n = note({ ...s, steering: s.steering.filter(x => x.id !== a.id), pending: [...s.pending, { id: item.id, text: item.text }] },
267
+ a.reason || 'Steering is not available for this turn; queued instead.', 'warn');
268
+ return { ...n, autoDrain: !n.turn };
269
+ }
270
+ case 'steer.failed': { // ambiguous or denied: never auto-queued; the text goes back to the draft (the app does that)
271
+ if (!s.steering.some(x => x.id === a.id)) return s;
272
+ return note({ ...s, steering: s.steering.filter(x => x.id !== a.id) }, a.reason, 'warn');
273
+ }
274
+ case 'interrupt.requested':
275
+ return s.turn ? { ...s, turn: { ...s.turn, status: 'interrupting' } } : s;
276
+ case 'stopped.local': { // SSE aborted by us because the server has no interrupt route
277
+ let n = note(s, 'Stopped locally; work already sent may still finish. Verify before retrying.', 'warn');
278
+ return endTurn(n, 'interrupted', a.now);
279
+ }
280
+ case 'approval.found': { // pending approval seen by polling (covers provider paths that emit no approval.requested)
281
+ const card = cardFromInboxItem(a.item, { since: s.turn?.startedAt ?? a.since ?? 0, userId: a.userId, conversationId: s.ctx.conversationId, now: a.now || 0 });
282
+ if (!card || s.seenInbox[card.inboxItemId] || s.approvals.some(x => x.inboxItemId === card.inboxItemId || (card.approvalId && x.approvalId === card.approvalId))) return s;
283
+ return { ...s, approvals: [...s.approvals, card], seenInbox: { ...s.seenInbox, [card.inboxItemId]: true }, turn: s.turn ? { ...s.turn, approvals: s.turn.approvals + 1 } : s.turn };
284
+ }
285
+ case 'approval.dismiss':
286
+ return { ...s, approvals: s.approvals.filter(x => x.callId !== a.callId) };
287
+ case 'approval.sent': { // the human answered from this terminal
288
+ const rest = { ...s, approvals: s.approvals.filter(x => x.callId !== a.callId) };
289
+ if (a.action !== 'approve') return note(rest, `denied: ${a.tool || 'approval'}${a.target ? ` on ${a.target}` : ''}`, 'warn');
290
+ const run = { callId: a.callId, approvalId: a.approvalId || null, tool: a.tool, target: a.target };
291
+ return note({ ...rest, running: [...rest.running.filter(x => x.callId !== a.callId), run] },
292
+ `approved; running (result arrives asynchronously)${a.tool ? `: ${a.tool}${a.target ? ` on ${a.target}` : ''}` : ''}`, 'ok');
293
+ }
294
+ case 'approval.result': { // outcome of polling GET /api/v1/approvals/:id/result
295
+ const run = s.running.find(x => x.callId === a.callId);
296
+ const n = { ...s, running: s.running.filter(x => x.callId !== a.callId) };
297
+ const what = run ? `${run.tool || 'approval'}${run.target ? ` on ${run.target}` : ''}` : 'approval';
298
+ if (a.outcome === 'done') return note(n, `result: ${what}: ${a.text || 'completed'}`, 'ok');
299
+ if (a.outcome === 'failed') return note(n, `failed: ${what}: ${a.text || 'error'}`, 'warn');
300
+ if (a.outcome === 'denied') return note(n, `denied: ${what}`, 'warn');
301
+ return note(n, `result not known yet: ${what}; check /approvals`, 'warn');
302
+ }
303
+ case 'inbox.sync': { // open approval ids from a successful inbox refresh
304
+ const ids = new Set(a.ids);
305
+ const approvals = s.approvals.filter(x => !x.inboxItemId || ids.has(x.inboxItemId) || (a.now || 0) - x.shownAt < 5000);
306
+ return approvals.length === s.approvals.length ? s : { ...s, approvals };
307
+ }
308
+ case 'event':
309
+ return reduceEvent(s, a.ev, a.now || 0);
310
+ default:
311
+ return s;
312
+ }
313
+ }
314
+
315
+ /** The first card is armed from the moment it is actually SHOWN, not from when it was received. */
316
+ export function reduce(s, a) {
317
+ let n = reduceInner(s, a);
318
+ // The honest activity word: only real events move it (see activity.js).
319
+ if (n.turn) {
320
+ const act = a.type === 'event' ? nextActivity(s.turn?.act, a.ev, a.now || 0, { hasText: Boolean(n.turn.chars) })
321
+ : actionActivity(n.turn.act, a, a.now || 0);
322
+ if (act !== n.turn.act) n = { ...n, turn: { ...n.turn, act } };
323
+ }
324
+ const first = n.approvals[0];
325
+ if (first && first.displayedAt == null) {
326
+ return { ...n, approvals: [{ ...first, displayedAt: a.now || 0 }, ...n.approvals.slice(1)] };
327
+ }
328
+ return n;
329
+ }
330
+
331
+ function reduceEvent(s, ev, now) {
332
+ switch (ev.type) {
333
+ case 'connected': return s;
334
+ // TODO(contract v2): background.status is not in events v1; wire here once the server defines it.
335
+ case 'turn.started': {
336
+ const ctx = {
337
+ agent: ev.agent ?? s.ctx.agent, model: ev.model ?? s.ctx.model, provider: ev.provider ?? s.ctx.provider,
338
+ // B3: the host a self-hosted provider ran at; cleared when the server names none.
339
+ endpoint: ev.provider ? (ev.endpoint ?? null) : (s.ctx.endpoint ?? null),
340
+ hat: ev.hat ?? s.ctx.hat, conversationId: ev.conversation_id ?? s.ctx.conversationId
341
+ };
342
+ // The model THIS turn actually used, as the server reported it (never the profile's default).
343
+ const used = ev.model || ev.provider || null;
344
+ return { ...s, ctx, turn: s.turn ? { ...s.turn, gate: textGate(s.turn.gate, ev).st, ...(used ? { model: String(used) } : {}) } : s.turn };
345
+ }
346
+ case 'thinking': {
347
+ const stage = ev.stage || '';
348
+ const msg = String(ev.message || '');
349
+ if (!s.turn) return s;
350
+ const turn = { ...s.turn, stage: msg || stage, activity: stage.includes('tool') && !s.blocks.some(b => b.kind === 'tool' && b.status === 'running') ? msg : s.turn.activity };
351
+ let n = { ...s, turn };
352
+ // B3: a provider fact the user should see (e.g. "this model does not support tools"), always, dim.
353
+ if (stage === 'provider_note' && msg) return addBlock(n, { kind: 'note', text: msg, tone: 'dim' });
354
+ // QUOXON: the model's own reasoning text: one dim, collapsed line (size only); verbose full
355
+ // expands it. It is never answer text.
356
+ if (stage === 'model_reasoning') {
357
+ const why = String(ev.reasoning || '').trim();
358
+ const text = s.verbose === 'full' && why ? `thinking: ${why}` : (msg || 'thinking');
359
+ return addBlock(n, { kind: 'note', text, tone: 'dim', collapsed: !(s.verbose === 'full' && why) });
360
+ }
361
+ // Legacy servers report tool use only as a thinking stage: show it honestly, with no result claimed.
362
+ if (stage.includes('tool') && msg) n = addBlock(n, { kind: 'note', text: `working: ${msg}`, tone: 'dim', legacyTool: true });
363
+ if (s.verbose === 'full' && msg && !stage.includes('tool')) n = addBlock(n, { kind: 'note', text: `thinking: ${msg}`, tone: 'dim' });
364
+ return n;
365
+ }
366
+ case 'chunk': case 'text.delta': {
367
+ const g = textGate(s.turn?.gate, ev);
368
+ const n = s.turn ? { ...s, turn: { ...s.turn, gate: g.st } } : s;
369
+ if (!g.append) return n;
370
+ const counted = n.turn ? { ...n, turn: { ...n.turn, chars: (n.turn.chars || 0) + String(ev.content).length } } : n;
371
+ return appendText(counted, ev.content);
372
+ }
373
+ // QUOXON C5 (events v1.1): the client's own reconnect facts around a dropped durable turn.
374
+ case 'reconnecting':
375
+ return ev.attempt === 1 && s.turn ? addBlock(s, { kind: 'note', text: 'Connection to the server dropped; reconnecting. Quox keeps working on the turn meanwhile.', tone: 'warn' }) : s;
376
+ case 'resumed':
377
+ return s.turn ? addBlock(s, { kind: 'note', text: ev.after ? `Reconnected; picking up after event ${ev.after}.` : 'Reattached to the running turn.', tone: 'dim' }) : s;
378
+ case 'replay.gap':
379
+ return addBlock(s, { kind: 'note', text: `Events ${ev.from} to ${ev.to} of this turn were not kept on the server (size cap); the rest follows.`, tone: 'warn' });
380
+ case 'tool.started':
381
+ return upsertTool(s, ev, { status: 'running', summary: ev.summary || ev.tool, startedAt: now });
382
+ case 'tool.finished': {
383
+ const status = TOOL_STATUSES.includes(ev.status) ? ev.status : 'unknown'; // never green for a status we do not know
384
+ const first = !s.doneCalls[ev.call_id] && ['ok', 'failed', 'denied'].includes(status);
385
+ let withTool = upsertTool(s, ev, {
386
+ status, resultSummary: ev.result_summary, durationMs: ev.duration_ms,
387
+ envelopeId: ev.envelope_id, receipt: ev.receipt || null
388
+ });
389
+ if (first && withTool.turn) withTool = { ...withTool, turn: { ...withTool.turn, finishedTools: withTool.turn.finishedTools + 1 } };
390
+ if (ev.receipt && ev.receipt.verified === true && withTool.turn && ev.receipt.ward_seq != null) {
391
+ return { ...withTool, turn: { ...withTool.turn, receipts: [...withTool.turn.receipts, ev.receipt.ward_seq] } };
392
+ }
393
+ return withTool;
394
+ }
395
+ case 'approval.requested': {
396
+ const card = {
397
+ callId: ev.call_id, inboxItemId: ev.inbox_item_id, approvalId: ev.approval_id, tool: ev.tool,
398
+ target: ev.target, scope: ev.scope, risk: ev.risk, url: ev.dashboard_url, shownAt: now,
399
+ undescribable: ev.describable === false || !ev.target
400
+ };
401
+ if (s.approvals.some(x => x.callId === card.callId)) return s;
402
+ const dup = s.approvals.findIndex(x => (card.inboxItemId && x.inboxItemId === card.inboxItemId) || (card.approvalId && x.approvalId === card.approvalId));
403
+ if (dup >= 0) { // already raised by the poll: adopt the event's call id so approval.resolved finds it
404
+ const approvals = s.approvals.slice(); approvals[dup] = { ...approvals[dup], callId: card.callId };
405
+ return { ...s, approvals };
406
+ }
407
+ if (card.inboxItemId && s.seenInbox[card.inboxItemId]) return s; // already shown and answered
408
+ return { ...s, seenInbox: card.inboxItemId ? { ...s.seenInbox, [card.inboxItemId]: true } : s.seenInbox, approvals: [...s.approvals, card], turn: s.turn ? { ...s.turn, approvals: s.turn.approvals + 1 } : s.turn };
409
+ }
410
+ case 'approval.resolved': {
411
+ const card = s.approvals.find(x => x.callId === ev.call_id);
412
+ const n = { ...s, approvals: s.approvals.filter(x => x.callId !== ev.call_id) };
413
+ return card || ev.decision ? note(n, `approval ${ev.decision || 'resolved'}${card ? `: ${card.tool} on ${card.target}` : ''}`, ev.decision === 'approved' ? 'ok' : 'warn') : n;
414
+ }
415
+ case 'steer.accepted': {
416
+ const i = s.steering.findIndex(x => x.text === ev.text);
417
+ const steering = i >= 0 ? s.steering.filter((_, k) => k !== i) : s.steering.slice(1);
418
+ return addBlock({ ...s, steering }, { kind: 'note', text: `steered: ${ev.text}`, tone: 'accent' });
419
+ }
420
+ case 'trace':
421
+ if (s.verbose === 'off') return s;
422
+ return addBlock(s, { kind: 'trace', protocol: ev.protocol, label: ev.label, ref: ev.ref, detail: ev.detail });
423
+ case 'error': {
424
+ const n = addBlock(s, { kind: 'error', text: String(ev.error || 'stream error') });
425
+ return n;
426
+ }
427
+ case 'turn.finished': case 'done': {
428
+ if (!s.turn) return s; // dedupe: done after turn.finished
429
+ let n = s;
430
+ const convo = ev.conversation_id || ev.session_id;
431
+ if (convo) n = { ...n, ctx: { ...n.ctx, conversationId: convo } };
432
+ if (ev.type === 'done' && !n.turn.sawText && ev.full_response) {
433
+ n = appendText(n, ev.full_response);
434
+ }
435
+ // `done` only carries text/session; turn.finished (sent just after it) ends the turn. A legacy
436
+ // server never sends turn.finished: the app synthesises one when the stream closes.
437
+ // usage: `done` may carry cost_usd; turn.finished carries `usage` (input/output tokens, maybe cost_usd).
438
+ if (ev.type === 'done') return { ...n, turn: { ...n.turn, doneCost: ev.cost_usd } };
439
+ n = { ...n, turn: { ...n.turn, usage: normUsage(ev.usage, n.turn.doneCost) } };
440
+ return endTurn(n, ev.status || 'completed', now);
441
+ }
442
+ default:
443
+ return s;
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Approval key guard: dialog arms after 300 ms, ignores held keys (any key
449
+ * seen within KEY_REPEAT_MS), and the caller must never map Enter to approve.
450
+ */
451
+ export function approvalKeyAllowed({ now, shownAt, lastKeyAt, heldKey = null, ch = null }) {
452
+ if (now - shownAt < APPROVAL_ARM_MS) return false;
453
+ // a fresh press needs a release gap: any key within KEY_REPEAT_MS is a held/repeat sequence
454
+ if (lastKeyAt != null && now - lastKeyAt < KEY_REPEAT_MS) return false;
455
+ // the key that was already down when the card appeared stays dead until another key or a long gap
456
+ if (heldKey != null && ch === heldKey && (lastKeyAt == null || now - lastKeyAt < HELD_KEY_MS)) return false;
457
+ return true;
458
+ }