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,94 @@
1
+ /**
2
+ * Headless mode: `quox --print "<prompt>"` sends one turn and exits.
3
+ * (-p is --profile across quox, so print has no short flag.)
4
+ * Exit codes: 0 completed, 2 held for approval, 3 interrupted, 4 auth, 5 unconfirmed
5
+ * (stream closed without turn.finished), 1 other.
6
+ */
7
+ import { textGate } from './events.js';
8
+ import { streamFields, fallbackNote } from './model.js';
9
+ import { connectOfferFor } from './connect.js';
10
+
11
+ export const EXIT = { ok: 0, error: 1, approval: 2, interrupted: 3, auth: 4, unconfirmed: 5 };
12
+
13
+ export async function readStdin(stream = process.stdin) {
14
+ if (stream.isTTY) return '';
15
+ let s = '';
16
+ stream.setEncoding?.('utf8');
17
+ for await (const chunk of stream) s += chunk;
18
+ return s;
19
+ }
20
+
21
+ /** Resolve the prompt: arg wins; absent or "-" reads stdin. */
22
+ export async function resolvePrompt(arg, stdin) {
23
+ if (arg && arg !== '-') return arg;
24
+ return (await readStdin(stdin)).replace(/\n+$/, '');
25
+ }
26
+
27
+ const isAuth = (e) => e?.status === 401 || /valid jwt|service key|unauthor|not authenticated/i.test(e?.message || '');
28
+ const newTurnId = () => `QXN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
29
+
30
+ /** Runs one turn. out/err are write(string) sinks. Returns the exit code. */
31
+ export async function runPrint({ api, prompt, output = 'text', conversation = null, turnId = newTurnId(), timeoutS = 0, color = false, stopGraceMs = 8000, context = undefined, choice = null, promptProfile = undefined, out, err }) {
32
+ if (!prompt || !prompt.trim()) { err('quox: empty prompt (pass --print "<prompt>" or pipe it on stdin)\n'); return EXIT.error; }
33
+ const ctrl = new AbortController();
34
+ let abandoned = false, graceTimer = null;
35
+ // A timeout asks the SERVER to stop; only if it cannot (or does not answer in the grace) do we abandon the
36
+ // local stream, and then we say "abandoned", never "interrupted": the server may still finish the turn.
37
+ const requestStop = async () => {
38
+ let r = { ok: false };
39
+ try { if (api.interrupt) r = await api.interrupt(convo, turnId); } catch { r = { ok: false }; }
40
+ if (r?.ok) graceTimer = setTimeout(() => { abandoned = true; ctrl.abort(); }, stopGraceMs);
41
+ else { abandoned = true; ctrl.abort(); }
42
+ };
43
+ const timer = timeoutS > 0 ? setTimeout(requestStop, timeoutS * 1000) : null;
44
+ const t0 = Date.now();
45
+ const sum = { tools: 0, approvals: 0, receipts: [] };
46
+ let convo = conversation, doneSession = null, status = null, text = '', sawApproval = false, errorMsg = null, authFail = false;
47
+ const seen = new Set();
48
+ let offer = null;
49
+ let gate = null;
50
+ try {
51
+ for await (const ev of api.stream({ turnId, message: prompt, agent: 'quox', conversationId: conversation, context, ...streamFields(choice), ...(promptProfile ? { promptProfile } : {}) }, ctrl.signal)) {
52
+ if (output === 'json') out(JSON.stringify(ev) + '\n');
53
+ const fb = fallbackNote(choice, ev);
54
+ if (fb) err(`quox: ${fb}\n`);
55
+ if (!offer) offer = connectOfferFor(ev);
56
+ if (ev.conversation_id) convo = ev.conversation_id;
57
+ if (ev.session_id) doneSession = ev.session_id; // the server's real id: v1 events only echo the request
58
+ if (ev.type === 'chunk' || ev.type === 'text.delta' || ev.type === 'turn.started') { const g = textGate(gate, ev); gate = g.st; if (g.append) text += ev.content; }
59
+ else if (ev.type === 'done' && !text && ev.full_response) text = ev.full_response;
60
+ else if (ev.type === 'tool.started' && !seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; }
61
+ else if (ev.type === 'tool.finished') { if (!seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; } if (ev.receipt?.ward_seq != null) sum.receipts.push(ev.receipt.ward_seq); }
62
+ else if (ev.type === 'approval.requested') { sawApproval = true; sum.approvals++; }
63
+ else if (ev.type === 'approval.resolved') sawApproval = false;
64
+ else if (ev.type === 'turn.finished') status = ev.status || 'completed';
65
+ else if (ev.type === 'error') errorMsg = ev.error || 'stream error';
66
+ }
67
+ } catch (e) {
68
+ if (ctrl.signal.aborted) { status = 'abandoned'; errorMsg = `timed out after ${timeoutS}s; the server was not confirmed stopped`; }
69
+ else { authFail = isAuth(e); errorMsg = e?.message || String(e); }
70
+ } finally { if (timer) clearTimeout(timer); clearTimeout(graceTimer); }
71
+ if (ctrl.signal.aborted && !status) { status = 'abandoned'; errorMsg = errorMsg || `timed out after ${timeoutS}s; the server was not confirmed stopped`; }
72
+ if (doneSession) convo = doneSession;
73
+ const serverStopped = abandoned && status === 'abandoned' ? false : status === 'interrupted' ? true : null;
74
+
75
+ let code;
76
+ if (authFail) code = EXIT.auth;
77
+ else if (sawApproval && status !== 'interrupted' && status !== 'abandoned') { status = status === 'completed' || !status ? 'held_for_approval' : status; code = EXIT.approval; }
78
+ else if (status === 'completed' && !errorMsg) code = EXIT.ok;
79
+ else if (status === 'interrupted' || status === 'abandoned') code = EXIT.interrupted;
80
+ else if (sawApproval) { status = status || 'held_for_approval'; code = EXIT.approval; }
81
+ else if (!status && !errorMsg) { status = 'unconfirmed'; code = EXIT.unconfirmed; }
82
+ else code = EXIT.error;
83
+ if (!status) status = authFail ? 'auth' : 'failed';
84
+
85
+ if (output === 'json') {
86
+ out(JSON.stringify({ type: 'summary', status, turn_id: turnId, conversation_id: convo, duration_ms: Date.now() - t0, server_stopped: serverStopped, ...sum }) + '\n');
87
+ } else if (text) out(text.endsWith('\n') ? text : text + '\n');
88
+ if (authFail) err(`Not signed in for chat. Run: quox login -p <profile>\n`);
89
+ else if (code === EXIT.approval) err('Held for approval: approve it in the dashboard or with `quox` /approvals.\n');
90
+ else if (code === EXIT.unconfirmed) err('quox: Ended without confirmation from the server\n');
91
+ else if (errorMsg) err(`quox: ${errorMsg}\n`);
92
+ if (offer && code !== EXIT.ok) err(`${offer}\n`);
93
+ return code;
94
+ }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * The horned Q as Braille art, and every motion the owner reviewed in the lab (~/quoxon-work/intro/lab):
3
+ * Orbit & Lock (the intro), Portal, Particle collapse, and the Dragon's Eye. CLI-QUOX.
4
+ *
5
+ * PURE AND PORTABLE on purpose: no Ink, no process, no timers, no terminal APIs. Inputs are size, progress
6
+ * (or ms), state and seed; the core output is rows of cells `{ ch, rgb }` (rgb null = default colour). The
7
+ * same module renders in the terminal (cellsToAnsi) and in a browser (web/qface.js), which is how the Q
8
+ * becomes the face of the app on cli.quox.ai as well as in the CLI.
9
+ *
10
+ * The Q is rasterised from a small vector description (ring bowl, tapered tail, two curved horns) at the
11
+ * dot resolution of the target size, so every size has a smooth bowl instead of a scaled bitmap. Each dot
12
+ * knows which part it belongs to (bowl, tail, horn) and how far along that part it sits, which is what the
13
+ * tail sweep, the horn glint and the portal reveal key off.
14
+ */
15
+
16
+ export const SKY = [56, 189, 248];
17
+ export const VIOLET = [168, 85, 247];
18
+ export const AMBER = [245, 158, 11];
19
+ export const RED = [239, 68, 68];
20
+ const TAU = Math.PI * 2;
21
+ const ASPECT = 0.82; // silhouette width / height, in square dots
22
+
23
+ // size -> dot height. A Braille cell is 2 dots wide and 4 tall, so rows = H / 4.
24
+ // small is the lab's compact particle Q (under 60 cols); large/mid are the intro sizes.
25
+ export const SIZES = { large: 72, mid: 44, small: 24 };
26
+
27
+ /** Pick the Q size for a terminal. Under 60 cols the caller uses the half-block compact Q instead. */
28
+ export function qSize(cols, rows) {
29
+ if (cols < 60) return 'compact';
30
+ return cols >= 100 && rows >= 30 ? 'large' : 'mid';
31
+ }
32
+
33
+ /** Lab parity sizing: under 60 cols (or under 12 rows) the lab's compact particle Q, never a half-block. */
34
+ export function labSize(cols, rows) {
35
+ if (cols < 60 || rows < 12) return 'small';
36
+ return qSize(cols, rows);
37
+ }
38
+
39
+ export function qDims(size) {
40
+ const H = SIZES[size];
41
+ const cells = Math.ceil((H * ASPECT) / 2);
42
+ return { H, W: cells * 2, cols: cells, rows: H / 4 };
43
+ }
44
+
45
+ // ---- silhouette (unit space: y 0..1 top to bottom, x 0..ASPECT) -----------------------------------
46
+ const distSeg = (px, py, ax, ay, bx, by) => {
47
+ const dx = bx - ax, dy = by - ay;
48
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)));
49
+ return [Math.sqrt((px - (ax + dx * t)) ** 2 + (py - (ay + dy * t)) ** 2), t];
50
+ };
51
+ const bez = (a, b, c, t) => [(1 - t) ** 2 * a[0] + 2 * (1 - t) * t * b[0] + t * t * c[0], (1 - t) ** 2 * a[1] + 2 * (1 - t) * t * b[1] + t * t * c[1]];
52
+
53
+ export const BOWL = { cx: ASPECT / 2, cy: 0.6, rx: 0.4, ry: 0.385, w: 0.118 };
54
+ // Each horn is a quadratic curve, thick at the base and tapering to a point: out and up like a bull's.
55
+ // The right horn is the exact mirror of the left about the bowl's axis (it was hand-tuned and its tip had
56
+ // drifted to y 0.0 against the left's 0.03, which read as a thin stalk next to a solid horn).
57
+ const LEFT_HORN = { a: [0.2, 0.29], b: [-0.02, 0.27], c: [0.02, 0.03], r0: 0.048 };
58
+ const mirrorX = (p) => [ASPECT - p[0], p[1]];
59
+ const HORNS = [LEFT_HORN, { a: mirrorX(LEFT_HORN.a), b: mirrorX(LEFT_HORN.b), c: mirrorX(LEFT_HORN.c), r0: LEFT_HORN.r0 }];
60
+ const HORN_PTS = HORNS.map((hn) => Array.from({ length: 24 }, (_, i) => ({ p: bez(hn.a, hn.b, hn.c, i / 23), t: i / 23, r0: hn.r0 })));
61
+ const TAIL = { a: [0.4, 0.69], b: [0.79, 0.985], r0: 0.07, r1: 0.04 };
62
+
63
+ /** Which part of the Q a unit point is in: null, or { part: 'bowl'|'tail'|'horn', t } (t = 0 base .. 1 tip). */
64
+ function partAt(x, y) {
65
+ const [d, t] = distSeg(x, y, ...TAIL.a, ...TAIL.b);
66
+ const inTail = d <= TAIL.r0 + (TAIL.r1 - TAIL.r0) * t;
67
+ const dx = (x - BOWL.cx) / BOWL.rx, dy = (y - BOWL.cy) / BOWL.ry;
68
+ const e = Math.sqrt(dx ** 2 + dy ** 2);
69
+ const inner = 1 - BOWL.w / ((BOWL.rx + BOWL.ry) / 2);
70
+ if (e <= 1 && e >= inner) return { part: inTail && t > 0.45 ? 'tail' : 'bowl', t: inTail ? t : 0 };
71
+ if (inTail) return { part: 'tail', t };
72
+ if (y > 0.34) return null;
73
+ for (const pts of HORN_PTS) for (const q of pts) if (Math.sqrt((x - q.p[0]) ** 2 + (y - q.p[1]) ** 2) <= q.r0 * (1 - q.t * 0.93) + 0.004) return { part: 'horn', t: q.t };
74
+ return null;
75
+ }
76
+
77
+ const maskCache = new Map();
78
+ /**
79
+ * Boolean mask of the Q silhouette at a size: { W, H, ox, on(x, y), dots: [{ x, y, part, t }] }.
80
+ * ox is the left offset of the silhouette inside the cell grid (dots).
81
+ */
82
+ export function qMask(size) {
83
+ if (maskCache.has(size)) return maskCache.get(size);
84
+ const { W, H } = qDims(size);
85
+ const ox = (W - H * ASPECT) / 2; // the Q's axis sits exactly on the grid centre, so the mask mirrors dot for dot
86
+ const dots = [];
87
+ const grid = new Uint8Array(W * H);
88
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
89
+ // 2x2 supersample for clean edges
90
+ let n = 0;
91
+ for (const [sx, sy] of [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]]) if (partAt((x - ox + sx) / H, (y + sy) / H)) n++;
92
+ if (n >= 2) {
93
+ grid[y * W + x] = 1;
94
+ const p = partAt((x - ox + 0.5) / H, (y + 0.5) / H) || { part: 'bowl', t: 0 };
95
+ dots.push({ x, y, part: p.part, t: p.t });
96
+ }
97
+ }
98
+ const m = { W, H, ox, on: (x, y) => grid[y * W + x] === 1, dots };
99
+ maskCache.set(size, m);
100
+ return m;
101
+ }
102
+
103
+ // ---- intensity field -> cells -> text -----------------------------------------------------------------
104
+ const BR = [[0x1, 0x8], [0x2, 0x10], [0x4, 0x20], [0x40, 0x80]];
105
+ const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
106
+ const smooth = (e) => { e = clamp(e); return e * e * (3 - 2 * e); };
107
+ const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
108
+ const RESET = '\x1b[0m';
109
+
110
+ /** A field of dot intensities. 0 = off, 1 = the settled Q, > 1 = a flash (pushes towards white). */
111
+ export function makeField(W, H) {
112
+ const v = new Float32Array(W * H);
113
+ return {
114
+ W, H, v,
115
+ set(x, y, val) {
116
+ x = Math.round(x); y = Math.round(y);
117
+ if (x < 0 || y < 0 || x >= W || y >= H) return;
118
+ const i = y * W + x; if (val > v[i]) v[i] = val;
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Turn a field into rows of cells { ch, rgb }. rgb is null for blanks and in mono.
125
+ * color false (mono): shapes only, dim dots (< 0.35) are dropped, as in the lab.
126
+ * quant true (terminal default): 4 hue bands x 6 brightness levels, so SGR runs stay long (Ink tokenising
127
+ * a per-cell colour was ~100 ms a frame); trails keep their own levels so the drag still reads.
128
+ * quant false (web): the lab's continuous shade, every cell its own colour.
129
+ * tint: one rgb for every cell instead of the sky to violet gradient (amber, red, ...).
130
+ */
131
+ export function fieldToCells(field, { color = true, quant = true, tint = null } = {}) {
132
+ const { W, H, v } = field;
133
+ const thr = color ? 0.04 : 0.35;
134
+ const cols = W / 2, rows = H / 4, out = [];
135
+ for (let cy = 0; cy < rows; cy++) {
136
+ const row = [];
137
+ for (let cx = 0; cx < cols; cx++) {
138
+ let bits = 0, m = 0;
139
+ for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) {
140
+ const val = v[(cy * 4 + dy) * W + cx * 2 + dx];
141
+ if (val > thr) { bits |= BR[dy][dx]; if (val > m) m = val; }
142
+ }
143
+ if (!bits) { row.push({ ch: ' ', rgb: null }); continue; }
144
+ let rgb = null;
145
+ if (color) {
146
+ if (quant) {
147
+ const base = tint || lerp(SKY, VIOLET, (Math.min(3, Math.floor((cx / cols) * 4)) + 0.5) / 4);
148
+ const [lum, fl] = m > 1.5 ? [1, 0.85] : m > 1.05 ? [1, 0.45] : m >= 0.95 ? [1, 0] : m > 0.6 ? [0.72, 0] : m > 0.38 ? [0.48, 0] : [0.3, 0];
149
+ rgb = base.map((c) => Math.round(c * lum + (255 - c * lum) * fl));
150
+ } else {
151
+ const base = tint || lerp(SKY, VIOLET, cols > 1 ? cx / (cols - 1) : 0);
152
+ const f = 0.28 + 0.72 * Math.min(1, m), fl = clamp(m - 1);
153
+ rgb = base.map((c) => { c *= f; return Math.round(c + (255 - c) * fl * 0.9); });
154
+ }
155
+ }
156
+ row.push({ ch: String.fromCharCode(0x2800 + bits), rgb });
157
+ }
158
+ out.push(row);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ const sgr = (rgb) => `\x1b[38;2;${rgb.map((x) => Math.max(0, Math.min(255, Math.round(x)))).join(';')}m`;
164
+ /** Cells -> ANSI lines (truecolor SGR, one escape per colour run). Mono cells carry no SGR at all. */
165
+ export function cellsToAnsi(rows) {
166
+ return rows.map((row) => {
167
+ let s = '', last = '';
168
+ for (const c of row) {
169
+ if (c.rgb) { const e = sgr(c.rgb); if (e !== last) { s += e; last = e; } }
170
+ else if (last && c.ch !== ' ') { s += RESET; last = ''; }
171
+ s += c.ch;
172
+ }
173
+ return s + (last ? RESET : '');
174
+ });
175
+ }
176
+ /** Cells -> plain text lines. */
177
+ export const cellsToText = (rows) => rows.map((r) => r.map((c) => c.ch).join(''));
178
+
179
+ const rng = (seed) => () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
180
+
181
+ // ---- the settled Q ------------------------------------------------------------------------------------
182
+ /** Cells of the settled Q. */
183
+ export function qCells(size, opts = {}) {
184
+ const m = qMask(size), f = makeField(m.W, m.H);
185
+ for (const d of m.dots) f.v[d.y * m.W + d.x] = 1;
186
+ return fieldToCells(f, opts);
187
+ }
188
+ /** Rows of text for the settled Q (identical to orbitFrame(size, 1)). */
189
+ export function qLines(size, { color = true } = {}) { return cellsToAnsi(qCells(size, { color })); }
190
+
191
+ // ---- 1: Orbit & Lock (and 3: Particle collapse) ---------------------------------------------------------
192
+ /** The intro's whole budget, ms. Orbit runs ORBIT_MS of it; the rest holds the finished mark. */
193
+ export const INTRO_MS = 900;
194
+ export const ORBIT_MS = 820;
195
+ const FLASH_MS = 170;
196
+ // Lab parameters, verbatim (lab.mjs build()): orbit for large/mid, particle for the compact Q.
197
+ const SWARM = {
198
+ orbit: { band: 0.3, appearMax: 0.08, lockStart: 0.42, lockSpan: 0.2, lockLen: 0.2, byAngle: true, spin: [0.5, 5] },
199
+ particle: { band: 0.05, appearMax: 0.5, lockStart: 0.55, lockSpan: 0.12, lockLen: 0.2, byAngle: false, spin: [1.2, 2.5] },
200
+ };
201
+
202
+ const swarmCache = new Map();
203
+ function swarm(size, kind, seed) {
204
+ const key = `${size}:${kind}:${seed}`;
205
+ if (swarmCache.has(key)) return swarmCache.get(key);
206
+ const P = SWARM[kind], m = qMask(size), R = rng(seed + m.H);
207
+ const cx = m.W / 2, cy = m.H / 2, Rmax = Math.min(m.W, m.H) / 2 - 1.5;
208
+ const parts = m.dots.map((d) => ({
209
+ tx: d.x, ty: d.y, part: d.part, pt: d.t, ang: Math.atan2(d.y - cy, d.x - cx), th0: R() * TAU,
210
+ rho: Rmax - Rmax * P.band * R() ** 2, w: 0.85 + 0.3 * R(), ap: R() * P.appearMax, rnd: R(),
211
+ }));
212
+ const key2 = (p) => (P.byAngle ? p.ang : p.rnd);
213
+ [...parts].sort((a, b) => key2(a) - key2(b)).forEach((p, i) => { p.s = P.lockStart + P.lockSpan * (i / (parts.length - 1)); });
214
+ // Tail sweep: the tail is the last stroke to land, base to tip, so the Q finishes with a flourish.
215
+ // Horns land tip-last, so each horn grows out of the bowl with its flash travelling up to the point.
216
+ const end = P.lockStart + P.lockSpan;
217
+ for (const p of parts) {
218
+ if (p.part === 'tail') p.s = end - 0.06 + 0.08 * p.pt;
219
+ else if (p.part === 'horn') p.s = Math.min(p.s, end - 0.1) + 0.05 * p.pt;
220
+ }
221
+ const r = { P, parts, cx, cy };
222
+ swarmCache.set(key, r);
223
+ return r;
224
+ }
225
+
226
+ /**
227
+ * The swarm field at t ms. Each particle draws a head plus two trail samples that lag along its path; the
228
+ * lag grows with speed (0.09 * (1 + 2u) rad), which is the "drag" in the lab: fast particles smear.
229
+ */
230
+ function swarmField(size, t, { kind = 'orbit', dur = ORBIT_MS, seed = 11 } = {}) {
231
+ const m = qMask(size), { P, parts, cx, cy } = swarm(size, kind, seed), f = makeField(m.W, m.H);
232
+ const u = t / dur, settled = t > dur + FLASH_MS + 300;
233
+ const ph = TAU * (P.spin[0] * u + P.spin[1] * u * u);
234
+ for (const p of parts) {
235
+ const e = settled ? 1 : smooth((u - p.s) / P.lockLen);
236
+ if (e >= 1) {
237
+ const lt = (u - (p.s + P.lockLen)) * dur;
238
+ f.set(p.tx, p.ty, settled ? 1 : 1 + Math.max(0, 1 - lt / FLASH_MS) * 0.9);
239
+ continue;
240
+ }
241
+ if (u < p.ap) continue;
242
+ const fade = Math.min(1, ((u - p.ap) * dur) / 90);
243
+ for (let k = 0; k < 3; k++) {
244
+ const th = p.th0 + (ph - k * 0.09 * (1 + 2 * u)) * p.w;
245
+ const x = cx + p.rho * Math.cos(th), y = cy + p.rho * Math.sin(th);
246
+ f.set(x + (p.tx - x) * e, y + (p.ty - y) * e, (k === 0 ? 0.9 : k === 1 ? 0.5 : 0.28) * fade + (e > 0 && k === 0 ? 0.4 * e : 0));
247
+ }
248
+ }
249
+ return f;
250
+ }
251
+
252
+ /** Orbit & Lock cells at t ms (0..INTRO_MS; >= INTRO_MS is the settled Q). small plays the particle collapse. */
253
+ export function orbitCellsAt(size, t, opts = {}) {
254
+ if (t >= INTRO_MS) return qCells(size, opts);
255
+ const kind = size === 'small' ? 'particle' : 'orbit';
256
+ return fieldToCells(swarmField(size, t, { kind, dur: kind === 'particle' ? INTRO_MS - 60 : ORBIT_MS, seed: opts.seed ?? 11 }), opts);
257
+ }
258
+
259
+ /**
260
+ * One frame of Orbit & Lock at progress 0..1 of INTRO_MS (lab timing: 820 ms orbit, all locks done by
261
+ * 672 ms, the last flashes fade by ~840 ms). progress >= 1 is exactly the settled Q.
262
+ */
263
+ export function orbitFrame(size, progress, { color = true } = {}) {
264
+ if (progress >= 1) return qLines(size, { color });
265
+ return cellsToAnsi(orbitCellsAt(size, progress * INTRO_MS, { color }));
266
+ }
267
+
268
+ /** Particle collapse at t ms of `dur` (the lab's compact variant, any size). */
269
+ export function particleCellsAt(size, t, opts = {}) {
270
+ const dur = opts.dur ?? 1100;
271
+ if (t >= dur + FLASH_MS + 300) return qCells(size, opts);
272
+ return fieldToCells(swarmField(size, t, { kind: 'particle', dur, seed: opts.seed ?? 12 }), opts);
273
+ }
274
+
275
+ // ---- 2: Portal -------------------------------------------------------------------------------------------
276
+ export const PORTAL_MS = 1000;
277
+ const portalCache = new Map();
278
+ function portalModel(size) {
279
+ if (portalCache.has(size)) return portalCache.get(size);
280
+ const m = qMask(size), H = m.H;
281
+ const ccx = m.ox + BOWL.cx * H, ccy = BOWL.cy * H, ringR = 0.3 * H, revR = 0.42 * H;
282
+ const phiAt = (u) => (u < 0.3 ? (Math.PI / 2) * smooth(u / 0.3) : u < 0.7 ? Math.PI / 2 + (Math.PI / 2) * smooth((u - 0.3) / 0.4) : Math.PI);
283
+ const pts = m.dots.map((d) => {
284
+ // The tail sweeps in last, base to tip (lab: rt 0.74 .. 0.94 along the tail).
285
+ if (d.part === 'tail') return { ...d, rt: 0.74 + 0.2 * d.t };
286
+ let rt = 0.74 + 0.1 * (d.y / H);
287
+ for (let u = 0.3; u <= 0.7; u += 0.004) {
288
+ const w = Math.max(0.02, Math.abs(Math.cos(phiAt(u))));
289
+ if (((d.x - ccx) / (revR * w)) ** 2 + ((d.y - ccy) / revR) ** 2 <= 1) { rt = u; break; }
290
+ }
291
+ return { ...d, rt };
292
+ });
293
+ const r = { m, ccx, ccy, ringR, phiAt, pts };
294
+ portalCache.set(size, r);
295
+ return r;
296
+ }
297
+
298
+ /** Portal at t ms: a ring spins in 3D (circle, edge-on line, open), the Q fills from the opening, tail last. */
299
+ export function portalCellsAt(size, t, opts = {}) {
300
+ const { m, ccx, ccy, ringR, phiAt, pts } = portalModel(size), f = makeField(m.W, m.H);
301
+ const dur = PORTAL_MS, u = t / dur, settled = t > dur + 500, phi = phiAt(Math.min(u, 1));
302
+ if (settled) return qCells(size, opts);
303
+ const alpha = Math.min(1, u / 0.1) * (1 - clamp((u - 0.68) / 0.12));
304
+ if (alpha > 0) {
305
+ const w = Math.cos(phi), n = Math.round(ringR * 24);
306
+ for (let i = 0; i < n; i++) {
307
+ const th = (i / n) * TAU, z = Math.cos(th) * Math.sin(phi);
308
+ f.set(ccx + ringR * Math.cos(th) * w, ccy + ringR * Math.sin(th), alpha * (0.62 + 0.38 * z));
309
+ }
310
+ if (Math.abs(w) < 0.12) for (let yy = -ringR; yy <= ringR; yy++) f.set(ccx, ccy + yy, alpha * 1.15);
311
+ }
312
+ for (const p of pts) if (u >= p.rt) f.set(p.x, p.y, 1 + Math.max(0, 1 - ((u - p.rt) * dur) / 150) * 0.7);
313
+ return fieldToCells(f, opts);
314
+ }
315
+
316
+ // ---- 5: Dragon's Eye -------------------------------------------------------------------------------------
317
+ export const EYE_MS = 2800;
318
+ /** Lid openness 0..1 over the eye's progress u: open, hold, one blink, hold, close (lab timings). */
319
+ export function eyeOpenAt(u) {
320
+ if (u < 0.12) return 0;
321
+ if (u < 0.34) return smooth((u - 0.12) / 0.22);
322
+ if (u < 0.5) return 1;
323
+ if (u < 0.58) return 1 - smooth((u - 0.5) / 0.08);
324
+ if (u < 0.68) return smooth((u - 0.58) / 0.1);
325
+ if (u < 0.86) return 1;
326
+ if (u < 0.96) return 1 - smooth((u - 0.86) / 0.1);
327
+ return 0;
328
+ }
329
+
330
+ /**
331
+ * The Dragon's Eye at t ms: a slit-pupil eye opens in the bowl, blinks once, and dissolves back into the Q
332
+ * with a flash. The Q dims while the eye is open so the eye reads. t >= EYE_MS + 400 is the settled Q.
333
+ */
334
+ export function eyeCellsAt(size, t, opts = {}) {
335
+ if (t >= EYE_MS + 400 || t < 0) return qCells(size, opts);
336
+ const m = qMask(size), H = m.H, f = makeField(m.W, m.H), u = t / EYE_MS, k = eyeOpenAt(u), big = H >= 40;
337
+ const ecx = m.ox + BOWL.cx * H, ecy = 0.51 * H, ew = 0.2 * H * ASPECT + 0.5, eh = 0.155 * H;
338
+ const back = u > 0.94 ? 1 + (1 - clamp((u - 0.94) / 0.06)) * 0.8 : 1;
339
+ const qv = u < 0.12 ? 0.55 + 0.45 * smooth(u / 0.12) : 0.75 + 0.25 * (1 - k);
340
+ for (const d of m.dots) f.set(d.x, d.y, back > 1 ? back : (k > 0.02 && d.part === 'tail' && d.t < 0.4 ? qv * 0.6 : qv));
341
+ if (k > 0.02) {
342
+ for (let dx = -ew; dx <= ew + 1e-6; dx += 0.5) {
343
+ const hh = k * eh * Math.sqrt(Math.max(0, 1 - (dx / ew) ** 2));
344
+ f.set(ecx + dx, ecy - hh, 1.05); f.set(ecx + dx, ecy + hh, 1.05);
345
+ if (big && hh > 1.2 && Math.abs(dx) < ew * 0.8) for (let yy = -hh + 1; yy < hh; yy += 1) f.set(ecx + dx, ecy + yy, 0.18);
346
+ }
347
+ const dil = 0.8 + 0.25 * Math.sin(u * 14), pw = (big ? H / 36 : 0.6) * dil;
348
+ for (let dx = -pw; dx <= pw; dx++) for (let yy = -k * eh * 0.92; yy <= k * eh * 0.92; yy += 0.5) f.set(ecx + dx, ecy + yy, 1.7);
349
+ if (big) { f.set(ecx - ew, ecy, 1.05); f.set(ecx + ew, ecy, 1.05); }
350
+ }
351
+ return fieldToCells(f, opts);
352
+ }
353
+
354
+ // ---- resting --------------------------------------------------------------------------------------------
355
+ export const BREATH_MS = 2400;
356
+ /** The settled Q breathing gently (idle on the web face): brightness 0.82 .. 1 over BREATH_MS. */
357
+ export function restCellsAt(size, t, opts = {}) {
358
+ const m = qMask(size), f = makeField(m.W, m.H);
359
+ const g = 0.82 + 0.18 * (0.5 + 0.5 * Math.sin((t / BREATH_MS) * TAU - Math.PI / 2));
360
+ for (const d of m.dots) f.v[d.y * m.W + d.x] = g;
361
+ return fieldToCells(f, opts);
362
+ }
363
+
364
+ // ---- intro plan ------------------------------------------------------------------------------------------
365
+ /**
366
+ * The intro as one timeline: Orbit & Lock, then (when the launch egg fires or the cinematic level asks) the
367
+ * Dragon's Eye on the settled Q. Returns { ms, at(t) -> cells }. Callers drive t from their own clock.
368
+ */
369
+ export function introPlan(size, { eye = false, ...opts } = {}) {
370
+ const ms = INTRO_MS + (eye ? EYE_MS + 400 : 0);
371
+ return {
372
+ ms,
373
+ at(t) {
374
+ if (t < INTRO_MS) return orbitCellsAt(size, t, opts);
375
+ if (eye) return eyeCellsAt(size, t - INTRO_MS, opts);
376
+ return qCells(size, opts);
377
+ },
378
+ };
379
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The perpetual Q: the status glyph that carries state (CLI-QUOX, lab variant "perpetual").
3
+ *
4
+ * ONE fixed shape, a two-cell Braille Q (4 x 4 dots: bowl, counter, tail). It never swaps frames; all of
5
+ * its life is smooth colour (owner, 2026-10-12: "our icons could animate the colour but stay the same icon.
6
+ * Smoother."). Colours are interpolated in OKLab so transitions stay even to the eye:
7
+ * resting the Q gradient (sky to violet across the two cells), slow breathing brightness (2.4 s)
8
+ * working sky > indigo > violet > teal flowing through the cells (1.6 s a cycle)
9
+ * waiting amber, slow pulse (1.2 s)
10
+ * failing a red flash (from `since`, 600 ms), then steady red
11
+ * Meant to be sampled at ~12 fps from the shared motion clock. depth '256' steps the colour less often
12
+ * (every 250 ms) and snaps it to the xterm 256 palette, so a 256-colour terminal does not shimmer.
13
+ * Under mono (NO_COLOR) or where Braille does not render two cells wide, it falls back to Ⓠ.
14
+ *
15
+ * PURE AND PORTABLE: no Ink, no process, no timers. Output cells { ch, rgb } plus the joined text and a
16
+ * representative colour, so the terminal (Ink, raw ANSI) and the web face (web/qface.js) draw the same.
17
+ */
18
+
19
+ export const GLYPH_STATES = ['resting', 'working', 'waiting', 'failing'];
20
+ export const FALLBACK = 'Ⓠ';
21
+ const SKY = [56, 189, 248], INDIGO = [99, 102, 241], VIOLET = [168, 85, 247], TEAL = [6, 182, 212];
22
+ const AMBER = [245, 158, 11], RED = [239, 68, 68];
23
+ const TAU = Math.PI * 2;
24
+ const wave = (t, period) => 0.5 + 0.5 * Math.sin((t / period) * TAU - Math.PI / 2); // 0 at t=0, 1 at half
25
+
26
+ // ---- OKLab (Björn Ottosson), for perceptually even blends --------------------------------------------
27
+ const toLin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
28
+ const toSrgb = (c) => Math.round(255 * Math.max(0, Math.min(1, c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)));
29
+ export function oklab([r, g, b]) {
30
+ const [R, G, B] = [toLin(r), toLin(g), toLin(b)];
31
+ const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
32
+ const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
33
+ const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
34
+ return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
35
+ }
36
+ export function fromOklab([L, a, b]) {
37
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3, m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3, s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
38
+ return [toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)];
39
+ }
40
+ /** Blend two sRGB colours in OKLab. */
41
+ export const mixOk = (a, b, t) => { const A = oklab(a), B = oklab(b); return fromOklab(A.map((v, i) => v + (B[i] - v) * t)); };
42
+ /** Scale lightness in OKLab (k < 1 darker), keeping hue. */
43
+ const dimOk = (rgb, k) => { const [L, a, b] = oklab(rgb); return fromOklab([L * k, a * k, b * k]); };
44
+ /** A point on a closed OKLab gradient through `stops` at phase p (0..1, wraps). */
45
+ export function cycleOk(stops, p) {
46
+ const n = stops.length, x = (((p % 1) + 1) % 1) * n, i = Math.floor(x);
47
+ const t = x - i, e = t * t * (3 - 2 * t); // eased between stops: no visible kinks
48
+ return mixOk(stops[i], stops[(i + 1) % n], e);
49
+ }
50
+
51
+ // ---- the fixed shape ----------------------------------------------------------------------------------
52
+ // .##.
53
+ // #..#
54
+ // #.##
55
+ // .###
56
+ export const DOTS = ['.##.', '#..#', '#.##', '.###'];
57
+ const BR = [[0x1, 0x8], [0x2, 0x10], [0x4, 0x20], [0x40, 0x80]];
58
+ export const SHAPE = [0, 1].map((cx) => {
59
+ let bits = 0;
60
+ for (let y = 0; y < 4; y++) for (let dx = 0; dx < 2; dx++) if (DOTS[y][cx * 2 + dx] === '#') bits |= BR[y][dx];
61
+ return String.fromCharCode(0x2800 + bits);
62
+ }).join('');
63
+
64
+ export const BREATH_MS = 2400, FLOW_MS = 1600, PULSE_MS = 1200, FLASH_MS = 600, STEP_256_MS = 250;
65
+ const FLOW = [SKY, INDIGO, VIOLET, TEAL];
66
+
67
+ /** The two cell colours for a state at t ms. since: when the state began (failing flashes from there). */
68
+ export function glyphColors(state, t = 0, { quiet = false, since = null } = {}) {
69
+ switch (state) {
70
+ case 'working': {
71
+ // The gradient flows left to right through the two cells: cell 1 is a quarter cycle behind cell 0.
72
+ const p = quiet ? 0.1 : t / FLOW_MS;
73
+ return [cycleOk(FLOW, p), cycleOk(FLOW, p - 0.25)];
74
+ }
75
+ case 'waiting': {
76
+ const k = quiet ? 1 : 0.45 + 0.55 * wave(t, PULSE_MS);
77
+ return [dimOk(AMBER, k), dimOk(AMBER, k)];
78
+ }
79
+ case 'failing': {
80
+ const dt = since == null || quiet ? Infinity : t - since;
81
+ const f = dt >= 0 && dt < FLASH_MS ? 1 - dt / FLASH_MS : 0; // the flash: red lifted towards white, easing out
82
+ const c = f > 0 ? mixOk(RED, [255, 236, 236], f * f) : RED;
83
+ return [c, c];
84
+ }
85
+ default: {
86
+ const k = quiet ? 0.9 : 0.62 + 0.38 * wave(t, BREATH_MS);
87
+ return [dimOk(SKY, k), dimOk(mixOk(SKY, VIOLET, 0.85), k)];
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Nearest xterm 256-colour cube entry, returned as rgb (so callers keep one colour type). */
93
+ export function to256(rgb) {
94
+ const lv = [0, 95, 135, 175, 215, 255];
95
+ const near = (v) => lv.reduce((a, x) => (Math.abs(x - v) < Math.abs(a - v) ? x : a), 0);
96
+ return rgb.map(near);
97
+ }
98
+
99
+ /**
100
+ * Does this terminal draw the Braille glyph? Pure: pass the env and, if you have one, a width measurer
101
+ * (string-width). Braille is skipped under NO_COLOR (mono), TERM=dumb or linux (the VT console font has no
102
+ * Braille), QUOX_GLYPH=circled, or when two Braille cells measure wider than 2.
103
+ */
104
+ export function glyphMode(env = {}, measure = null) {
105
+ if (env.NO_COLOR || env.TERM === 'dumb' || env.TERM === 'linux' || env.QUOX_GLYPH === 'circled') return 'circled';
106
+ if (measure && measure('⣿⣿') !== 2) return 'circled';
107
+ return 'braille';
108
+ }
109
+
110
+ /** Colour depth from the env: 'truecolor' | '256' | '16'. Pure. */
111
+ export function colorDepth(env = {}) {
112
+ if (/truecolor|24bit/i.test(env.COLORTERM || '')) return 'truecolor';
113
+ if (/256/.test(env.TERM || '')) return '256';
114
+ return env.TERM ? '16' : 'truecolor';
115
+ }
116
+
117
+ /**
118
+ * One frame of the status glyph.
119
+ * state resting | working | waiting | failing
120
+ * t ms (any monotonic clock); since: ms when the state began (for the failing flash)
121
+ * opts { mono, quiet (reduced motion: still colour), mode: 'braille' | 'circled', depth: 'truecolor' | '256' }
122
+ * Returns { text, cells: [{ ch, rgb }], rgb, width }. The text is always the same for a mode: only colour moves.
123
+ */
124
+ export function qGlyph(state, t = 0, { mono = false, quiet = false, mode = 'braille', depth = 'truecolor', since = null } = {}) {
125
+ const st = GLYPH_STATES.includes(state) ? state : 'working';
126
+ const tt = depth === '256' ? Math.floor(t / STEP_256_MS) * STEP_256_MS : t;
127
+ let cols = mono ? [null, null] : glyphColors(st, tt, { quiet, since });
128
+ if (depth === '256' && !mono) cols = cols.map(to256);
129
+ if (mono || mode !== 'braille') {
130
+ const rgb = mono ? null : cols[0];
131
+ return { text: FALLBACK, cells: [{ ch: FALLBACK, rgb }], rgb, width: 1 };
132
+ }
133
+ const cells = [...SHAPE].map((ch, i) => ({ ch, rgb: cols[i] }));
134
+ return { text: SHAPE, cells, rgb: cols[0], width: 2 };
135
+ }
136
+
137
+ /** The settled glyph (no motion): what a static surface shows. */
138
+ export const glyphStill = (state = 'resting', opts = {}) => qGlyph(state, 0, { ...opts, quiet: true });
139
+
140
+ /** Truecolor ANSI for a frame (raw terminal use: gallery). */
141
+ export function glyphAnsi(g) {
142
+ return g.cells.map((c) => (c.rgb ? `\x1b[38;2;${c.rgb.join(';')}m${c.ch}` : c.ch)).join('') + (g.cells.some((c) => c.rgb) ? '\x1b[0m' : '');
143
+ }