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,256 @@
1
+ /**
2
+ * Systemd USER unit installer for the resident supervisor, Option A
3
+ * persistence (DEVSUITE-DEVMODE follow-on, quox#453 phase 15 follow-on).
4
+ *
5
+ * A resident's `quox dev supervisor run` proved out one-shot passes; this
6
+ * module is the delivery half: a `Restart=always` systemd --user unit so the
7
+ * runner answers its slot channel with no session open and comes back after
8
+ * a crash, a logout, or a reboot (the last of those needs `loginctl
9
+ * enable-linger`, see installSupervisorService below).
10
+ *
11
+ * GOVERNANCE NOTE: installing this unit is deliberately NOT the autonomy
12
+ * grant. The unit only ever answers a message when the human-gated
13
+ * server-side switch (`quox dev supervisor enable`, requireHumanAdmin on the
14
+ * collector) is on, see supervisorEngine.js's kill-switch check in
15
+ * runPollOnce. A resident may install its own service on its own host (the
16
+ * scoped self-service pattern), while a human still decides whether it may
17
+ * act. Installing the unit and being allowed to act are two different
18
+ * switches on purpose.
19
+ *
20
+ * Every systemctl/loginctl call goes through one injectable `execFn` seam
21
+ * (see realExecFn below) so this is fully testable without touching the
22
+ * real system: tests pass a fake execFn and never spawn systemctl.
23
+ */
24
+
25
+ import { execFile } from 'node:child_process';
26
+ import { promisify } from 'node:util';
27
+ import { homedir } from 'node:os';
28
+ import { join } from 'node:path';
29
+
30
+ const execFileAsync = promisify(execFile);
31
+
32
+ export const SERVICE_NAME = 'quox-resident-supervisor.service';
33
+ export const DEFAULT_RESTART_SEC = 10;
34
+
35
+ // Item 2 (found by the acceptance owner driving this on a real host,
36
+ // 2026-08-23): `systemctl --user enable --now` reporting success only
37
+ // proves systemd ACCEPTED the unit, not that it stayed up. A unit whose
38
+ // ExecStart dies immediately (the item 1 slot bug is exactly this) is
39
+ // already crash-looping under Restart=always by the time enable returns.
40
+ // Give it a few seconds, then read the live state back through the same
41
+ // injectable execFn seam before calling install a success.
42
+ export const DEFAULT_VERIFY_DELAY_MS = 3000;
43
+
44
+ export function defaultUnitDir() {
45
+ return join(homedir(), '.config', 'systemd', 'user');
46
+ }
47
+
48
+ export function defaultUnitPath() {
49
+ return join(defaultUnitDir(), SERVICE_NAME);
50
+ }
51
+
52
+ /**
53
+ * Build the unit file content. Pure string building, no I/O, so it is
54
+ * trivially snapshot-testable.
55
+ *
56
+ * `env`: extra `Environment=KEY=VALUE` lines, baked in at install time
57
+ * (item 1, live outage 2026-08-23). A systemd unit inherits none of the
58
+ * caller's shell environment, so this is how a resident's reach-back URLs
59
+ * (QUOX_URL/QUOX_AUTH_URL/QUOX_COLLECTOR_URL/QUOX_INSECURE) survive into a
60
+ * unit that runs with no login shell present. A value containing a newline
61
+ * or a quote would break the unit file's own line-based format, or the
62
+ * shell-like word-splitting systemd applies to `Environment=`, so it is
63
+ * refused outright rather than escaped: an unrenderable value is a defect
64
+ * in the caller, not something this function should paper over.
65
+ */
66
+ export function buildUnitFile({ quoxBin, extraArgs = [], restartSec = DEFAULT_RESTART_SEC, home = homedir(), env = {} } = {}) {
67
+ const bin = quoxBin || 'quox';
68
+ const argsSuffix = extraArgs.length ? ` ${extraArgs.join(' ')}` : '';
69
+ const envLines = Object.entries(env || {}).map(([key, value]) => {
70
+ const v = String(value);
71
+ if (/[\n"']/.test(v)) {
72
+ throw new Error(
73
+ `buildUnitFile: refusing to bake ${key} into the unit: its value contains a newline or a quote character `
74
+ + `(value withheld from this message). Fix the source value rather than escaping it.`
75
+ );
76
+ }
77
+ return `Environment=${key}=${v}`;
78
+ });
79
+ return [
80
+ '[Unit]',
81
+ 'Description=Quox resident supervisor (answers the slot channel headlessly, no session open)',
82
+ 'After=network-online.target',
83
+ 'Wants=network-online.target',
84
+ '',
85
+ '[Service]',
86
+ 'Type=simple',
87
+ `ExecStart=${bin} dev supervisor run --service${argsSuffix}`,
88
+ 'Restart=always',
89
+ `RestartSec=${restartSec}`,
90
+ `Environment=HOME=${home}`,
91
+ ...envLines,
92
+ '',
93
+ '[Install]',
94
+ 'WantedBy=default.target',
95
+ '',
96
+ ].join('\n');
97
+ }
98
+
99
+ /**
100
+ * Real systemctl/loginctl runner (the non-test default). Never throws for a
101
+ * nonzero exit: callers read `.ok`/`.stderr`, so a failed `enable-linger`
102
+ * (e.g. no permission) is data, not a crash.
103
+ */
104
+ export async function realExecFn(cmd, args) {
105
+ return execFileAsync(cmd, args, { timeout: 15000 });
106
+ }
107
+
108
+ /** Parses `systemctl --user show <unit> -p A,B,C` stdout ("KEY=value" per line) into an object. */
109
+ function parseSystemctlShow(stdout) {
110
+ const props = {};
111
+ for (const line of String(stdout || '').split('\n')) {
112
+ const idx = line.indexOf('=');
113
+ if (idx === -1) continue;
114
+ props[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
115
+ }
116
+ return props;
117
+ }
118
+
119
+ async function runExec(execFn, cmd, args) {
120
+ try {
121
+ const res = await execFn(cmd, args);
122
+ return { ok: true, code: 0, stdout: (res && res.stdout) || '', stderr: (res && res.stderr) || '' };
123
+ } catch (err) {
124
+ return {
125
+ ok: false,
126
+ code: err && err.code != null ? err.code : 1,
127
+ stdout: (err && err.stdout) || '',
128
+ stderr: (err && (err.stderr || err.message)) || String(err),
129
+ };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Write the unit, reload the daemon, enable + start it now, and attempt
135
+ * linger. Every step's outcome is reported back rather than thrown, because
136
+ * a partial success (unit written, enabled, but linger refused) is the
137
+ * single most important case this reports honestly: the service DOES start
138
+ * now, but will not survive a reboot or logout until linger is granted.
139
+ */
140
+ export async function installSupervisorService({
141
+ execFn,
142
+ writeFileFn,
143
+ mkdirFn,
144
+ unitPath = defaultUnitPath(),
145
+ unitDir = defaultUnitDir(),
146
+ unitContent,
147
+ user,
148
+ // Injectable so tests never wait the real delay. Defaults to a real sleep
149
+ // outside tests, same seam discipline as execFn/writeFileFn/mkdirFn.
150
+ sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
151
+ verifyDelayMs = DEFAULT_VERIFY_DELAY_MS,
152
+ }) {
153
+ mkdirFn(unitDir, { recursive: true });
154
+ writeFileFn(unitPath, unitContent);
155
+
156
+ const reload = await runExec(execFn, 'systemctl', ['--user', 'daemon-reload']);
157
+ const enable = await runExec(execFn, 'systemctl', ['--user', 'enable', '--now', SERVICE_NAME]);
158
+ const linger = await runExec(execFn, 'loginctl', ['enable-linger', user]);
159
+
160
+ // Item 2: post-start verification. Only meaningful once `enable --now`
161
+ // itself reported success — if enable failed outright there is nothing
162
+ // running to crash-loop, and the enable_error above already says so.
163
+ let verified = false;
164
+ let active = false;
165
+ let restartCount = null;
166
+ let crashLoop = false;
167
+ let crashJournal = null;
168
+ if (enable.ok) {
169
+ await sleepFn(verifyDelayMs);
170
+ const show = await runExec(execFn, 'systemctl', ['--user', 'show', SERVICE_NAME, '-p', 'NRestarts,ActiveState,SubState']);
171
+ verified = true;
172
+ if (show.ok) {
173
+ const props = parseSystemctlShow(show.stdout);
174
+ active = props.ActiveState === 'active';
175
+ const n = Number(props.NRestarts);
176
+ restartCount = Number.isFinite(n) ? n : null;
177
+ // Restart=always means one crash silently reads as "started fine"
178
+ // unless something checks back: any restart already counted, or a
179
+ // state that isn't active, in our short observation window is a
180
+ // crash loop, not a success, however cheerfully `enable --now` read.
181
+ crashLoop = !active || (restartCount != null && restartCount > 0);
182
+ } else {
183
+ // Could not read the live state back at all: never claim success on
184
+ // silence — same "an error body is not data" discipline as the rest
185
+ // of this codebase's proof-before-claim rule.
186
+ crashLoop = true;
187
+ }
188
+ if (crashLoop) {
189
+ const journal = await runExec(execFn, 'journalctl', ['--user', '-u', SERVICE_NAME, '-n', '20', '--no-pager']);
190
+ crashJournal = journal.ok ? journal.stdout : null;
191
+ }
192
+ }
193
+
194
+ return {
195
+ unit_path: unitPath,
196
+ daemon_reloaded: reload.ok,
197
+ daemon_reload_error: reload.ok ? null : (reload.stderr || `exit ${reload.code}`),
198
+ enabled: enable.ok,
199
+ enable_error: enable.ok ? null : (enable.stderr || `exit ${enable.code}`),
200
+ linger_enabled: linger.ok,
201
+ linger_error: linger.ok ? null : (linger.stderr || `exit ${linger.code}`),
202
+ verified,
203
+ active,
204
+ restart_count: restartCount,
205
+ crash_loop: crashLoop,
206
+ crash_journal: crashJournal,
207
+ };
208
+ }
209
+
210
+ export async function uninstallSupervisorService({
211
+ execFn,
212
+ existsFn,
213
+ unlinkFn,
214
+ unitPath = defaultUnitPath(),
215
+ }) {
216
+ const disable = await runExec(execFn, 'systemctl', ['--user', 'disable', '--now', SERVICE_NAME]);
217
+
218
+ let removed = false;
219
+ if (existsFn(unitPath)) {
220
+ unlinkFn(unitPath);
221
+ removed = true;
222
+ }
223
+
224
+ const reload = await runExec(execFn, 'systemctl', ['--user', 'daemon-reload']);
225
+
226
+ return {
227
+ disabled: disable.ok,
228
+ disable_error: disable.ok ? null : (disable.stderr || `exit ${disable.code}`),
229
+ unit_removed: removed,
230
+ daemon_reloaded: reload.ok,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Read-only install/enable/active/linger report for `quox dev supervisor
236
+ * status`. Never throws: an unreadable systemctl/loginctl reports as
237
+ * false/unknown rather than crashing a status check.
238
+ */
239
+ export async function getServiceInstallStatus({
240
+ execFn,
241
+ existsFn,
242
+ unitPath = defaultUnitPath(),
243
+ user,
244
+ }) {
245
+ const installed = existsFn(unitPath);
246
+ const enabledRes = await runExec(execFn, 'systemctl', ['--user', 'is-enabled', SERVICE_NAME]);
247
+ const activeRes = await runExec(execFn, 'systemctl', ['--user', 'is-active', SERVICE_NAME]);
248
+ const lingerRes = await runExec(execFn, 'loginctl', ['show-user', user, '--property=Linger', '--value']);
249
+
250
+ return {
251
+ installed,
252
+ enabled: enabledRes.stdout.trim() === 'enabled',
253
+ active: activeRes.stdout.trim() === 'active',
254
+ linger: lingerRes.stdout.trim().toLowerCase() === 'yes',
255
+ };
256
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * CLI version + drift resolution — shared so `quox --version` (bin/quox.js)
3
+ * and `quox doctor` (commands/doctor.js) report the SAME facts and can never
4
+ * disagree about which build is running. Closes quoxai/quox#610.
5
+ *
6
+ * Every git call is fully guarded (stderr discarded, failure swallowed) so
7
+ * version/drift resolution can never break a command: a plain-package install
8
+ * with no .git, a missing git binary, or a detached worktree all degrade to a
9
+ * sensible fallback rather than throwing.
10
+ *
11
+ * Note on cost: bin/quox.js keeps its argv guard at the CALL SITE (it only
12
+ * calls resolveCliVersion() when --version/-V is actually present) so normal
13
+ * commands stay zero-cost. doctor calls both freely — the user explicitly ran
14
+ * a diagnostic, so spawning git is expected there.
15
+ */
16
+
17
+ import { execFileSync } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, resolve } from 'node:path';
20
+
21
+ export const BASE_VERSION = '0.2.0';
22
+
23
+ // The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
24
+ // levels up is the tree whose HEAD identifies the running build.
25
+ const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
26
+
27
+ function git(args) {
28
+ return execFileSync('git', ['-C', CLI_ROOT, ...args], {
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'ignore'],
31
+ }).trim();
32
+ }
33
+
34
+ /**
35
+ * Human version string: "0.1.0 (7bac4f67, session/vulcan)" — or bare
36
+ * "0.1.0" when git facts are unavailable. Unlike the drift check this never
37
+ * needs origin/main, so it works on a shallow or rem-less clone.
38
+ */
39
+ export function resolveCliVersion() {
40
+ try {
41
+ const sha = git(['rev-parse', '--short', 'HEAD']);
42
+ if (!sha) return BASE_VERSION;
43
+ let ref = '';
44
+ try { ref = git(['rev-parse', '--abbrev-ref', 'HEAD']); } catch { /* detached HEAD / no ref */ }
45
+ const label = ref && ref !== 'HEAD' ? ref : 'detached';
46
+ return `${BASE_VERSION} (${sha}, ${label})`;
47
+ } catch {
48
+ return BASE_VERSION;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Drift of the running worktree vs the LAST-FETCHED origin/main.
54
+ *
55
+ * Deliberately does NOT fetch: a health check must not hang on a slow remote
56
+ * or block on credentials. The count therefore reflects the last fetch, which
57
+ * the detail line states plainly. This mirrors scripts/quox-cli-refresh.sh's
58
+ * `git rev-list --count HEAD..origin/main` so doctor and the refresh tool agree
59
+ * (#607 shipped the refresh side).
60
+ *
61
+ * @returns {{status:'ok'|'stale'|'unknown'|'skip', detail:string}}
62
+ * ok — HEAD == origin/main
63
+ * stale — behind origin/main; nudge quox-cli-refresh
64
+ * unknown — no origin/main ref locally (never fetched here)
65
+ * skip — not a git worktree (plain-package install)
66
+ */
67
+ export function checkCliDrift() {
68
+ let head;
69
+ try {
70
+ head = git(['rev-parse', 'HEAD']);
71
+ } catch {
72
+ return { status: 'skip', detail: 'not a git worktree' };
73
+ }
74
+ let remote;
75
+ try {
76
+ remote = git(['rev-parse', 'origin/main']);
77
+ } catch {
78
+ return { status: 'unknown', detail: 'no origin/main ref (never fetched here)' };
79
+ }
80
+ if (head === remote) return { status: 'ok', detail: 'up to date with origin/main' };
81
+ let behind = '?';
82
+ try { behind = git(['rev-list', '--count', 'HEAD..origin/main']); } catch { /* keep ? */ }
83
+ return {
84
+ status: 'stale',
85
+ detail: `${behind} commit(s) behind origin/main (run quox-cli-refresh) — reflects last fetch`,
86
+ };
87
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Website admin session for owner-only licence verbs (GRIPE-715 phase 2).
3
+ *
4
+ * `quox licence reissue` and `quox licence catalogue-sign` talk to the quox.ai
5
+ * WEBSITE admin API, not to this instance, so they need their own credential,
6
+ * separate from the instance profile in config.json.
7
+ *
8
+ * What is stored: per website profile, the base URL, the admin username and
9
+ * the website session cookie (`quox.sid`, 24h). The password is used once for
10
+ * POST /api/admin/login and never written anywhere. The file is 0600. Nothing
11
+ * here prints the cookie.
12
+ *
13
+ * File: ~/.config/quox/website-sessions.json (QUOX_WEBSITE_SESSION_FILE overrides,
14
+ * used by tests).
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { QuoxError, QuoxValidationError } from './errors.js';
21
+
22
+ export const DEFAULT_WEBSITE_URL = 'https://quox.ai';
23
+ export const DEFAULT_WEBSITE_PROFILE = 'default';
24
+ const SESSION_COOKIE = 'quox.sid';
25
+
26
+ export function sessionFilePath() {
27
+ return process.env.QUOX_WEBSITE_SESSION_FILE || join(homedir(), '.config', 'quox', 'website-sessions.json');
28
+ }
29
+
30
+ function readStore(file = sessionFilePath()) {
31
+ if (!existsSync(file)) return { profiles: {} };
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
34
+ return parsed && typeof parsed.profiles === 'object' ? parsed : { profiles: {} };
35
+ } catch {
36
+ return { profiles: {} };
37
+ }
38
+ }
39
+
40
+ function writeStore(store, file = sessionFilePath()) {
41
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
42
+ const tmp = `${file}.${process.pid}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
44
+ chmodSync(tmp, 0o600);
45
+ renameSync(tmp, file);
46
+ }
47
+
48
+ export function normaliseWebsiteUrl(url) {
49
+ let u;
50
+ try { u = new URL(url); } catch { throw new QuoxValidationError(`Invalid website URL: ${url}`); }
51
+ const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
52
+ if (u.protocol !== 'https:' && !local) {
53
+ throw new QuoxValidationError('The website URL must be https (the session cookie is a credential); http is allowed only for localhost');
54
+ }
55
+ return `${u.protocol}//${u.host}`;
56
+ }
57
+
58
+ export function getWebsiteSession(profile = DEFAULT_WEBSITE_PROFILE) {
59
+ return readStore().profiles[profile] || null;
60
+ }
61
+
62
+ export function saveWebsiteSession(profile, session) {
63
+ const store = readStore();
64
+ store.profiles[profile] = session;
65
+ writeStore(store);
66
+ }
67
+
68
+ export function clearWebsiteSession(profile) {
69
+ const store = readStore();
70
+ const had = !!store.profiles[profile];
71
+ delete store.profiles[profile];
72
+ writeStore(store);
73
+ return had;
74
+ }
75
+
76
+ /** Pull `quox.sid=<value>` out of the Set-Cookie header(s). */
77
+ export function extractSessionCookie(headers) {
78
+ const list = typeof headers.getSetCookie === 'function'
79
+ ? headers.getSetCookie()
80
+ : [headers.get('set-cookie')].filter(Boolean);
81
+ for (const line of list) {
82
+ for (const part of String(line).split(/,(?=\s*[^;,=\s]+=)/)) {
83
+ const pair = part.trim().split(';')[0];
84
+ if (pair.startsWith(`${SESSION_COOKIE}=`)) return pair;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ async function readJson(res) {
91
+ const text = await res.text();
92
+ try { return text ? JSON.parse(text) : {}; } catch { return { error: text.slice(0, 200) }; }
93
+ }
94
+
95
+ /** Log in to the website admin API. Returns the session to save; never the password. */
96
+ export async function websiteLogin({ url, username, password, fetchImpl = globalThis.fetch }) {
97
+ const base = normaliseWebsiteUrl(url);
98
+ const res = await fetchImpl(`${base}/api/admin/login`, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
101
+ body: JSON.stringify({ username, password }),
102
+ redirect: 'manual',
103
+ });
104
+ const body = await readJson(res);
105
+ if (!res.ok) throw new QuoxError(`Website login failed (HTTP ${res.status}): ${body.error || 'no detail'}`, 3);
106
+ const cookie = extractSessionCookie(res.headers);
107
+ if (!cookie) throw new QuoxError('Website login returned no session cookie; nothing saved', 3);
108
+ return {
109
+ url: base,
110
+ username: body.username || username,
111
+ cookie,
112
+ must_change_password: !!body.mustChangePassword,
113
+ saved_at: new Date().toISOString(),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * POST to a website admin route with the saved session. Checks the status
119
+ * before trusting the body; a 401 means the 24h session lapsed.
120
+ */
121
+ export async function websitePost(profile, path, body, { fetchImpl = globalThis.fetch } = {}) {
122
+ const session = getWebsiteSession(profile);
123
+ if (!session?.cookie) {
124
+ throw new QuoxError(`No website session for profile "${profile}". Run: quox licence website-login${profile === DEFAULT_WEBSITE_PROFILE ? '' : ` --website-profile ${profile}`}`, 3);
125
+ }
126
+ let res;
127
+ try {
128
+ res = await fetchImpl(`${session.url}${path}`, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Cookie: session.cookie },
131
+ body: JSON.stringify(body || {}),
132
+ redirect: 'manual',
133
+ });
134
+ } catch (err) {
135
+ throw new QuoxError(`Could not reach the website admin API at ${session.url}: ${err.message}`, 7);
136
+ }
137
+ const data = await readJson(res);
138
+ if (res.status === 401) {
139
+ throw new QuoxError(`Website session expired or invalid (HTTP 401 from ${session.url}). Run: quox licence website-login`, 3);
140
+ }
141
+ if (!res.ok) {
142
+ const err = new QuoxError(`Website API ${path} failed (HTTP ${res.status}${data.code ? ` ${data.code}` : ''}): ${data.error || 'no detail'}`, res.status === 403 ? 4 : 1);
143
+ err.status = res.status;
144
+ err.body = data;
145
+ throw err;
146
+ }
147
+ return data;
148
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * LucidIntro: the ~1.2s startup for `quox lucid deck`.
3
+ *
4
+ * Frames (50ms): the QUOX block wordmark is revealed by a gradient sweep, the
5
+ * ◉ LUCID mark fades in under it, then "tuning in…" resolves into
6
+ * "◉ LUCID │ N sources live · M need you" once the first fetch has landed.
7
+ * The fetch is owned by the deck and runs in parallel, never gated on this.
8
+ * onDone fires once: at the natural end, on a hard 2.5s cap, or on a key
9
+ * (the deck calls skip via the `skipSignal` prop).
10
+ */
11
+ import React, { useState, useEffect, useRef } from 'react';
12
+ import { Box, Text } from 'ink';
13
+ import chalk from 'chalk';
14
+ import { BLOCK_FONT, gradientColor } from '../tui/intro/Intro.jsx';
15
+ import { PALETTE, STATE_HEX } from './fmt.js';
16
+
17
+ export const FRAME_MS = 50;
18
+ const SWEEP_FRAMES = 12;
19
+ const MIN_FRAMES = 24; // 1.2s
20
+ const MAX_FRAMES = 50; // 2.5s cap if the fetch never lands
21
+
22
+ const LETTERS = ['Q', 'U', 'O', 'X'];
23
+ const rgb = (c, s) => chalk.rgb(c[0], c[1], c[2])(s);
24
+
25
+ function titleRow(row, frame, colsRevealed) {
26
+ let out = ''; let col = 0;
27
+ const total = LETTERS.reduce((n, ch) => n + BLOCK_FONT[ch][0].length + 1, 0);
28
+ for (const ch of LETTERS) {
29
+ const g = BLOCK_FONT[ch][row];
30
+ for (let i = 0; i < g.length; i++) {
31
+ const x = col + i;
32
+ const lit = g[i] !== ' ';
33
+ if (x > colsRevealed) { out += ' '; continue; }
34
+ if (!lit) { out += ' '; continue; }
35
+ const edge = colsRevealed - x < 2 && colsRevealed < total;
36
+ out += edge ? chalk.white('██') : rgb(gradientColor(x / total + frame * 0.02), '██');
37
+ }
38
+ out += ' ';
39
+ col += g.length + 1;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export function LucidIntro({ data, err, onDone, rows = 24 }) {
45
+ const [frame, setFrame] = useState(0);
46
+ const doneRef = useRef(false);
47
+ const readyAt = useRef(null);
48
+ const sit = data && data.situation;
49
+
50
+ if (sit && readyAt.current == null) readyAt.current = frame;
51
+
52
+ useEffect(() => {
53
+ const id = setInterval(() => setFrame((f) => f + 1), FRAME_MS);
54
+ return () => clearInterval(id);
55
+ }, []);
56
+
57
+ const finishAt = readyAt.current != null
58
+ ? Math.max(MIN_FRAMES, readyAt.current + 6)
59
+ : (err ? Math.max(MIN_FRAMES, 16) : MAX_FRAMES);
60
+
61
+ useEffect(() => {
62
+ if (frame >= finishAt && !doneRef.current) { doneRef.current = true; onDone(); }
63
+ }, [frame, finishAt, onDone]);
64
+
65
+ const tall = rows >= 16;
66
+ const total = LETTERS.reduce((n, ch) => n + BLOCK_FONT[ch][0].length + 1, 0);
67
+ const revealed = Math.round((Math.min(frame, SWEEP_FRAMES) / SWEEP_FRAMES) * (total + 2)) - 1;
68
+ const markOn = frame >= 6;
69
+ const resolved = sit && frame >= 14 && frame >= (readyAt.current ?? 0) + 2;
70
+ const cov = (sit && sit.cannot_see && sit.cannot_see.coverage) || { live: 0, expected: 0 };
71
+ const dots = '.'.repeat((frame % 3) + 1).padEnd(3, ' ');
72
+
73
+ const mark = [...'◉ LUCID'].map((ch, i) => (
74
+ <Text key={i} bold color={'#' + gradientColor((i / 7) + frame * 0.03).map((v) => v.toString(16).padStart(2, '0')).join('')}>{ch}</Text>
75
+ ));
76
+
77
+ return (
78
+ <Box flexDirection="column" alignItems="center" justifyContent="center" flexGrow={1} paddingY={1}>
79
+ {tall ? (
80
+ <Box flexDirection="column" alignItems="center">
81
+ {Array.from({ length: 5 }, (_, r) => <Text key={r}>{titleRow(r, frame, revealed)}</Text>)}
82
+ </Box>
83
+ ) : (
84
+ <Text bold color={PALETTE.brand}>Q U O X</Text>
85
+ )}
86
+ <Box marginTop={1}>{markOn ? mark : <Text> </Text>}</Box>
87
+ <Box marginTop={1}>
88
+ {frame < 10 ? <Text> </Text> : resolved ? (
89
+ <Text>
90
+ <Text bold color={PALETTE.brand}>◉ LUCID</Text>
91
+ <Text color={PALETTE.dim}> │ </Text>
92
+ <Text color={STATE_HEX.ok}>{cov.live} sources live</Text>
93
+ <Text color={PALETTE.dim}> · </Text>
94
+ <Text bold color={(sit.needs_you || []).length ? STATE_HEX.attention : STATE_HEX.ok}>{(sit.needs_you || []).length} need you</Text>
95
+ </Text>
96
+ ) : err && !sit ? (
97
+ <Text color={STATE_HEX.blind}>LUCID unreachable, still trying</Text>
98
+ ) : (
99
+ <Text color={PALETTE.muted}>tuning in{dots}</Text>
100
+ )}
101
+ </Box>
102
+ <Box marginTop={1}><Text color={PALETTE.dim}>any key to skip</Text></Box>
103
+ </Box>
104
+ );
105
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `quox lucid blind`: what LUCID can and cannot hear, and what does not add up.
3
+ * Pure: renderBlind({ situation, feeds, expected }, { now }) -> string (plain text, no colour,
4
+ * so it pastes anywhere and reads the same in a pipe).
5
+ *
6
+ * A source is live, silent (expected but quiet), failing (speaking, but its own read is
7
+ * failing) or never heard. Contradictions list both sides of each mismatch.
8
+ */
9
+
10
+ function ago(ms, now) {
11
+ if (ms == null) return '';
12
+ const s = Math.max(0, Math.floor((now - ms) / 1000));
13
+ if (s < 60) return `${s}s ago`;
14
+ const m = Math.floor(s / 60);
15
+ if (m < 60) return `${m}m ago`;
16
+ const h = Math.floor(m / 60);
17
+ if (h < 24) return `${h}h ago`;
18
+ return `${Math.floor(h / 24)}d ago`;
19
+ }
20
+
21
+ const nameOf = (x) => (typeof x === 'string' ? x : (x && (x.feed || x.name)) || '');
22
+
23
+ /** Rows: { source, state, last, note } sorted worst first. */
24
+ export function blindRows({ situation, feeds = [], expected = [] } = {}, now = Date.now()) {
25
+ const cs = (situation && situation.cannot_see) || {};
26
+ const byFeed = new Map(feeds.map((f) => [f.feed, f]));
27
+ const failing = new Map((cs.failing || []).map((x) => [nameOf(x), x]));
28
+ const silent = new Set((cs.silent || []).map(nameOf));
29
+ const unwired = new Set((cs.unwired || []).map(nameOf));
30
+ const names = new Set([...byFeed.keys(), ...expected, ...failing.keys(), ...silent, ...unwired]);
31
+ const rows = [];
32
+ for (const source of names) {
33
+ if (!source) continue;
34
+ const f = byFeed.get(source);
35
+ const last = f && f.last_heard != null ? ago(f.last_heard, now) : '';
36
+ if (failing.has(source)) rows.push({ rank: 0, source, state: 'failing', last, note: (failing.get(source) || {}).error || (f && f.last_error) || '' });
37
+ else if (silent.has(source)) rows.push({ rank: 1, source, state: 'silent', last, note: 'expected, but gone quiet' });
38
+ else if (unwired.has(source) || !f) rows.push({ rank: 2, source, state: 'never heard', last: '', note: 'expected, has never reported' });
39
+ else rows.push({ rank: 3, source, state: 'live', last, note: '' });
40
+ }
41
+ return rows.sort((a, b) => a.rank - b.rank || a.source.localeCompare(b.source));
42
+ }
43
+
44
+ export function renderBlind(data = {}, { now = Date.now() } = {}) {
45
+ const sit = data.situation || {};
46
+ const cov = (sit.cannot_see && sit.cannot_see.coverage) || null;
47
+ const rows = blindRows(data, now);
48
+ const out = [];
49
+ out.push(`SOURCES${cov ? ` (${cov.live} of ${cov.expected} speaking)` : ''}`);
50
+ if (!rows.length) out.push(' I have not heard from any source yet. That is not "all quiet".');
51
+ else {
52
+ const w = Math.max(6, ...rows.map((r) => r.source.length));
53
+ out.push(` ${'SOURCE'.padEnd(w)} ${'STATE'.padEnd(11)} ${'LAST HEARD'.padEnd(10)} NOTE`);
54
+ for (const r of rows) out.push(` ${r.source.padEnd(w)} ${r.state.padEnd(11)} ${r.last.padEnd(10)} ${r.note}`.trimEnd());
55
+ }
56
+ const stale = (sit.cannot_see && sit.cannot_see.stale) || [];
57
+ if (stale.length) out.push('', `Stale claims (source quiet, so these may be out of date): ${stale.map((s) => nameOf({ name: s.subject })).join(', ')}`);
58
+
59
+ const da = sit.doesnt_add_up || [];
60
+ out.push('', `DOESN'T ADD UP${da.length ? ` (${da.length})` : ''}`);
61
+ if (!da.length) out.push(' Nothing I can see contradicts anything else. I can only compare what the sources tell me.');
62
+ da.forEach((x, i) => {
63
+ out.push(` ${i + 1}. ${x.L0}${x.urgent ? ' [TODAY]' : ''}`);
64
+ out.push(` Claimed ${x.claimed}`);
65
+ out.push(` Observed ${x.observed}`);
66
+ if (x.sources && x.sources.length) out.push(` Sources ${x.sources.join(', ')}`);
67
+ if (x.why) out.push(` Why ${x.why}`);
68
+ if (x.how) out.push(` Settle ${x.how}`);
69
+ });
70
+ return `${out.join('\n')}\n`;
71
+ }