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,50 @@
1
+ /**
2
+ * /config settings, saved on the CLI config profile (profile.quoxon = { theme, motion, verbose }), so a
3
+ * choice survives restarts and follows `-p`. Theme replaces the env-only QUOX_THEME: the saved value is
4
+ * applied by setting the variable the theme module already reads, unless the user's shell set it, in
5
+ * which case the shell wins and the card says so.
6
+ */
7
+ import { PROFILE_IDS } from '../sound/mapping.js';
8
+
9
+ // Captured at import, before anything here writes it: what the user's shell set.
10
+ const SHELL = { theme: process.env.QUOX_THEME || null, motion: process.env.QUOX_REDUCED_MOTION || null };
11
+
12
+
13
+ /** The settings this build can honour. Sound is the /sound controller's own profile (server-side soundProfile). */
14
+ export function settingsList({ sound = true } = {}) {
15
+ return [
16
+ { id: 'theme', label: 'Theme', values: ['auto', 'dark', 'light'], def: 'auto', help: 'auto follows your terminal background' },
17
+ { id: 'motion', label: 'Motion', values: ['full', 'reduced'], def: 'full', help: 'reduced stills the animations' },
18
+ ...(sound ? [{ id: 'sound', label: 'Sound', values: PROFILE_IDS, def: 'minimal', help: 'same as /sound; saved to your account' }] : []),
19
+ { id: 'verbose', label: 'Verbose', values: ['off', 'on', 'full'], def: 'on', help: 'protocol lines in turns' },
20
+ { id: 'model', label: 'Model', action: 'model', help: 'Enter opens /model' }
21
+ ];
22
+ }
23
+
24
+ /** Apply saved look settings to this process. -> { themeFromShell, motionFromShell }. */
25
+ export function applySettings(s = {}, env = process.env, shell = SHELL) {
26
+ if (!shell.theme) {
27
+ if (s.theme === 'dark' || s.theme === 'light') env.QUOX_THEME = s.theme; else delete env.QUOX_THEME;
28
+ }
29
+ if (!shell.motion) {
30
+ if (s.motion === 'reduced') env.QUOX_REDUCED_MOTION = '1'; else delete env.QUOX_REDUCED_MOTION;
31
+ }
32
+ return { themeFromShell: Boolean(shell.theme), motionFromShell: Boolean(shell.motion) };
33
+ }
34
+
35
+ export const shellOverrides = () => ({ ...SHELL });
36
+
37
+ /** The card rows. `values` = current settings, `extra` = { model: label, verbose: current }. */
38
+ export function configRows(list, values, { shell = SHELL, model = 'auto' } = {}) {
39
+ return list.map(it => {
40
+ if (it.action === 'model') return { key: it.label, text: model, note: it.help };
41
+ const v = values[it.id] ?? it.def;
42
+ const forced = (it.id === 'theme' && shell.theme) ? `QUOX_THEME=${shell.theme} in your shell wins` : (it.id === 'motion' && shell.motion) ? 'QUOX_REDUCED_MOTION in your shell wins' : null;
43
+ return { key: it.label, text: it.values.map(x => (x === v ? `[${x}]` : x)).join(' '), note: forced || it.help };
44
+ });
45
+ }
46
+
47
+ export const cycle = (it, cur, dir = 1) => {
48
+ const i = it.values.indexOf(cur ?? it.def);
49
+ return it.values[(i + dir + it.values.length) % it.values.length];
50
+ };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * QuoxSound in the terminal: the controller the app calls from its single
3
+ * dispatch point. Decides (mapping.js), gates (profile, rate limit, mode) and
4
+ * plays (synthesised WAV through the OS player, or the terminal bell).
5
+ *
6
+ * const snd = createSound({ api, stdout });
7
+ * snd.observe(prevState, nextState, action) // after every reduce
8
+ * snd.focus(true|false) // from CSI I / CSI O (keys.js)
9
+ * await snd.command(arg, say) // /sound
10
+ *
11
+ * Never throws, never blocks: players are spawned detached with a kill timer.
12
+ * The setting is the dashboard's server-side `soundProfile` preference
13
+ * (PATCH /api/auth/me), so dashboard and terminal agree; the active CLI
14
+ * profile keeps a local copy for offline starts and as the fallback store.
15
+ */
16
+ import { spawn as nodeSpawn } from 'node:child_process';
17
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, writeFileSync } from 'node:fs';
18
+ import { homedir, tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { soundForEvent, createLimiter, normalizeSettings, allowed, PROFILE_IDS, PROFILE_LABELS } from './mapping.js';
21
+ import { soundMode, findPlayer, BEL, BEL_IDS } from './output.js';
22
+ import { earconWav } from './synth.js';
23
+
24
+ export const FOCUS_ON = '\x1b[?1004h';
25
+ export const FOCUS_OFF = '\x1b[?1004l';
26
+ export const PLAYER_TIMEOUT_MS = 5000;
27
+
28
+ const cap = (s) => s[0].toUpperCase() + s.slice(1);
29
+ const USAGE = 'Usage: /sound [Silent | Minimal | Operational | Full | test]';
30
+
31
+ function offReason({ env, stdout, print }) {
32
+ if (print) return '--print never makes sound';
33
+ if (!stdout?.isTTY) return 'not a terminal';
34
+ if (String(env.QUOX_SOUND ?? '').length && soundMode({ env: { QUOX_SOUND: env.QUOX_SOUND }, stdout: { isTTY: true } }) === 'off') return `QUOX_SOUND=${env.QUOX_SOUND}`;
35
+ return 'CI';
36
+ }
37
+
38
+ function defaultCacheDir(env) {
39
+ const base = env.XDG_CACHE_HOME || join(homedir(), '.cache');
40
+ const dir = join(base, 'quox', 'sound');
41
+ try { mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; } catch { return mkdtempSync(join(tmpdir(), 'quox-sound-')); }
42
+ }
43
+
44
+ export function createSound({
45
+ api = null, stdout = null, env = process.env, print = false, platform = process.platform,
46
+ has, spawn = nodeSpawn, now = Date.now, cacheDir = null, exitHook = (fn) => process.once('exit', fn),
47
+ timeoutMs = PLAYER_TIMEOUT_MS
48
+ } = {}) {
49
+ const mode = soundMode({ env, stdout, print });
50
+ const limiter = createLimiter();
51
+ const localProfile = safe(() => api?.localSoundProfile?.()) || null;
52
+ let settings = normalizeSettings({ profile: localProfile || undefined });
53
+ let source = localProfile ? 'local' : 'default';
54
+ let userSet = false;
55
+ let focused = null;
56
+ let player; // undefined = not looked up yet
57
+ let dir = cacheDir;
58
+ let focusOn = false;
59
+ let serverError = null;
60
+
61
+ function safe(fn) { try { return fn(); } catch { return null; } }
62
+ const write = (s) => { try { stdout?.write?.(s); } catch { /* never throw */ } };
63
+
64
+ // Server preference (source of truth), loaded in the background; a /sound typed meanwhile wins.
65
+ const ready = (async () => {
66
+ if (mode === 'off' || !api?.soundSettings) return;
67
+ try {
68
+ const v = await api.soundSettings();
69
+ if (v && !userSet) { settings = normalizeSettings(v); source = 'account'; safe(() => api.setLocalSoundProfile?.(settings.profile)); }
70
+ } catch (e) { serverError = e?.message || 'unreachable'; }
71
+ })();
72
+
73
+ if (mode !== 'off') {
74
+ write(FOCUS_ON);
75
+ focusOn = true;
76
+ safe(() => exitHook(dispose));
77
+ }
78
+
79
+ function getPlayer() {
80
+ if (player === undefined) player = safe(() => findPlayer({ platform, ...(has ? { has } : {}) })) || null;
81
+ return player;
82
+ }
83
+
84
+ function wavFile(id) {
85
+ if (!dir) dir = defaultCacheDir(env);
86
+ const vol = Math.round(settings.volume * 100);
87
+ const file = join(dir, `quox-${id}-v${vol}.wav`);
88
+ if (!existsSync(file)) {
89
+ const tmp = `${file}.${process.pid}.tmp`;
90
+ writeFileSync(tmp, earconWav(id, { volume: settings.volume }));
91
+ renameSync(tmp, file);
92
+ }
93
+ return file;
94
+ }
95
+
96
+ /** Emit `id` now (gates already passed). Returns how it went out: 'audio' | 'bel' | null. */
97
+ function emit(id) {
98
+ if (mode === 'off') return null;
99
+ const p = mode === 'audio' ? getPlayer() : null;
100
+ if (!p) {
101
+ if (!BEL_IDS.has(id)) return null;
102
+ write(BEL);
103
+ return 'bel';
104
+ }
105
+ try {
106
+ const file = wavFile(id);
107
+ const child = spawn(p.cmd, p.args(file), { detached: true, stdio: 'ignore', windowsHide: true });
108
+ child.on?.('error', () => {});
109
+ child.unref?.();
110
+ const t = setTimeout(() => { try { child.kill?.(); } catch { /* gone */ } }, timeoutMs);
111
+ t.unref?.();
112
+ return 'audio';
113
+ } catch {
114
+ if (BEL_IDS.has(id)) { write(BEL); return 'bel'; }
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function observe(prev, next, action) {
120
+ if (mode === 'off') return null;
121
+ try {
122
+ const id = soundForEvent(prev, action, { next, now: action?.now ?? now(), focused });
123
+ if (!id || !allowed(id, settings) || !limiter.take(id, now())) return null;
124
+ emit(id);
125
+ return id;
126
+ } catch { return null; }
127
+ }
128
+
129
+ function outputLine() {
130
+ if (mode === 'off') return `Output: off (${offReason({ env, stdout, print })}).`;
131
+ if (mode === 'bel') return 'Output: terminal bell, approvals and critical alerts only (over SSH the speaker is on your side).';
132
+ const p = getPlayer();
133
+ return p ? `Output: speaker via ${p.cmd}.` : 'Output: terminal bell, approvals and critical alerts only (no audio player found).';
134
+ }
135
+
136
+ function whereLine() {
137
+ if (source === 'account') return 'Saved to your account; the dashboard uses the same setting.';
138
+ if (source === 'local') return `Saved on this machine only${serverError ? ` (account setting unreachable: ${serverError})` : ''}.`;
139
+ return `Default${serverError ? ` (account setting unreachable: ${serverError})` : ''}.`;
140
+ }
141
+
142
+ async function command(arg, say) {
143
+ const a = String(arg || '').trim().toLowerCase();
144
+ if (!a) {
145
+ say(`Sound: ${cap(settings.profile)} (${PROFILE_LABELS[settings.profile]}). ${whereLine()}`);
146
+ say(outputLine());
147
+ say('Change with /sound silent|minimal|operational|full · /sound test plays the approval chime · QUOX_SOUND=off mutes this terminal.');
148
+ return;
149
+ }
150
+ if (a === 'test') {
151
+ if (mode === 'off') return say(outputLine(), 'warn');
152
+ const how = emit('hitl');
153
+ return say(how === 'audio' ? `Played the approval chime via ${getPlayer().cmd}.` : how === 'bel' ? 'Rang the terminal bell.' : 'Nothing to play on.', how ? undefined : 'warn');
154
+ }
155
+ if (!PROFILE_IDS.includes(a)) return say(USAGE, 'warn');
156
+ userSet = true;
157
+ settings = normalizeSettings({ ...settings, profile: a });
158
+ safe(() => api?.setLocalSoundProfile?.(a));
159
+ try {
160
+ if (!api?.setSoundSettings) throw new Error('not available in this mode');
161
+ await api.setSoundSettings(settings);
162
+ source = 'account';
163
+ serverError = null;
164
+ } catch (e) {
165
+ serverError = e?.message || 'failed';
166
+ source = api?.setLocalSoundProfile ? 'local' : 'session';
167
+ }
168
+ const where = source === 'account' ? 'Saved to your account (the dashboard uses the same setting).'
169
+ : source === 'local' ? `Saved on this machine only: the account setting could not be written (${serverError}).`
170
+ : `For this session only: ${serverError}.`;
171
+ say(`Sound: ${cap(a)} (${PROFILE_LABELS[a]}). ${where}`, source === 'account' ? 'ok' : 'warn');
172
+ }
173
+
174
+ function dispose() {
175
+ if (!focusOn) return;
176
+ focusOn = false;
177
+ write(FOCUS_OFF);
178
+ }
179
+
180
+ return {
181
+ mode, ready, observe, command, dispose, emit,
182
+ focus(v) { focused = Boolean(v); },
183
+ settings: () => ({ ...settings }),
184
+ get focused() { return focused; }
185
+ };
186
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * QuoxSound for the terminal: pure event -> earcon mapping, profiles and the
3
+ * rate limiter. Mirrors the dashboard's design (src/services/quoxSound.js):
4
+ * sound is STATE, not personality; the more common the event, the quieter;
5
+ * `hitl` is reserved for "a human must act". No sound for typing, text,
6
+ * routine tool rows or anything the human just did themselves.
7
+ *
8
+ * Pure: no I/O, no clocks (time comes in on ctx.now / the action).
9
+ */
10
+
11
+ export const SOUND_IDS = ['hitl', 'notify', 'success', 'denied', 'critical'];
12
+
13
+ /** Higher wins when several sounds land in one burst. */
14
+ export const PRIORITY = { critical: 5, hitl: 4, denied: 3, success: 2, notify: 1 };
15
+
16
+ /** Same layer split as the dashboard, so its per-layer toggles apply here too. */
17
+ export const SOUND_LAYERS = { hitl: 'notifications', notify: 'notifications', success: 'agents', denied: 'security', critical: 'security' };
18
+ export const LAYER_IDS = ['ui', 'agents', 'notifications', 'security'];
19
+
20
+ /**
21
+ * Terminal profiles. Same names and setting as the dashboard. Minimal here
22
+ * also carries failures (owner brief 2026-10-12: "hitl + critical + failure"),
23
+ * because a failed turn in a terminal you are not watching is otherwise silent.
24
+ */
25
+ export const PROFILES = {
26
+ silent: [],
27
+ minimal: ['hitl', 'critical', 'denied'],
28
+ operational: ['hitl', 'critical', 'denied', 'success', 'notify'],
29
+ full: ['hitl', 'critical', 'denied', 'success', 'notify']
30
+ };
31
+ export const PROFILE_IDS = ['silent', 'minimal', 'operational', 'full'];
32
+ export const PROFILE_LABELS = {
33
+ silent: 'nothing',
34
+ minimal: 'approvals, critical alerts, failures',
35
+ operational: 'approvals, critical alerts, failures, long turns finishing',
36
+ full: 'approvals, critical alerts, failures, long turns finishing'
37
+ };
38
+ export const DEFAULT_PROFILE = 'minimal';
39
+
40
+ export const LONG_TURN_MS = 20000;
41
+ export const RATE_LIMIT_MS = 2000;
42
+ /** Sounds this close together are one burst: only a more urgent one may follow. */
43
+ export const BURST_MS = 300;
44
+
45
+ const DEFAULT_LAYERS = Object.freeze({ ui: true, agents: true, notifications: true, security: true });
46
+
47
+ /** Same shape as the dashboard's soundProfile preference: { profile, volume, layers }. */
48
+ export function normalizeSettings(raw) {
49
+ const src = raw && typeof raw === 'object' ? raw : {};
50
+ const profile = PROFILE_IDS.includes(src.profile) ? src.profile : DEFAULT_PROFILE;
51
+ const v = Number(src.volume);
52
+ const volume = Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.6;
53
+ const layers = { ...DEFAULT_LAYERS };
54
+ if (src.layers && typeof src.layers === 'object') {
55
+ for (const id of LAYER_IDS) if (typeof src.layers[id] === 'boolean') layers[id] = src.layers[id];
56
+ }
57
+ return { profile, volume, layers };
58
+ }
59
+
60
+ /** May `id` play under these settings? */
61
+ export function allowed(id, settings) {
62
+ const s = normalizeSettings(settings);
63
+ if (!SOUND_IDS.includes(id) || s.volume <= 0) return false;
64
+ if (!PROFILES[s.profile].includes(id)) return false;
65
+ return s.layers[SOUND_LAYERS[id]] !== false;
66
+ }
67
+
68
+ const FAILED_TURN = new Set(['failed', 'unconfirmed']);
69
+
70
+ /**
71
+ * prevState + the dispatched action (+ ctx.next, the reduced state) -> sound id or null.
72
+ * ctx: { next, now, focused } where focused is true | false | null (unknown = treated as looking).
73
+ * Only state CHANGES the reducer accepted make sound: a duplicate approval or a repeated tool
74
+ * finish leaves the state alone and stays silent.
75
+ */
76
+ export function soundForEvent(prev, action, ctx = {}) {
77
+ const next = ctx.next || prev;
78
+ const now = ctx.now ?? action?.now ?? 0;
79
+ if (!prev || !action) return null;
80
+ // An approval card appeared (stream event or inbox poll).
81
+ if (next.approvals?.length > (prev.approvals?.length || 0)) return 'hitl';
82
+ if (action.type === 'approval.result') return action.outcome === 'failed' ? 'denied' : null;
83
+ if (action.type !== 'event' || !action.ev) return null;
84
+ const ev = action.ev;
85
+ switch (ev.type) {
86
+ case 'alert':
87
+ return ev.severity === 'critical' ? 'critical' : null;
88
+ case 'tool.finished': {
89
+ if (ev.status !== 'failed' && ev.status !== 'denied') return null;
90
+ return prev.doneCalls?.[ev.call_id] ? null : 'denied';
91
+ }
92
+ case 'turn.finished': {
93
+ if (!prev.turn) return null; // dedupe: no running turn
94
+ const status = ev.status || 'completed';
95
+ if (FAILED_TURN.has(status)) return 'denied';
96
+ if (status !== 'completed') return null; // interrupted: the human pressed Esc
97
+ const took = prev.turn.startedAt != null ? now - prev.turn.startedAt : 0;
98
+ return took > LONG_TURN_MS || ctx.focused === false ? 'success' : null;
99
+ }
100
+ default:
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** At most one sound per type per RATE_LIMIT_MS; inside a burst only a more urgent sound plays. */
106
+ export function createLimiter({ windowMs = RATE_LIMIT_MS, burstMs = BURST_MS } = {}) {
107
+ const last = new Map();
108
+ let lastAny = null;
109
+ return {
110
+ take(id, now) {
111
+ const t = last.get(id);
112
+ if (t != null && now - t < windowMs) return false;
113
+ if (lastAny && now - lastAny.at < burstMs && PRIORITY[id] <= PRIORITY[lastAny.id]) return false;
114
+ last.set(id, now);
115
+ lastAny = { id, at: now };
116
+ return true;
117
+ }
118
+ };
119
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Where a terminal earcon goes: a local OS player, the terminal bell, or
3
+ * nowhere. Pure decisions here; the controller (index.js) does the I/O.
4
+ */
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ const OFF_VALUES = new Set(['off', '0', 'false', 'no', 'silent']);
8
+
9
+ /**
10
+ * 'off' : --print, non-TTY, CI, or QUOX_SOUND=off. Never a sound, never an escape write.
11
+ * 'bel' : over SSH the speaker is on the other end, so only the terminal bell can reach it.
12
+ * 'audio' : a local desktop; play the WAV (falls back to the bell when no player exists).
13
+ */
14
+ export function soundMode({ env = {}, stdout, print = false } = {}) {
15
+ if (print || !stdout?.isTTY) return 'off';
16
+ if (env.CI && !OFF_VALUES.has(String(env.CI).toLowerCase())) return 'off';
17
+ if (OFF_VALUES.has(String(env.QUOX_SOUND ?? '').toLowerCase())) return 'off';
18
+ if (env.SSH_CONNECTION || env.SSH_TTY) return 'bel';
19
+ return 'audio';
20
+ }
21
+
22
+ const psQuote = (s) => `'${String(s).replace(/'/g, "''")}'`;
23
+
24
+ const PLAYERS = {
25
+ linux: [
26
+ { cmd: 'pw-play', args: (f) => [f] },
27
+ { cmd: 'paplay', args: (f) => [f] },
28
+ { cmd: 'aplay', args: (f) => ['-q', f] }
29
+ ],
30
+ darwin: [{ cmd: 'afplay', args: (f) => [f] }],
31
+ win32: [{ cmd: 'powershell', args: (f) => ['-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer(${psQuote(f)})).PlaySync()`] }]
32
+ };
33
+
34
+ /** Is `cmd` on PATH? (one lookup per player, cached by the controller) */
35
+ export function onPath(cmd, platform = process.platform) {
36
+ try {
37
+ const r = platform === 'win32'
38
+ ? spawnSync('where', [cmd], { stdio: 'ignore', timeout: 2000 })
39
+ : spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore', timeout: 2000 });
40
+ return r.status === 0;
41
+ } catch { return false; }
42
+ }
43
+
44
+ /** First available player for this platform, or null. */
45
+ export function findPlayer({ platform = process.platform, has = (c) => onPath(c, platform) } = {}) {
46
+ const list = PLAYERS[platform] || PLAYERS.linux;
47
+ return list.find(p => has(p.cmd)) || null;
48
+ }
49
+
50
+ /** Only these may ring the bell: the bell is for a human, not for feedback. */
51
+ export const BEL_IDS = new Set(['hitl', 'critical']);
52
+ export const BEL = '\x07';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Offline renderer for the QuoxSound earcons: the dashboard's Web Audio
3
+ * recipes (src/services/quoxSound.js RECIPES) rendered sample by sample into
4
+ * 16-bit mono PCM WAV. No assets, no dependencies.
5
+ *
6
+ * Timbre: fundamental + octave (0.18) + twelfth (0.07), linear attack then an
7
+ * exponential decay to near-silence, same as the phone earcons. `success`
8
+ * reuses the granted interval (587 -> 880 Hz), `denied` the denied floor
9
+ * (233 Hz gliding down through a 600 Hz lowpass).
10
+ */
11
+
12
+ export const SAMPLE_RATE = 44100;
13
+ const HARMONICS = [[1, 1], [2, 0.18], [3, 0.07]];
14
+ const FLOOR = 0.0001;
15
+
16
+ /** The recipes as data: the same notes, times and envelopes as the dashboard. */
17
+ export const RECIPES = {
18
+ hitl: { notes: [
19
+ { freq: 523.25, at: 0, decay: 0.30, peak: 0.20 },
20
+ { freq: 659.25, at: 0.15, decay: 0.30, peak: 0.19 },
21
+ { freq: 880.0, at: 0.30, decay: 0.32, peak: 0.18 }
22
+ ] },
23
+ notify: { notes: [
24
+ { freq: 698.46, at: 0, attack: 0.01, decay: 0.10, peak: 0.11 },
25
+ { freq: 932.33, at: 0.10, attack: 0.01, decay: 0.14, peak: 0.10 }
26
+ ] },
27
+ success: { notes: [
28
+ { freq: 587.33, at: 0, attack: 0.003, decay: 0.04, peak: 0.09 },
29
+ { freq: 880.0, at: 0.045, attack: 0.003, decay: 0.07, peak: 0.09 }
30
+ ] },
31
+ denied: { lowpass: 600, notes: [
32
+ { freq: 233.08, glideTo: 150, at: 0, attack: 0.004, decay: 0.16, peak: 0.28, harmonics: [[1, 1]] }
33
+ ] },
34
+ critical: { lowpass: 2400, notes: [0, 1, 2].map(i => (
35
+ { freq: 622.25, at: i * 0.12, type: 'square', attack: 0.003, decay: 0.07, peak: 0.12, harmonics: [[1, 1]] }
36
+ )) }
37
+ };
38
+
39
+ function envelope(t, attack, decay, peak) {
40
+ if (t < 0) return 0;
41
+ if (t < attack) return FLOOR + (peak - FLOOR) * (t / attack);
42
+ const d = t - attack;
43
+ if (d < decay) return peak * Math.pow(FLOOR / peak, d / decay);
44
+ return 0;
45
+ }
46
+
47
+ function renderNote(out, sr, n) {
48
+ const attack = n.attack ?? 0.008;
49
+ const decay = n.decay ?? 0.2;
50
+ const peak = n.peak ?? 0.2;
51
+ const harmonics = n.harmonics || HARMONICS;
52
+ const len = attack + decay + 0.02;
53
+ const start = Math.round(n.at * sr);
54
+ const count = Math.min(out.length - start, Math.round(len * sr));
55
+ for (const [mult, level] of harmonics) {
56
+ let phase = 0;
57
+ for (let i = 0; i < count; i++) {
58
+ const t = i / sr;
59
+ // exponential glide over attack + decay, then held (as exponentialRampToValueAtTime)
60
+ const f = n.glideTo ? n.freq * Math.pow(n.glideTo / n.freq, Math.min(1, t / (attack + decay))) : n.freq;
61
+ phase += (2 * Math.PI * f * mult) / sr;
62
+ const osc = n.type === 'square' ? (Math.sin(phase) >= 0 ? 1 : -1) : Math.sin(phase);
63
+ out[start + i] += osc * level * envelope(t, attack, decay, peak);
64
+ }
65
+ }
66
+ }
67
+
68
+ /** RBJ biquad lowpass, in place. */
69
+ function lowpass(buf, sr, cutoff, q = Math.SQRT1_2) {
70
+ const w0 = (2 * Math.PI * cutoff) / sr;
71
+ const alpha = Math.sin(w0) / (2 * q);
72
+ const cos = Math.cos(w0);
73
+ const a0 = 1 + alpha;
74
+ const b0 = (1 - cos) / 2 / a0, b1 = (1 - cos) / a0, b2 = (1 - cos) / 2 / a0;
75
+ const a1 = (-2 * cos) / a0, a2 = (1 - alpha) / a0;
76
+ let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
77
+ for (let i = 0; i < buf.length; i++) {
78
+ const x = buf[i];
79
+ const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
80
+ x2 = x1; x1 = x; y2 = y1; y1 = y;
81
+ buf[i] = y;
82
+ }
83
+ }
84
+
85
+ /** Render one earcon to float samples in [-1, 1]. */
86
+ export function renderEarcon(id, { volume = 0.6, sampleRate = SAMPLE_RATE } = {}) {
87
+ const r = RECIPES[id];
88
+ if (!r) throw new Error(`unknown earcon ${id}`);
89
+ const secs = Math.max(...r.notes.map(n => n.at + (n.attack ?? 0.008) + (n.decay ?? 0.2) + 0.02));
90
+ const out = new Float32Array(Math.ceil(secs * sampleRate));
91
+ for (const n of r.notes) renderNote(out, sampleRate, n);
92
+ if (r.lowpass) lowpass(out, sampleRate, r.lowpass);
93
+ const g = Math.min(1, Math.max(0, volume));
94
+ for (let i = 0; i < out.length; i++) out[i] = Math.max(-1, Math.min(1, out[i] * g));
95
+ return out;
96
+ }
97
+
98
+ /** Float samples -> 16-bit mono PCM WAV (44-byte RIFF header). */
99
+ export function toWav(samples, sampleRate = SAMPLE_RATE) {
100
+ const data = samples.length * 2;
101
+ const buf = Buffer.alloc(44 + data);
102
+ buf.write('RIFF', 0, 'ascii');
103
+ buf.writeUInt32LE(36 + data, 4);
104
+ buf.write('WAVE', 8, 'ascii');
105
+ buf.write('fmt ', 12, 'ascii');
106
+ buf.writeUInt32LE(16, 16); // fmt chunk size
107
+ buf.writeUInt16LE(1, 20); // PCM
108
+ buf.writeUInt16LE(1, 22); // mono
109
+ buf.writeUInt32LE(sampleRate, 24);
110
+ buf.writeUInt32LE(sampleRate * 2, 28); // byte rate
111
+ buf.writeUInt16LE(2, 32); // block align
112
+ buf.writeUInt16LE(16, 34); // bits per sample
113
+ buf.write('data', 36, 'ascii');
114
+ buf.writeUInt32LE(data, 40);
115
+ for (let i = 0; i < samples.length; i++) {
116
+ const v = Math.max(-1, Math.min(1, samples[i]));
117
+ buf.writeInt16LE(Math.round(v < 0 ? v * 0x8000 : v * 0x7fff), 44 + i * 2);
118
+ }
119
+ return buf;
120
+ }
121
+
122
+ export const earconWav = (id, opts = {}) => toWav(renderEarcon(id, opts), opts.sampleRate ?? SAMPLE_RATE);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The terminal as Ink sees it.
3
+ *
4
+ * Ink 6.8 has a bookkeeping defect on its "frame was as tall as the screen" path (ink.js onRender): it
5
+ * repaints with clearTerminal + all static output + the frame WITHOUT a trailing newline, then records the
6
+ * frame WITH one (log.sync(outputToRender)). log-update then believes the cursor is one row lower than it
7
+ * is, so its next erase removes one extra row: the last line of whatever <Static> printed last. On
8
+ * 2026-10-12 that silently deleted "status writeup that's supposed to accompany completion." from a LUCID
9
+ * answer at 64x34 (test/quoxon/dropped.test.jsx replays it).
10
+ *
11
+ * Fix: Ink is told the screen has unlimited rows, so it never takes that path (only the fullscreen and
12
+ * repaint checks read stdout.rows). The app reads the real height from termRows() and keeps its live
13
+ * region shorter than the screen itself (app.js), so log-update can always erase it.
14
+ */
15
+ export function inkStdout(out) {
16
+ if (!out || out.__quoxInk) return out;
17
+ const diff = out.isTTY ? createLineDiff() : null;
18
+ // One frame = one write: Ink brackets each frame in separate synchronized-update writes (?2026h ... ?2026l);
19
+ // they are held and sent together with the (diffed) frame, and a frame that changed nothing sends nothing.
20
+ // A write that sends nothing (or is held for the frame) must still call its callback: Ink's unmount waits
21
+ // on write('', cb) before waitUntilExit() resolves, and a dropped callback let the event loop drain, so
22
+ // the process exited without running anything after the app (/restart never started the new client).
23
+ let held = null;
24
+ let waiting = [];
25
+ const settle = () => { const cbs = waiting; waiting = []; for (const cb of cbs) cb(); };
26
+ const flush = () => {
27
+ if (held == null) return;
28
+ const body = held; held = null;
29
+ if (body) out.write(SYNC_ON + body + SYNC_OFF, settle); else if (waiting.length) out.write('', settle);
30
+ };
31
+ const write = diff ? (chunk, ...rest) => {
32
+ if (typeof chunk !== 'string') { flush(); diff.reset(); return out.write(chunk, ...rest); }
33
+ const cb = rest.find(a => typeof a === 'function');
34
+ if (chunk === SYNC_ON) { flush(); held = ''; setImmediate(flush); if (cb) waiting.push(cb); return true; }
35
+ if (chunk === SYNC_OFF && held != null) { if (cb) waiting.push(cb); flush(); return true; }
36
+ const d = diff(chunk);
37
+ if (held != null) { held += d; if (cb) waiting.push(cb); return true; }
38
+ if (d) return out.write(d, ...rest);
39
+ if (cb) out.write('', cb); // nothing to send: still a barrier behind the earlier writes
40
+ return true;
41
+ } : null;
42
+ return new Proxy(out, {
43
+ get(target, prop) {
44
+ if (prop === 'write' && write) return write;
45
+ if (prop === 'rows') return Infinity;
46
+ if (prop === 'termRows') return target.rows;
47
+ if (prop === '__quoxInk') return true;
48
+ const v = Reflect.get(target, prop, target);
49
+ return typeof v === 'function' ? v.bind(target) : v;
50
+ },
51
+ set(target, prop, value) { return Reflect.set(target, prop, value, target); }
52
+ });
53
+ }
54
+
55
+ /** Real terminal height, or null when unknown (not a TTY, tests). */
56
+ export function termRows(stdout) {
57
+ const r = stdout?.__quoxInk ? stdout.termRows : stdout?.rows;
58
+ return Number.isFinite(r) && r > 0 ? r : null;
59
+ }
60
+
61
+ /**
62
+ * Line-diffed live frames (so native text selection survives animation).
63
+ *
64
+ * Ink repaints its live region as ONE write: eraseLines(previous line count) followed by the whole frame. A 12 fps
65
+ * ring or activity word would then rewrite the input line, and the user's selection on it, every frame. This
66
+ * transform recognises exactly that shape; when the new frame has the same height as the last one it moves up
67
+ * and rewrites only the lines that changed, leaving the cursor where Ink expects it (start of the line after the
68
+ * frame). Anything else (static history, clears, a height change, a resize, a write it did not see) passes
69
+ * through untouched and resets the diff, so the next frame is drawn in full. Cursor moves never scroll.
70
+ * (Ink's own incrementalRendering was tried first and duplicates lines on the dropped-text replay.)
71
+ */
72
+ const FRAME_RE = /^((?:\x1b\[2K\x1b\[1A)*\x1b\[2K\x1b\[G)([\s\S]*)$/;
73
+ const SYNC_ON = '\x1b[?2026h', SYNC_OFF = '\x1b[?2026l';
74
+ const TRANSPARENT = new Set(['\x1b[?2026h', '\x1b[?2026l', '\x1b[?25l', '\x1b[?25h', '']);
75
+ export function createLineDiff() {
76
+ let prev = null; // lines of the last frame, trailing '' included
77
+ const diff = (chunk) => {
78
+ if (TRANSPARENT.has(chunk)) return chunk; // synchronized-update brackets and cursor hide/show around a frame
79
+ const m = FRAME_RE.exec(chunk);
80
+ if (!m || !m[2].endsWith('\n') || m[2].includes('\x1b[2J')) { prev = null; return chunk; }
81
+ const erased = (m[1].match(/\x1b\[2K/g) || []).length;
82
+ const next = m[2].split('\n');
83
+ if (!prev || erased !== prev.length || next.length !== prev.length || next.length < 2) { prev = next; return chunk; }
84
+ const body = next.length - 1;
85
+ let out = body > 1 ? `\x1b[${body}A` : (body === 1 ? '\x1b[1A' : '');
86
+ let changed = 0;
87
+ for (let i = 0; i < body; i++) {
88
+ if (next[i] !== prev[i]) { out += `\r${next[i]}\x1b[K`; changed++; }
89
+ out += '\x1b[1B';
90
+ }
91
+ out += '\r';
92
+ prev = next;
93
+ return changed ? out : '';
94
+ };
95
+ diff.reset = () => { prev = null; };
96
+ return diff;
97
+ }
98
+
99
+ /**
100
+ * Ink render options for the interactive client. Standard rendering (Ink's incrementalRendering duplicated lines on
101
+ * the dropped-text replay); the stdout proxy line-diffs same-height frames instead. Never mouse tracking, never the
102
+ * alternate screen: native selection keeps working.
103
+ */
104
+ export function inkRenderOptions(stdout) {
105
+ return { exitOnCtrlC: false, stdout: inkStdout(stdout) };
106
+ }