quoxcli 0.2.0 → 0.4.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 (442) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/actions.js +15 -0
  5. package/lib/api/endpoints/admin.js +46 -0
  6. package/lib/api/endpoints/agent-schedules.js +26 -0
  7. package/lib/api/endpoints/agents.js +7 -0
  8. package/lib/api/endpoints/assistants.js +12 -0
  9. package/lib/api/endpoints/away.js +15 -0
  10. package/lib/api/endpoints/blog.js +16 -0
  11. package/lib/api/endpoints/chat.js +14 -0
  12. package/lib/api/endpoints/contactsq.js +6 -0
  13. package/lib/api/endpoints/conversations.js +14 -0
  14. package/lib/api/endpoints/devServerRegistry.js +25 -0
  15. package/lib/api/endpoints/engagement.js +26 -0
  16. package/lib/api/endpoints/evidence.js +20 -0
  17. package/lib/api/endpoints/executions.js +13 -0
  18. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  19. package/lib/api/endpoints/files.js +14 -0
  20. package/lib/api/endpoints/hat.js +44 -0
  21. package/lib/api/endpoints/inbox.js +20 -0
  22. package/lib/api/endpoints/index.js +26 -0
  23. package/lib/api/endpoints/loops.js +26 -0
  24. package/lib/api/endpoints/memory.js +19 -0
  25. package/lib/api/endpoints/mind.js +13 -0
  26. package/lib/api/endpoints/mon.js +179 -0
  27. package/lib/api/endpoints/objectives.js +12 -0
  28. package/lib/api/endpoints/plugins.js +18 -0
  29. package/lib/api/endpoints/presence.js +33 -0
  30. package/lib/api/endpoints/quoxplan.js +7 -0
  31. package/lib/api/endpoints/quoxseo.js +12 -0
  32. package/lib/api/endpoints/router.js +17 -0
  33. package/lib/api/endpoints/safety.js +7 -0
  34. package/lib/api/endpoints/server.js +21 -0
  35. package/lib/api/endpoints/setup.js +6 -0
  36. package/lib/api/endpoints/skills.js +20 -0
  37. package/lib/api/endpoints/stepup.js +20 -0
  38. package/lib/api/endpoints/teams.js +2 -2
  39. package/lib/api/endpoints/triggers.js +26 -0
  40. package/lib/api/endpoints/v1.js +120 -0
  41. package/lib/api/endpoints/ward.js +7 -0
  42. package/lib/api/endpoints/workflows.js +7 -0
  43. package/lib/api/endpoints/x.js +8 -0
  44. package/lib/commands/actions.js +131 -0
  45. package/lib/commands/admin.js +16 -0
  46. package/lib/commands/agent.js +170 -27
  47. package/lib/commands/agentAttach.js +140 -13
  48. package/lib/commands/approvals.js +78 -1
  49. package/lib/commands/aside.js +24 -0
  50. package/lib/commands/ask.js +146 -0
  51. package/lib/commands/assistant.js +59 -4
  52. package/lib/commands/auth.js +8 -2
  53. package/lib/commands/backup.js +8 -4
  54. package/lib/commands/blog.js +110 -0
  55. package/lib/commands/brain.js +105 -14
  56. package/lib/commands/build.js +19 -5
  57. package/lib/commands/chat.js +180 -26
  58. package/lib/commands/chatCompanion.js +27 -0
  59. package/lib/commands/chatState.js +45 -0
  60. package/lib/commands/claude.js +35 -38
  61. package/lib/commands/contacts.js +134 -4
  62. package/lib/commands/contactsSendAt.js +21 -0
  63. package/lib/commands/conversation.js +35 -4
  64. package/lib/commands/dashboard.js +43 -0
  65. package/lib/commands/department.js +28 -7
  66. package/lib/commands/devResident.js +15 -3
  67. package/lib/commands/devSupervisor.js +13 -1
  68. package/lib/commands/discord.js +75 -39
  69. package/lib/commands/done.js +98 -28
  70. package/lib/commands/engagement.js +107 -6
  71. package/lib/commands/evidence.js +154 -0
  72. package/lib/commands/ext.js +23 -0
  73. package/lib/commands/fetchAllowlist.js +71 -0
  74. package/lib/commands/file.js +38 -1
  75. package/lib/commands/fleet.js +336 -4
  76. package/lib/commands/governance.js +12 -3
  77. package/lib/commands/gripe.js +44 -13
  78. package/lib/commands/hat.js +254 -9
  79. package/lib/commands/helpers.js +18 -1
  80. package/lib/commands/hyperion.js +58 -0
  81. package/lib/commands/image.js +68 -0
  82. package/lib/commands/inbox.js +74 -23
  83. package/lib/commands/kuma.js +1050 -0
  84. package/lib/commands/kumaSla.js +174 -0
  85. package/lib/commands/licence.js +52 -155
  86. package/lib/commands/loop.js +291 -0
  87. package/lib/commands/lucid.js +10 -4
  88. package/lib/commands/memory.js +85 -8
  89. package/lib/commands/mind.js +71 -0
  90. package/lib/commands/mon.js +69 -2
  91. package/lib/commands/monitoring.js +1 -1
  92. package/lib/commands/motion.js +2 -2
  93. package/lib/commands/nucleus.js +48 -17
  94. package/lib/commands/objective.js +59 -1
  95. package/lib/commands/offload.js +89 -0
  96. package/lib/commands/onboard.js +2 -1
  97. package/lib/commands/org.js +30 -3
  98. package/lib/commands/peek.js +28 -0
  99. package/lib/commands/plan.js +38 -0
  100. package/lib/commands/plugin.js +110 -0
  101. package/lib/commands/presence.js +89 -0
  102. package/lib/commands/provider.js +217 -0
  103. package/lib/commands/qlarity.js +19 -3
  104. package/lib/commands/quoxchat.js +13 -4
  105. package/lib/commands/quoxseo.js +69 -0
  106. package/lib/commands/reach.js +2 -1
  107. package/lib/commands/research.js +79 -0
  108. package/lib/commands/router.js +77 -0
  109. package/lib/commands/rows.js +367 -0
  110. package/lib/commands/run.js +82 -10
  111. package/lib/commands/scope.js +2 -2
  112. package/lib/commands/server.js +405 -0
  113. package/lib/commands/setup.js +33 -7
  114. package/lib/commands/shortcut.js +41 -0
  115. package/lib/commands/skills.js +68 -9
  116. package/lib/commands/slot.js +260 -7
  117. package/lib/commands/status.js +1 -1
  118. package/lib/commands/team.js +3 -1
  119. package/lib/commands/tips.js +23 -0
  120. package/lib/commands/tool.js +133 -45
  121. package/lib/commands/trace.js +12 -7
  122. package/lib/commands/trigger.js +95 -0
  123. package/lib/commands/update.js +46 -0
  124. package/lib/commands/vault.js +31 -8
  125. package/lib/commands/vm.js +237 -0
  126. package/lib/commands/voice.js +34 -3
  127. package/lib/commands/volt.js +45 -3
  128. package/lib/commands/walkthrough.js +32 -0
  129. package/lib/commands/watch.js +73 -21
  130. package/lib/commands/welcome.js +14 -0
  131. package/lib/commands/workflow.js +189 -32
  132. package/lib/commands/x.js +13 -4
  133. package/lib/commands/youtube.js +172 -0
  134. package/lib/core/actingAs.js +2 -2
  135. package/lib/core/actionItems.js +115 -0
  136. package/lib/core/activeSummary.js +186 -0
  137. package/lib/core/agentAlias.js +2 -0
  138. package/lib/core/agentFamilies.js +7 -0
  139. package/lib/core/aside.js +54 -0
  140. package/lib/core/askDecision.js +60 -0
  141. package/lib/core/auth.js +164 -13
  142. package/lib/core/callerSource.js +1 -1
  143. package/lib/core/cliBuild.js +62 -0
  144. package/lib/core/client.js +152 -35
  145. package/lib/core/commandRegistrars.js +130 -0
  146. package/lib/core/config.js +143 -25
  147. package/lib/core/customHats.js +168 -0
  148. package/lib/core/doneEngineFetch.js +8 -0
  149. package/lib/core/errors.js +329 -20
  150. package/lib/core/findingReconcile.js +90 -0
  151. package/lib/core/global-flags.js +105 -5
  152. package/lib/core/governedSession.js +1 -3
  153. package/lib/core/hyperion.js +102 -0
  154. package/lib/core/inboxResolution.js +65 -0
  155. package/lib/core/lazyCommands.js +4 -1
  156. package/lib/core/liveSessionsReport.js +1 -1
  157. package/lib/core/loopGlyph.js +97 -0
  158. package/lib/core/offload.js +533 -0
  159. package/lib/core/org-override.js +1 -1
  160. package/lib/core/orgResolve.js +70 -0
  161. package/lib/core/output.js +11 -11
  162. package/lib/core/peek.js +126 -0
  163. package/lib/core/profileUsers.js +51 -0
  164. package/lib/core/promptCancel.js +18 -0
  165. package/lib/core/repoPath.js +11 -0
  166. package/lib/core/residentSeed.js +3 -1
  167. package/lib/core/rootOptions.js +39 -0
  168. package/lib/core/router.js +168 -0
  169. package/lib/core/selfUpdate.js +188 -0
  170. package/lib/core/sessionHeartbeat.js +12 -8
  171. package/lib/core/sessionsOverview.js +64 -0
  172. package/lib/core/shortcut.js +83 -0
  173. package/lib/core/slotKeeper.js +248 -3
  174. package/lib/core/slotWake.js +120 -0
  175. package/lib/core/supervisorEngine.js +71 -2
  176. package/lib/core/version.js +1 -1
  177. package/lib/core/vm.js +366 -0
  178. package/lib/core/vmTui.js +86 -0
  179. package/lib/core/walkthrough.js +35 -0
  180. package/lib/dragon/companionView.js +34 -0
  181. package/lib/dragon/frames.js +118 -0
  182. package/lib/dragon/jobs.js +123 -0
  183. package/lib/dragon/renderWorker.js +15 -0
  184. package/lib/dragon/select.js +72 -0
  185. package/lib/evidence/bundleExport.js +47 -0
  186. package/lib/evidence/bundleVerify.js +77 -0
  187. package/lib/hyperion/deck.js +29 -0
  188. package/lib/lucid/LucidCard.jsx +68 -0
  189. package/lib/lucid/LucidIntro.jsx +45 -58
  190. package/lib/lucid/cards.js +86 -0
  191. package/lib/lucid/markdown.js +81 -0
  192. package/lib/lucid/motion.js +56 -0
  193. package/lib/quoxon/EXTENSIONS.md +23 -0
  194. package/lib/quoxon/activity.js +14 -2
  195. package/lib/quoxon/aocl.js +117 -0
  196. package/lib/quoxon/api.js +690 -25
  197. package/lib/quoxon/app.js +3586 -250
  198. package/lib/quoxon/approvalFacts.js +96 -0
  199. package/lib/quoxon/approvalSentence.js +44 -0
  200. package/lib/quoxon/approvalsView.js +384 -0
  201. package/lib/quoxon/asideView.js +40 -0
  202. package/lib/quoxon/ask.js +121 -0
  203. package/lib/quoxon/away.js +52 -0
  204. package/lib/quoxon/bang.js +224 -0
  205. package/lib/quoxon/bulkRisk.js +17 -0
  206. package/lib/quoxon/capabilities.js +13 -6
  207. package/lib/quoxon/cells.js +53 -0
  208. package/lib/quoxon/commandShort.js +124 -0
  209. package/lib/quoxon/commands.js +429 -39
  210. package/lib/quoxon/connect.js +210 -23
  211. package/lib/quoxon/connectCard.js +19 -2
  212. package/lib/quoxon/consent.js +86 -0
  213. package/lib/quoxon/copy.js +4 -3
  214. package/lib/quoxon/countdownLine.js +20 -0
  215. package/lib/quoxon/deck.js +30 -0
  216. package/lib/quoxon/demo.autoplay.js +29 -0
  217. package/lib/quoxon/demo.fixtures.js +1 -1
  218. package/lib/quoxon/demo.js +46 -3
  219. package/lib/quoxon/devObserver.js +20 -0
  220. package/lib/quoxon/dividerAocl.js +192 -0
  221. package/lib/quoxon/dividerRule.js +57 -0
  222. package/lib/quoxon/dragonCompanion.js +255 -0
  223. package/lib/quoxon/dragonModes.js +151 -0
  224. package/lib/quoxon/dragonMoments.js +82 -0
  225. package/lib/quoxon/dragonReads.js +68 -0
  226. package/lib/quoxon/dragonView.js +41 -0
  227. package/lib/quoxon/earlyInput.js +40 -0
  228. package/lib/quoxon/echo.js +15 -0
  229. package/lib/quoxon/egg.js +2 -2
  230. package/lib/quoxon/events.js +266 -26
  231. package/lib/quoxon/extensions.js +276 -0
  232. package/lib/quoxon/feedback.js +50 -0
  233. package/lib/quoxon/gallery.js +21 -7
  234. package/lib/quoxon/handoffOffer.js +78 -0
  235. package/lib/quoxon/hatSwitch.js +37 -0
  236. package/lib/quoxon/headless.js +161 -0
  237. package/lib/quoxon/hostUrls.js +41 -0
  238. package/lib/quoxon/inboxView.js +79 -0
  239. package/lib/quoxon/indicator.js +6 -2
  240. package/lib/quoxon/installCli.js +79 -0
  241. package/lib/quoxon/intent.js +12 -1
  242. package/lib/quoxon/keys.js +51 -8
  243. package/lib/quoxon/launcher.js +103 -0
  244. package/lib/quoxon/loopCard.js +456 -0
  245. package/lib/quoxon/loopCards.js +180 -0
  246. package/lib/quoxon/loopStream.js +87 -0
  247. package/lib/quoxon/main.js +101 -160
  248. package/lib/quoxon/markdown.js +45 -21
  249. package/lib/quoxon/markdownSettle.js +19 -0
  250. package/lib/quoxon/matrixDomains.json +46 -0
  251. package/lib/quoxon/mentions.js +90 -0
  252. package/lib/quoxon/mind.js +103 -0
  253. package/lib/quoxon/mode.js +46 -0
  254. package/lib/quoxon/model.js +132 -26
  255. package/lib/quoxon/modelPrefs.js +345 -0
  256. package/lib/quoxon/motion/clock.js +3 -2
  257. package/lib/quoxon/motion/level.js +91 -0
  258. package/lib/quoxon/mouse.js +222 -0
  259. package/lib/quoxon/noai.js +57 -0
  260. package/lib/quoxon/objectiveOffer.js +82 -0
  261. package/lib/quoxon/objects/away.js +72 -0
  262. package/lib/quoxon/objects/fetch.js +17 -5
  263. package/lib/quoxon/objects/fleet.js +149 -0
  264. package/lib/quoxon/objects/health.js +186 -0
  265. package/lib/quoxon/objects/index.js +46 -7
  266. package/lib/quoxon/objects/mirror.js +137 -0
  267. package/lib/quoxon/objects/model.js +33 -0
  268. package/lib/quoxon/objects/peek.js +17 -0
  269. package/lib/quoxon/objects/proof.js +12 -4
  270. package/lib/quoxon/objects/prove.js +130 -0
  271. package/lib/quoxon/objects/replay.js +125 -0
  272. package/lib/quoxon/objects/sitrep.js +58 -0
  273. package/lib/quoxon/objects/view.js +11 -0
  274. package/lib/quoxon/onboarding/copy.js +32 -4
  275. package/lib/quoxon/onboarding/credential.js +17 -4
  276. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  277. package/lib/quoxon/onboarding/failureCause.js +33 -0
  278. package/lib/quoxon/onboarding/finish.js +15 -0
  279. package/lib/quoxon/onboarding/index.js +34 -7
  280. package/lib/quoxon/onboarding/installRunner.js +64 -5
  281. package/lib/quoxon/onboarding/preflight.js +17 -3
  282. package/lib/quoxon/onboarding/probe.js +65 -0
  283. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  284. package/lib/quoxon/onboarding/view.js +76 -19
  285. package/lib/quoxon/orgLabel.js +10 -0
  286. package/lib/quoxon/orgSwitch.js +59 -0
  287. package/lib/quoxon/ownBroadcast.js +22 -0
  288. package/lib/quoxon/palette.js +43 -0
  289. package/lib/quoxon/planSteps.js +51 -0
  290. package/lib/quoxon/print.js +12 -5
  291. package/lib/quoxon/profileSwitch.js +53 -0
  292. package/lib/quoxon/promptHistory.js +41 -0
  293. package/lib/quoxon/providers.js +48 -0
  294. package/lib/quoxon/qart.js +20 -51
  295. package/lib/quoxon/qdragon.js +410 -0
  296. package/lib/quoxon/qdragonFly.js +62 -0
  297. package/lib/quoxon/qdragonScenes.js +282 -0
  298. package/lib/quoxon/qeyes.js +213 -0
  299. package/lib/quoxon/qmotion.js +75 -37
  300. package/lib/quoxon/render.js +740 -124
  301. package/lib/quoxon/resend.js +72 -0
  302. package/lib/quoxon/roster.js +117 -17
  303. package/lib/quoxon/route.js +6 -5
  304. package/lib/quoxon/rows.js +954 -0
  305. package/lib/quoxon/rowsStore.js +80 -0
  306. package/lib/quoxon/screenContext.js +150 -0
  307. package/lib/quoxon/screenFrames.js +254 -0
  308. package/lib/quoxon/serverChip.js +42 -0
  309. package/lib/quoxon/session.js +182 -2
  310. package/lib/quoxon/sessionDead.js +17 -0
  311. package/lib/quoxon/signin.js +125 -0
  312. package/lib/quoxon/slash/agents.js +112 -0
  313. package/lib/quoxon/slash/argSources.js +65 -0
  314. package/lib/quoxon/slash/args.js +65 -0
  315. package/lib/quoxon/slash/automate.js +89 -0
  316. package/lib/quoxon/slash/broadcast.js +140 -0
  317. package/lib/quoxon/slash/broadcastRun.js +108 -0
  318. package/lib/quoxon/slash/card.js +30 -0
  319. package/lib/quoxon/slash/challenge.js +114 -0
  320. package/lib/quoxon/slash/cliCommand.js +94 -0
  321. package/lib/quoxon/slash/cliLeaves.json +1023 -0
  322. package/lib/quoxon/slash/compact.js +1 -1
  323. package/lib/quoxon/slash/confirm.js +30 -0
  324. package/lib/quoxon/slash/crystallize.js +69 -0
  325. package/lib/quoxon/slash/dashboard.js +136 -0
  326. package/lib/quoxon/slash/draft.js +54 -0
  327. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  328. package/lib/quoxon/slash/hatWalk.js +206 -0
  329. package/lib/quoxon/slash/inverses.js +77 -0
  330. package/lib/quoxon/slash/killer.js +24 -0
  331. package/lib/quoxon/slash/killerApi.js +16 -0
  332. package/lib/quoxon/slash/ledger.js +13 -8
  333. package/lib/quoxon/slash/listView.js +3 -1
  334. package/lib/quoxon/slash/modelCmd.js +261 -0
  335. package/lib/quoxon/slash/palette.js +22 -8
  336. package/lib/quoxon/slash/paletteController.js +117 -7
  337. package/lib/quoxon/slash/panels.js +21 -6
  338. package/lib/quoxon/slash/picker.js +20 -2
  339. package/lib/quoxon/slash/qpeCmd.js +35 -0
  340. package/lib/quoxon/slash/restart.js +95 -13
  341. package/lib/quoxon/slash/resume.js +18 -1
  342. package/lib/quoxon/slash/rowCmd.js +157 -0
  343. package/lib/quoxon/slash/rowWalk.js +175 -0
  344. package/lib/quoxon/slash/settings.js +16 -6
  345. package/lib/quoxon/slash/spawn.js +177 -0
  346. package/lib/quoxon/slash/stepUp.js +274 -0
  347. package/lib/quoxon/slash/stepUpCard.js +60 -0
  348. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  349. package/lib/quoxon/slash/todoCmd.js +122 -0
  350. package/lib/quoxon/slash/undoApi.js +38 -0
  351. package/lib/quoxon/slash/undoCmd.js +49 -0
  352. package/lib/quoxon/slash/undoWish.js +15 -0
  353. package/lib/quoxon/slash/watch.js +164 -0
  354. package/lib/quoxon/slash/wish.js +23 -0
  355. package/lib/quoxon/slash/wishCmd.js +32 -0
  356. package/lib/quoxon/slotChat.js +300 -0
  357. package/lib/quoxon/slotFormat.js +195 -0
  358. package/lib/quoxon/slotRun.js +131 -0
  359. package/lib/quoxon/sound/mapping.js +2 -1
  360. package/lib/quoxon/stray.js +22 -0
  361. package/lib/quoxon/suggestCommand.js +90 -0
  362. package/lib/quoxon/term.js +46 -11
  363. package/lib/quoxon/textBuffer.js +176 -2
  364. package/lib/quoxon/theme.js +58 -25
  365. package/lib/quoxon/tips.js +433 -0
  366. package/lib/quoxon/toolCards.js +30 -0
  367. package/lib/quoxon/toolError.js +41 -0
  368. package/lib/quoxon/transcript.js +42 -4
  369. package/lib/quoxon/web/qface.js +18 -10
  370. package/lib/quoxon/welcome.js +1 -1
  371. package/lib/quoxon/whatsnew.js +136 -0
  372. package/lib/quoxon/whatsnew.jsonl +134 -0
  373. package/lib/quoxon/windowOrg.js +123 -0
  374. package/lib/server/devServers.js +137 -0
  375. package/lib/server/diag.js +129 -0
  376. package/lib/server/doctor.js +71 -0
  377. package/lib/server/install.js +149 -0
  378. package/lib/server/pluginEnable.js +161 -0
  379. package/lib/server/skew.js +56 -0
  380. package/lib/server/snapshot.js +131 -0
  381. package/lib/server/status.js +128 -0
  382. package/lib/server/sys.js +65 -0
  383. package/lib/server/uninstall.js +63 -0
  384. package/lib/server/update.js +234 -0
  385. package/lib/toolOutputShape.js +315 -0
  386. package/lib/tui/views/HyperionDeck.jsx +79 -0
  387. package/lib/tui/views/LucidDeck.jsx +209 -217
  388. package/package.json +5 -1
  389. package/vendor/dragonlab/SOURCE +4 -0
  390. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  391. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  392. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  393. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  394. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  395. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  396. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  397. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  398. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  399. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  400. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  401. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  402. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  403. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  404. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  405. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  406. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  407. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  408. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  409. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  410. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  411. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  412. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  413. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  414. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  415. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  416. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  417. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  418. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  419. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  420. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  421. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  422. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  423. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  424. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  425. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  426. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  427. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  428. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  429. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  430. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  431. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  432. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  433. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  434. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  435. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  436. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  437. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  438. package/vendor/dragonlab/lib/power-effects.js +294 -0
  439. package/vendor/dragonlab/lib/power-registry.js +74 -0
  440. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  441. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
  442. package/lib/core/websiteSession.js +0 -148
@@ -0,0 +1,954 @@
1
+ /**
2
+ * QUOXON-FW17: custom rows. User-defined lines rendered just above the status line.
3
+ *
4
+ * A row is { id, label?, source: { type: 'url', url, every } | { type: 'cmd', cmd, every }, template, colour? }.
5
+ * The template pulls values out of the fetched JSON (or the command's stdout, as {{text}}) with a tiny
6
+ * SAFE evaluator: {{a.b[0].c}}, {{count(list)}}, {{count(list[field=value])}}, list[field~text] (contains, any
7
+ * case, a plain includes), list[*] (every item) and {a,b} (only these fields). Nothing is eval'd.
8
+ *
9
+ * Sync split (a security line, enforced here and tested): url rows sync per user (preference namespace
10
+ * quoxonRows); cmd rows are LOCAL ONLY. normRows() on anything that arrives from the server drops every cmd
11
+ * row, so a synced row can never make another machine run a shell command.
12
+ *
13
+ * ROWS (2026-10-17): the status-row contract. A row with NO template reads the public JSON shape
14
+ * { text, label?, value?, tone?: ok|warn|error|info|neutral, trend?: up|down|flat|<number>, link? }
15
+ * (docs/STATUS_ROWS.md). Plain text output counts as { text }. Two more keyless, sync-safe sources:
16
+ * quox: { type: 'quox', service: auth|collector, path: '/api/...{org}...' } your own Quox, with your session
17
+ * plugin: { type: 'plugin', plugin: '<id>', path: '<relative>' } GET /api/v1/plugins/<id>/<path>
18
+ * Everything a row prints passes cleanText(): terminal escapes and control characters never reach the screen.
19
+ */
20
+ import { execFile } from 'node:child_process';
21
+ import { homedir } from 'node:os';
22
+ import { join, dirname } from 'node:path';
23
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
24
+ import { cellWidth } from './cells.js';
25
+
26
+ export const MAX_ROWS = 3;
27
+ /** Lines drawn above the status bar (a row can bring several with `lines`); the stored-row cap stays MAX_ROWS. */
28
+ export const MAX_LINES = 4;
29
+ export const FETCH_TIMEOUT_MS = 5000;
30
+ /** url rows fetch from busy boxes (the proof board is ~400 KB): 15 s. cmd rows keep FETCH_TIMEOUT_MS. */
31
+ export const URL_TIMEOUT_MS = 15000;
32
+ /** A failed refresh keeps showing the last good value (with an age marker) for this long, then warns. */
33
+ export const STALE_MAX_MS = 30 * 60 * 1000;
34
+ export const BACKOFF_CAP_MS = 10 * 60 * 1000;
35
+ export const OUTPUT_CAP = 64 * 1024;
36
+ /** A JSON body is parsed whole or not at all: cutting it leaves nothing to read (2026-10-14: the proof board grew to 347 KB and the row showed only dashes). */
37
+ export const JSON_CAP = 2 * 1024 * 1024;
38
+ export const ROLES = ['prose', 'command', 'value', 'ok', 'warn', 'error', 'brand', 'quiet'];
39
+ const ROLE_TO_THEME = { value: 'id' }; // palette role -> theme.js name
40
+ export const themeRole = (r) => ROLE_TO_THEME[r] || r || 'prose';
41
+
42
+ const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
43
+
44
+ /* ---- what a row may print ------------------------------------------------------------------ */
45
+
46
+ // CSI, OSC (title, hyperlink, clipboard), DCS/SOS/PM/APC strings and lone 2-byte escapes.
47
+ const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][^\x1b]*(?:\x1b\\)?|\x1b[@-Z\\-_]?/g;
48
+ // C0, DEL, C1, zero-width space, LRM/RLM, bidi embeddings, overrides and isolates, BOM. ZWJ stays (emoji).
49
+ const CTRL_RE = /[\u0000-\u001f\u007f-\u009f\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g;
50
+
51
+ /**
52
+ * Text from a URL, a command or a plugin -> one safe line. A remote endpoint must never be able to move the
53
+ * cursor, retitle the terminal, write the clipboard (OSC 52) or reorder text with bidi overrides.
54
+ */
55
+ export function cleanText(v, max = 500) {
56
+ return String(v ?? '').replace(ANSI_RE, '').replace(/[\t\n\r\v\f]+/g, ' ').replace(CTRL_RE, '').replace(/\s+/g, ' ').trim().slice(0, max);
57
+ }
58
+
59
+ /* ---- durations --------------------------------------------------------------------------- */
60
+
61
+ /** '60s' | '5m' | '1h' -> ms, or null. Floor 5 s, ceiling 1 day. */
62
+ export function parseEvery(s) {
63
+ const m = /^(\d+)\s*(s|m|h)$/i.exec(String(s ?? '').trim());
64
+ if (!m) return null;
65
+ const ms = Number(m[1]) * { s: 1000, m: 60000, h: 3600000 }[m[2].toLowerCase()];
66
+ return ms >= 5000 && ms <= 86400000 ? ms : null;
67
+ }
68
+
69
+ /* ---- the template evaluator ------------------------------------------------------------- */
70
+
71
+ /** A path -> steps, or null when malformed. */
72
+ export function parsePath(src) {
73
+ const s = String(src ?? '').trim();
74
+ if (!s) return null;
75
+ const steps = [];
76
+ let i = 0;
77
+ while (i < s.length) {
78
+ if (s[i] === '.') { i++; continue; }
79
+ if (s[i] === '[') {
80
+ const end = s.indexOf(']', i);
81
+ if (end < 0) return null;
82
+ const inner = s.slice(i + 1, end).trim();
83
+ i = end + 1;
84
+ if (/^\d+$/.test(inner)) steps.push({ index: Number(inner) });
85
+ else if (inner === '*') steps.push({ wildcard: true }); // list[*]: every item
86
+ else {
87
+ const eq = /^([^=~\s\[\]]+)\s*=\s*(.*)$/.exec(inner);
88
+ if (eq) steps.push({ field: eq[1], value: eq[2].trim().replace(/^(["'])(.*)\1$/, '$2') });
89
+ else {
90
+ // list[field~text]: case-insensitive contains, a plain includes (never a regex from input).
91
+ const ct = /^([^=~\s\[\]]+)\s*~\s*(.*)$/.exec(inner);
92
+ if (!ct) return null;
93
+ const text = ct[2].trim().replace(/^(["'])(.*)\1$/, '$2');
94
+ if (!text) return null;
95
+ steps.push({ field: ct[1], contains: text.toLowerCase() });
96
+ }
97
+ }
98
+ continue;
99
+ }
100
+ if (s[i] === '{') {
101
+ // #1044: {a,b,c} keeps only these fields (of every item when on a list).
102
+ const end = s.indexOf('}', i);
103
+ if (end < 0) return null;
104
+ const fields = s.slice(i + 1, end).split(',').map((f) => f.trim());
105
+ if (!fields.length || fields.some((f) => !/^[A-Za-z0-9_-]+$/.test(f))) return null;
106
+ steps.push({ fields });
107
+ i = end + 1;
108
+ continue;
109
+ }
110
+ let j = i;
111
+ while (j < s.length && s[j] !== '.' && s[j] !== '[') j++;
112
+ const key = s.slice(i, j).trim();
113
+ if (!key || /[\s(){}=,;+*\/]/.test(key)) return null;
114
+ steps.push({ key });
115
+ i = j;
116
+ }
117
+ return steps.length ? steps : null;
118
+ }
119
+
120
+ const own = (o, k) => o !== null && typeof o === 'object' && !Array.isArray(o) && !BLOCKED_KEYS.has(k) && Object.hasOwn(o, k);
121
+
122
+ function walk(data, steps) {
123
+ let cur = data;
124
+ for (const st of steps) {
125
+ if (cur === undefined || cur === null) return undefined;
126
+ if ('key' in st) { if (!own(cur, st.key)) return undefined; cur = cur[st.key]; }
127
+ else if ('index' in st) { if (!Array.isArray(cur)) return undefined; cur = cur[st.index]; }
128
+ else if (st.wildcard) { if (!Array.isArray(cur)) return undefined; }
129
+ else if (st.fields) {
130
+ const pick = (o) => { const r = {}; for (const f of st.fields) if (own(o, f)) r[f] = o[f]; return r; };
131
+ cur = Array.isArray(cur) ? cur.map(pick) : pick(cur);
132
+ }
133
+ else {
134
+ if (!Array.isArray(cur)) return undefined;
135
+ const sub = parsePath(st.field);
136
+ cur = cur.filter((it) => {
137
+ const v = sub ? walk(it, sub) : undefined;
138
+ if (v === undefined || v === null || typeof v === 'object') return false;
139
+ return 'contains' in st ? String(v).toLowerCase().includes(st.contains) : String(v) === st.value;
140
+ });
141
+ }
142
+ }
143
+ return cur;
144
+ }
145
+
146
+ /** 1234 -> 1.2k, 12345 -> 12k, 1500000 -> 1.5M. Small numbers stay as they are. */
147
+ export function fmtNum(n) {
148
+ if (!Number.isFinite(n)) return String(n);
149
+ const a = Math.abs(n);
150
+ if (a < 1000) return String(Number.isInteger(n) ? n : Number(n.toFixed(1)));
151
+ for (const [u, sfx] of [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'k']]) {
152
+ if (a >= u) {
153
+ const v = n / u;
154
+ return `${Math.abs(v) < 10 ? v.toFixed(1).replace(/\.0$/, '') : Math.round(v)}${sfx}`;
155
+ }
156
+ }
157
+ return String(n);
158
+ }
159
+
160
+ const show = (v) => {
161
+ if (v === undefined || v === null) return '–';
162
+ if (typeof v === 'number') return fmtNum(v);
163
+ if (typeof v === 'string') return cleanText(v);
164
+ if (typeof v === 'boolean') return String(v);
165
+ if (Array.isArray(v)) return String(v.length);
166
+ return '{…}';
167
+ };
168
+
169
+ /** One {{ expr }} body -> { ok, value } | { ok: false, error }. */
170
+ function parseExpr(body) {
171
+ const e = String(body ?? '').trim();
172
+ const fn = /^([A-Za-z_]+)\s*\(([\s\S]*)\)$/.exec(e);
173
+ if (fn) {
174
+ if (fn[1] !== 'count') return { error: `unknown function ${fn[1]}() (only count)` };
175
+ const steps = parsePath(fn[2]);
176
+ return steps ? { fn: 'count', steps } : { error: 'count() needs a path' };
177
+ }
178
+ const steps = parsePath(e);
179
+ return steps ? { steps } : { error: e ? `bad path "${e}"` : 'empty {{ }}' };
180
+ }
181
+
182
+ const countOf = (v) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : undefined);
183
+
184
+ /** Syntax check for a template at add time -> an error string or null. */
185
+ export function checkTemplate(tpl) {
186
+ const t = String(tpl ?? '');
187
+ if (!t.trim()) return 'The template is empty.';
188
+ if ((t.match(/\{\{/g) || []).length !== (t.match(/\}\}/g) || []).length) return 'Unbalanced {{ }} in the template.';
189
+ for (const m of t.matchAll(/\{\{([\s\S]*?)\}\}/g)) { const p = parseExpr(m[1]); if (p.error) return p.error; }
190
+ return null;
191
+ }
192
+
193
+ /** Fill a template from parsed data. Unknown paths show as "–". Never throws. */
194
+ export function evalTemplate(tpl, data) {
195
+ return String(tpl ?? '').replace(/\{\{([\s\S]*?)\}\}/g, (_, body) => {
196
+ const p = parseExpr(body);
197
+ if (p.error) return '–';
198
+ const v = walk(data, p.steps);
199
+ return show(p.fn === 'count' ? countOf(v) : v);
200
+ });
201
+ }
202
+
203
+ /** Raw text (a response body or stdout) -> the object the template reads: parsed JSON, else { text }. */
204
+ export function dataFrom(raw) {
205
+ const text = String(raw ?? '');
206
+ const t = text.trim();
207
+ if (t.startsWith('{') || t.startsWith('[')) { try { const j = JSON.parse(t); if (j && typeof j === 'object') return j; } catch { /* plain text */ } }
208
+ return { text: t };
209
+ }
210
+
211
+ /* ---- the status-row contract (docs/STATUS_ROWS.md) ---------------------------------------- */
212
+
213
+ export const STATUS_ROW_TONES = ['ok', 'warn', 'error', 'info', 'neutral'];
214
+ const TONE_ROLE = { ok: 'ok', warn: 'warn', error: 'error', info: 'command', neutral: 'prose' };
215
+ const TONE_GLYPH = { ok: '✔', warn: '⚠', error: '✖', info: '◆' };
216
+ export const toneRole = (t) => TONE_ROLE[t] || null;
217
+
218
+ const trendText = (t) => {
219
+ if (t === 'up') return '▲';
220
+ if (t === 'down') return '▼';
221
+ if (t === 'flat') return '▬';
222
+ if (typeof t === 'number' && Number.isFinite(t)) return t > 0 ? `▲${fmtNum(t)}` : t < 0 ? `▼${fmtNum(-t)}` : '▬';
223
+ return '';
224
+ };
225
+
226
+ /* ---- cells (contract v1.1, TUI-QPE 2026-10-19) --------------------------------------------------
227
+ * A row may carry `cells`: [{ parts: [{ text, tone?, bold?, keep?, group? }], keep? }]. Each part has its own colour
228
+ * (a tone), so one row can say "70%" in amber and "▼12 broke" in red. `keep` says what goes first when the window is
229
+ * narrow: the lowest keep is dropped whole (a part, the parts sharing its group, or a cell; never half a word); no keep
230
+ * = never dropped. Rows with the same `grid` share one keep order and line their cells up in columns; `min_cols` hides
231
+ * a row in a narrower window.
232
+ */
233
+ export const CELL_LIMITS = { cells: 6, parts: 12, text: 80 };
234
+ const keepOf = (k) => (typeof k === 'number' && Number.isFinite(k) ? Math.max(0, Math.min(99, k)) : null);
235
+
236
+ function parseCells(list, warnings) {
237
+ if (!Array.isArray(list)) { warnings.push('"cells" is a list of {parts: [{text, tone}]}; ignored.'); return []; }
238
+ const out = [];
239
+ for (const c of list.slice(0, CELL_LIMITS.cells)) {
240
+ const raw = Array.isArray(c?.parts) ? c.parts : typeof c?.text === 'string' ? [c] : null;
241
+ if (!raw) { warnings.push('A cell needs "parts" (or "text"); skipped.'); continue; }
242
+ const parts = [];
243
+ for (const p of raw.slice(0, CELL_LIMITS.parts)) {
244
+ // Keep edge spaces (a part's own joiner, " · reach 214/266"): clean the middle, not the ends.
245
+ const t = typeof p?.text === 'string' ? p.text : typeof p?.text === 'number' ? fmtNum(p.text) : '';
246
+ const lead = /^\s/.test(t) ? ' ' : ''; const trail = /\s$/.test(t) ? ' ' : '';
247
+ const body = cleanText(t, CELL_LIMITS.text);
248
+ if (!body) continue;
249
+ const part = { text: lead + body + trail };
250
+ if (p.tone !== undefined && p.tone !== null) {
251
+ if (STATUS_ROW_TONES.includes(p.tone)) { if (p.tone !== 'neutral') part.tone = p.tone; }
252
+ else warnings.push(`a part's "tone" is one of ${STATUS_ROW_TONES.join(', ')}; "${cleanText(p.tone, 16)}" shows as neutral.`);
253
+ }
254
+ if (p.bold === true) part.bold = true;
255
+ const k = keepOf(p.keep); if (k !== null) part.keep = k;
256
+ if (typeof p.group === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(p.group)) part.group = p.group; // parts that drop together
257
+ parts.push(part);
258
+ }
259
+ if (!parts.length) continue;
260
+ const cell = { parts };
261
+ const k = keepOf(c.keep); if (k !== null) cell.keep = k;
262
+ out.push(cell);
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /** cells -> one plain line (screen context, /row list, a narrow fallback). */
268
+ export const cellsText = (cells) => (cells || []).map((c) => c.parts.map((p) => p.text).join('').trim()).join(' │ ');
269
+
270
+ /**
271
+ * Parsed data -> { seg, warnings } | { error }. Lenient on read (an unknown tone shows neutral and is named
272
+ * in warnings, so `quox rows test` can say so); strict on what it prints (everything passes cleanText).
273
+ */
274
+ export function parseStatusRow(d, depth = 0) {
275
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return { error: 'A status row is a JSON object: {"text": "..."}.' };
276
+ const warnings = [];
277
+ const seg = {};
278
+ const text = typeof d.text === 'string' ? cleanText(d.text, 160) : typeof d.text === 'number' ? fmtNum(d.text) : '';
279
+ if (text) seg.text = text;
280
+ if (typeof d.value === 'number' && Number.isFinite(d.value)) seg.value = fmtNum(d.value);
281
+ else if (typeof d.value === 'string' && cleanText(d.value, 24)) seg.value = cleanText(d.value, 24);
282
+ else if (d.value !== undefined && d.value !== null) warnings.push('"value" is a number or a short string; ignored.');
283
+ if (depth === 0 && d.lines !== undefined && d.lines !== null) { // TUI-QPE 2026-10-20: one source, several lines (v1.1)
284
+ if (!Array.isArray(d.lines)) warnings.push('"lines" is a list of status rows; ignored.');
285
+ else {
286
+ const lines = [];
287
+ for (const x of d.lines.slice(0, MAX_LINES)) { const q = parseStatusRow(x, 1); if (q.error) warnings.push(`a line: ${q.error}`); else { lines.push(q.seg); warnings.push(...q.warnings); } }
288
+ if (lines.length) { seg.lines = lines; if (!seg.text) seg.text = lines.map((l) => l.text).join(' / ').slice(0, 160); }
289
+ }
290
+ }
291
+ if (d.cells !== undefined && d.cells !== null) { // TUI-QPE: coloured, responsive cells (v1.1)
292
+ const cells = parseCells(d.cells, warnings);
293
+ if (cells.length) { seg.cells = cells; if (!seg.text) seg.text = cellsText(cells); }
294
+ }
295
+ if (!seg.text && !seg.value) return { error: 'Not a status row: it needs "text" (see quox rows contract).' };
296
+ if (d.grid !== undefined && d.grid !== null) {
297
+ if (typeof d.grid === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(d.grid)) seg.grid = d.grid; else warnings.push('"grid" is a short name (letters, digits, - and _); ignored.');
298
+ }
299
+ if (d.min_cols !== undefined && d.min_cols !== null) {
300
+ if (Number.isInteger(d.min_cols) && d.min_cols >= 20 && d.min_cols <= 400) seg.minCols = d.min_cols; else warnings.push('"min_cols" is a whole number from 20 to 400; ignored.');
301
+ }
302
+ if (d.tone !== undefined && d.tone !== null) {
303
+ if (STATUS_ROW_TONES.includes(d.tone)) { if (d.tone !== 'neutral') seg.tone = d.tone; }
304
+ else warnings.push(`"tone" is one of ${STATUS_ROW_TONES.join(', ')}; "${cleanText(d.tone, 16)}" shows as neutral.`);
305
+ }
306
+ if (d.trend !== undefined && d.trend !== null) {
307
+ const tt = trendText(d.trend);
308
+ if (tt) seg.trend = tt; else warnings.push('"trend" is up, down, flat or a number; ignored.');
309
+ }
310
+ if (typeof d.label === 'string' && cleanText(d.label, 16)) seg.label = cleanText(d.label, 16);
311
+ if (d.link !== undefined && d.link !== null) {
312
+ let ok = false;
313
+ try { const u = new URL(String(d.link)); ok = (u.protocol === 'http:' || u.protocol === 'https:') && !u.username && !u.password && String(d.link).length <= 500; } catch { ok = false; }
314
+ if (ok) seg.link = new URL(String(d.link)).href; else warnings.push('"link" must be an http(s) URL with no credentials; ignored.');
315
+ }
316
+ return { seg, warnings };
317
+ }
318
+
319
+ /**
320
+ * Data that is not a status row -> the paths a template could use (scalars, one level deep), for a helpful
321
+ * "try --template" hint. -> ['open', 'connection.connected', ...] (at most `max`).
322
+ */
323
+ export function fieldHints(data, max = 12) {
324
+ const out = [];
325
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return out;
326
+ for (const [k, v] of Object.entries(data)) {
327
+ if (out.length >= max) break;
328
+ if (!/^[A-Za-z0-9_-]+$/.test(k) || BLOCKED_KEYS.has(k)) continue;
329
+ if (v !== null && typeof v !== 'object') out.push(k);
330
+ else if (Array.isArray(v)) out.push(`count(${k})`);
331
+ else if (v && typeof v === 'object') for (const [k2, v2] of Object.entries(v)) {
332
+ if (out.length >= max) break;
333
+ if (/^[A-Za-z0-9_-]+$/.test(k2) && !BLOCKED_KEYS.has(k2) && v2 !== null && typeof v2 !== 'object') out.push(`${k}.${k2}`);
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+
339
+ /** seg -> the one-line text (screen context, /row list, tests): "value text trend". */
340
+ export const segText = (seg) => [seg.value, seg.text, seg.trend].filter(Boolean).join(' ');
341
+
342
+ /**
343
+ * A row + the raw text its source returned -> { ok, text, seg, warnings } | { ok: false, error }.
344
+ * With a template: the template fills from the data. Without one: the status-row contract.
345
+ */
346
+ export function rowValue(row, raw) {
347
+ // Empty output is not a value (owner frame 2026-10-17: "QPE │" drew blank). The engine keeps the last good value.
348
+ if (!cleanText(raw)) return { ok: false, error: 'no output' };
349
+ const data = dataFrom(raw);
350
+ if (row.template) {
351
+ const text = evalTemplate(row.template, data);
352
+ return text.trim() ? { ok: true, text, seg: null, warnings: [] } : { ok: false, error: 'no output' };
353
+ }
354
+ const p = parseStatusRow(data);
355
+ if (p.error) return { ok: false, error: p.error };
356
+ return { ok: true, text: segText(p.seg), seg: p.seg, warnings: p.warnings };
357
+ }
358
+
359
+ /* ---- row records -------------------------------------------------------------------------- */
360
+
361
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,23}$/;
362
+ export const validId = (s) => ID_RE.test(String(s ?? ''));
363
+ export const SOURCE_TYPES = ['url', 'cmd', 'quox', 'plugin'];
364
+ export const QUOX_SERVICES = ['auth', 'collector'];
365
+ const PLUGIN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
366
+
367
+ /** A quox or plugin path -> an error string or null. GET only, no traversal, no other host. */
368
+ function badPath(path, { absolute }) {
369
+ if (!path) return 'The path is empty.';
370
+ if (path.length > 300) return 'That path is too long (300 characters).';
371
+ if (/[\s#\\]/.test(path) || /[\u0000-\u001f\u007f]/.test(path)) return 'A path has no spaces, # or backslashes.';
372
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith('//')) return 'A path, not a URL: it is read from your own Quox.';
373
+ if (path.split(/[/?]/).some((seg) => seg === '..' || seg === '.' || /%2e%2e|%2f/i.test(seg))) return 'A path cannot step out with .. or encoded slashes.';
374
+ if (absolute && !path.startsWith('/api/')) return 'A quox path starts with /api/.';
375
+ if (!/^[A-Za-z0-9._~\-/?=&%{}:,+]+$/.test(path)) return 'That path has characters a row path cannot use.';
376
+ return null;
377
+ }
378
+
379
+ /** True when a row reads data that is safe to sync and share (no shell). */
380
+ export const syncable = (r) => r?.source?.type !== 'cmd';
381
+
382
+ // Names a secret-bearing query parameter, or a value that looks like a token.
383
+ const SECRET_KEY_RE = /^(.*[_-])?(token|access[_-]?token|api[_-]?key|apikey|key|secret|password|passwd|pwd|sig|signature|auth|authorization|session|sessionid|jwt|bearer|credential|creds)$/i;
384
+ const SECRET_VAL_RE = /^(eyJ[A-Za-z0-9_-]{8,}\.|(sk|pk|ghp|gho|xox[abp]|qmcp)[-_][A-Za-z0-9]|[A-Fa-f0-9]{32,}$|[A-Za-z0-9+/_-]{40,}={0,2}$)/;
385
+
386
+ /**
387
+ * Never put a secret in a row definition (it syncs to your account and can be shared). -> a reason or null.
388
+ * Checked when a row is ADDED, EDITED, IMPORTED or SHARED; a stored row is never silently dropped for it.
389
+ */
390
+ export function secretInRow(v) {
391
+ const src = v?.source || {};
392
+ const target = src.type === 'url' ? src.url : (src.type === 'quox' || src.type === 'plugin') ? src.path : null;
393
+ if (typeof target === 'string') {
394
+ let q = '';
395
+ try { q = src.type === 'url' ? new URL(target).search : (target.split('?')[1] || ''); } catch { q = ''; }
396
+ for (const [k, val] of new URLSearchParams(q)) {
397
+ if (SECRET_KEY_RE.test(k)) return `The "${k}" parameter looks like a secret. Rows never carry secrets: use a ${src.type === 'url' ? 'quox or plugin row (your session is used)' : 'path without it'} or a local command that reads the secret itself.`;
398
+ if (SECRET_VAL_RE.test(val)) return `The value of "${k}" looks like a token. Rows never carry secrets.`;
399
+ }
400
+ }
401
+ if (src.type === 'cmd' && typeof src.cmd === 'string' && /(authorization:\s*bearer\s+\S{8,}|\b(sk|ghp|xox[abp]|qmcp)[-_][A-Za-z0-9]{8,}|\beyJ[A-Za-z0-9_-]{8,}\.)/i.test(src.cmd))
402
+ return 'That command has a token written into it. Read the secret inside your script (from a file or the vault), never in the row.';
403
+ return null;
404
+ }
405
+
406
+ /** Validate one row -> { row } | { error }. Strict: used for typed input and for everything stored. */
407
+ export function checkRow(v) {
408
+ const id = typeof v?.id === 'string' ? v.id.trim() : '';
409
+ if (!validId(id)) return { error: 'Row ids are letters, digits, - and _ (24 at most).' };
410
+ const src = v?.source;
411
+ const every = src?.every === undefined ? '60s' : String(src.every).trim();
412
+ if (parseEvery(every) === null) return { error: `"${every}" is not an interval. Use 30s, 5m or 1h (5s to 1 day).` };
413
+ let source;
414
+ if (src?.type === 'url') {
415
+ let u;
416
+ try { u = new URL(String(src.url)); } catch { return { error: 'That is not a URL.' }; }
417
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return { error: 'Only http and https URLs.' };
418
+ if (u.username || u.password) return { error: 'Put no credentials in the URL; none are sent.' };
419
+ if (String(src.url).length > 500) return { error: 'That URL is too long.' };
420
+ source = { type: 'url', url: u.href, every };
421
+ } else if (src?.type === 'cmd') {
422
+ const cmd = typeof src.cmd === 'string' ? src.cmd.trim() : '';
423
+ if (!cmd || cmd.length > 500) return { error: 'The command must be 1 to 500 characters.' };
424
+ source = { type: 'cmd', cmd, every };
425
+ } else if (src?.type === 'quox') {
426
+ const service = src.service === undefined ? 'auth' : String(src.service);
427
+ if (!QUOX_SERVICES.includes(service)) return { error: `A quox row reads the ${QUOX_SERVICES.join(' or ')} service.` };
428
+ const path = typeof src.path === 'string' ? src.path.trim() : '';
429
+ const bad = badPath(path, { absolute: true });
430
+ if (bad) return { error: bad };
431
+ source = { type: 'quox', service, path, every };
432
+ } else if (src?.type === 'plugin') {
433
+ const plugin = typeof src.plugin === 'string' ? src.plugin.trim() : '';
434
+ if (!PLUGIN_ID_RE.test(plugin)) return { error: 'A plugin id is letters, digits, dots, - and _ (64 at most).' };
435
+ const path = typeof src.path === 'string' ? src.path.trim().replace(/^\/+/, '') : '';
436
+ const bad = badPath(path, { absolute: false });
437
+ if (bad) return { error: bad };
438
+ source = { type: 'plugin', plugin, path, every };
439
+ } else return { error: `A row source is one of ${SOURCE_TYPES.join(', ')}.` };
440
+ // No template = the status-row contract ({ text, value?, tone?, trend?, link? }, or plain text).
441
+ const template = typeof v.template === 'string' ? v.template.trim() : '';
442
+ if (template.length > 300) return { error: 'The template is too long (300 characters).' };
443
+ if (template) { const bad = checkTemplate(template); if (bad) return { error: bad }; }
444
+ const row = { id, source };
445
+ if (template) row.template = template;
446
+ const label = typeof v.label === 'string' ? v.label.trim().slice(0, 24) : '';
447
+ if (label) row.label = label;
448
+ if (v.colour !== undefined && v.colour !== null && v.colour !== '') {
449
+ if (!ROLES.includes(v.colour)) return { error: `Colour is one of ${ROLES.join(', ')}.` };
450
+ if (v.colour !== 'prose') row.colour = v.colour;
451
+ }
452
+ return { row };
453
+ }
454
+
455
+ /**
456
+ * Anything stored or received -> clean rows (bad ones dropped, duplicate ids dropped, capped).
457
+ * cmd rows survive ONLY with { cmd: true }; the server copy is always read without it.
458
+ */
459
+ export function normRows(v, { cmd = false } = {}) {
460
+ const out = [];
461
+ for (const x of Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : []) {
462
+ if (x?.source?.type === 'cmd' && !cmd) continue;
463
+ const r = checkRow(x);
464
+ if (r.row && !out.some((o) => o.id === r.row.id) && out.length < MAX_ROWS) out.push(r.row);
465
+ }
466
+ return out;
467
+ }
468
+
469
+ /** url = every row that syncs (url, quox, plugin); cmd = this machine only. */
470
+ export const splitRows = (rows) => ({
471
+ url: rows.filter(syncable),
472
+ cmd: rows.filter((r) => !syncable(r))
473
+ });
474
+ export const rowLabel = (r) => r.label || r.id;
475
+
476
+ /* ---- /row argument parsing ---------------------------------------------------------------- */
477
+
478
+ /** Quote-aware split: "double" and 'single' quotes group, \" escapes. -> tokens, or null on an open quote. */
479
+ export function tokenize(s) {
480
+ const out = [];
481
+ let i = 0;
482
+ const str = String(s ?? '');
483
+ while (i < str.length) {
484
+ if (/\s/.test(str[i])) { i++; continue; }
485
+ let tok = '';
486
+ if (str[i] === '"' || str[i] === "'") {
487
+ const q = str[i++];
488
+ let closed = false;
489
+ while (i < str.length) {
490
+ if (str[i] === '\\' && i + 1 < str.length && (str[i + 1] === q || str[i + 1] === '\\')) { tok += str[i + 1]; i += 2; continue; }
491
+ if (str[i] === q) { closed = true; i++; break; }
492
+ tok += str[i++];
493
+ }
494
+ if (!closed) return null;
495
+ } else while (i < str.length && !/\s/.test(str[i])) tok += str[i++];
496
+ out.push(tok);
497
+ }
498
+ return out;
499
+ }
500
+
501
+ /**
502
+ * The words after /row -> { kind, ... }. kinds: list add rm on off test error.
503
+ * add: /row add <id> url <url> "<template>" [every 60s] [label X] [colour role]
504
+ * /row add <id> cmd "<shell cmd>" "<template>" [every 60s] ...
505
+ */
506
+ export function parseRowCmd(arg) {
507
+ const toks = tokenize(arg);
508
+ if (!toks) return { kind: 'error', text: 'A quote is not closed.' };
509
+ const sub = (toks[0] || 'list').toLowerCase();
510
+ if (sub === 'list' || sub === 'ls') return { kind: 'list' };
511
+ if (sub === 'on' || sub === 'off') return { kind: sub };
512
+ if (['rm', 'remove', 'del', 'delete'].includes(sub))
513
+ return toks[1] ? { kind: 'rm', id: toks[1] } : { kind: 'error', text: 'Usage: /row rm <id>' };
514
+ if (sub === 'test') return toks[1] ? { kind: 'test', id: toks[1] } : { kind: 'error', text: 'Usage: /row test <id>' };
515
+ if (sub === 'new') return { kind: 'new' };
516
+ if (sub === 'reload') return { kind: 'reload' };
517
+ if (sub === 'edit') return toks[1] ? { kind: 'edit', id: toks[1] } : { kind: 'error', text: 'Usage: /row edit <id>' };
518
+ if (sub === 'add') {
519
+ const USAGE = 'Usage: /row add <id> url <url> "<template>" [every 60s] or /row add <id> cmd "<shell cmd>" "<template>"';
520
+ let [, id, type, target, template, ...rest] = toks;
521
+ // No template (nothing after the target, or straight into an option) = the status-row contract.
522
+ if (template !== undefined && ['every', 'label', 'colour', 'color'].includes(template.toLowerCase()) && rest.length % 2 === 1) { rest = [template, ...rest]; template = ''; }
523
+ if (template === undefined && target !== undefined) template = '';
524
+ if (!id || !target || (type !== 'url' && type !== 'cmd')) return { kind: 'error', text: USAGE };
525
+ const opts = {};
526
+ for (let i = 0; i < rest.length; i += 2) {
527
+ const k = (rest[i] || '').toLowerCase();
528
+ if (!['every', 'label', 'colour', 'color'].includes(k) || rest[i + 1] === undefined)
529
+ return { kind: 'error', text: `Unexpected "${rest[i]}". Options: every 60s, label <text>, colour <role>.` };
530
+ opts[k === 'color' ? 'colour' : k] = rest[i + 1];
531
+ }
532
+ const row = { id, source: { type, ...(type === 'url' ? { url: target } : { cmd: target }), ...(opts.every ? { every: opts.every } : {}) } };
533
+ if (template) row.template = template;
534
+ if (opts.label) row.label = opts.label;
535
+ if (opts.colour) row.colour = opts.colour;
536
+ return { kind: 'add', row };
537
+ }
538
+ return { kind: 'error', text: 'Usage: /row [new | add | list | edit <id> | rm <id> | test <id> | reload | on | off]' };
539
+ }
540
+
541
+ /* ---- fetching and running ----------------------------------------------------------------- */
542
+
543
+ /** Plain fetch of an http(s) URL, no credentials, 15 s timeout, body capped. -> text. Throws a short reason. */
544
+ export async function plainFetch(url, { timeoutMs = URL_TIMEOUT_MS, fetchImpl = globalThis.fetch } = {}) {
545
+ let u;
546
+ try { u = new URL(url); } catch { throw new Error('bad URL'); }
547
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('only http(s)');
548
+ const ctl = new AbortController();
549
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
550
+ try {
551
+ const r = await fetchImpl(u.href, { signal: ctl.signal, redirect: 'follow', credentials: 'omit', headers: { accept: 'application/json, text/plain;q=0.9, */*;q=0.5' } });
552
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
553
+ const text = await r.text();
554
+ const t0 = text.trimStart();
555
+ if (t0.startsWith('{') || t0.startsWith('[')) {
556
+ if (text.length > JSON_CAP) throw new Error(`response too large (${Math.round(text.length / 1024)} KB)`);
557
+ return text;
558
+ }
559
+ return text.length > OUTPUT_CAP * 4 ? text.slice(0, OUTPUT_CAP * 4) : text;
560
+ } catch (e) {
561
+ if (ctl.signal.aborted) throw new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`);
562
+ if (/^HTTP \d+|^response too large/.test(e?.message)) throw e;
563
+ const code = e?.cause?.code || e?.code || '';
564
+ throw new Error(code ? String(code) : 'unreachable');
565
+ } finally { clearTimeout(t); }
566
+ }
567
+
568
+ /** Run a shell command via the user's shell: timeout, output cap. -> stdout. Throws a short reason. */
569
+ export function runShell(cmd, { timeoutMs = FETCH_TIMEOUT_MS, shell = process.env.SHELL || '/bin/sh', exec = execFile } = {}) {
570
+ return new Promise((resolve, reject) => {
571
+ exec(shell, ['-c', cmd], { timeout: timeoutMs, maxBuffer: OUTPUT_CAP, killSignal: 'SIGKILL', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, (err, stdout) => {
572
+ if (!err) return resolve(String(stdout ?? ''));
573
+ if (err.killed || err.signal === 'SIGKILL') return reject(new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`));
574
+ if (/maxBuffer/i.test(err.message || '') || err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return reject(new Error('output too large'));
575
+ reject(new Error(typeof err.code === 'number' ? `exit ${err.code}` : 'could not run'));
576
+ });
577
+ });
578
+ }
579
+
580
+ /* ---- the engine --------------------------------------------------------------------------- */
581
+
582
+ /** Next retry delay after `fails` consecutive failures: 5 s doubling, capped at 10 minutes. */
583
+ export const backoffMs = (fails) => Math.min(BACKOFF_CAP_MS, 5000 * 2 ** Math.max(0, fails - 1));
584
+
585
+ const sameRow = (a, b) => JSON.stringify(a) === JSON.stringify(b);
586
+
587
+ /**
588
+ * Owns the polling. Injected: fetchUrl(url) -> text, runCmd(cmd) -> text, onChange(), timer fns (tests).
589
+ * Never blocks input: every poll is async and a failure is state, not an exception.
590
+ */
591
+ export function createRowEngine({ fetchUrl, runCmd, fetchQuox = () => Promise.reject(new Error('not available here')), onChange = () => {}, now = Date.now, setT = setTimeout, clearT = clearTimeout }) {
592
+ let rows = [];
593
+ let enabled = true;
594
+ let stopped = false;
595
+ const st = new Map(); // id -> { row, status, text, error, fails, timer, running }
596
+
597
+ const note = () => { try { onChange(); } catch { /* a render hiccup never kills polling */ } };
598
+
599
+ async function probe(row) {
600
+ let raw;
601
+ try {
602
+ const t = row.source.type;
603
+ raw = t === 'url' ? await fetchUrl(row.source.url) : t === 'cmd' ? await runCmd(row.source.cmd) : await fetchQuox(row.source);
604
+ } catch (e) { return { ok: false, error: cleanText(e?.message || 'failed', 60) || 'failed' }; }
605
+ const v = rowValue(row, raw);
606
+ return v.ok ? { ok: true, raw: String(raw ?? ''), text: v.text, seg: v.seg, warnings: v.warnings } : { ok: false, raw: String(raw ?? ''), error: v.error.slice(0, 60) };
607
+ }
608
+
609
+ function arm(s, ms) {
610
+ clearT(s.timer);
611
+ if (stopped || !enabled) { s.timer = null; return; }
612
+ s.timer = setT(() => poll(s), ms);
613
+ s.timer?.unref?.();
614
+ }
615
+
616
+ async function poll(s) {
617
+ if (stopped || !enabled || s.running || st.get(s.row.id) !== s) return;
618
+ s.running = true;
619
+ const r = await probe(s.row);
620
+ s.running = false;
621
+ if (stopped || st.get(s.row.id) !== s) return; // removed or replaced while in flight
622
+ if (r.ok) { s.status = 'ok'; s.text = r.text; s.seg = r.seg || null; s.error = null; s.fails = 0; s.at = now(); arm(s, parseEvery(s.row.source.every) ?? 60000); }
623
+ else { // s.text and s.at stay: the last good value is still shown
624
+ s.status = 'error'; s.error = r.error; s.fails += 1; arm(s, backoffMs(s.fails)); }
625
+ note();
626
+ }
627
+
628
+ return {
629
+ /** Replace the row set. Unchanged rows keep their state; new or edited ones start at once. */
630
+ setRows(next) {
631
+ rows = next.slice(0, MAX_ROWS);
632
+ const ids = new Set(rows.map((r) => r.id));
633
+ for (const [id, s] of st) if (!ids.has(id)) { clearT(s.timer); st.delete(id); }
634
+ for (const row of rows) {
635
+ const cur = st.get(row.id);
636
+ if (cur && sameRow(cur.row, row)) continue;
637
+ if (cur) clearT(cur.timer);
638
+ const s = { row, status: 'loading', text: '', seg: null, error: null, fails: 0, timer: null, running: false };
639
+ st.set(row.id, s);
640
+ if (enabled && !stopped) setT(() => poll(s), 0)?.unref?.();
641
+ }
642
+ note();
643
+ },
644
+ setEnabled(on) {
645
+ enabled = Boolean(on);
646
+ for (const s of st.values()) {
647
+ if (!enabled) { clearT(s.timer); s.timer = null; }
648
+ else if (!s.running) { s.status = s.status === 'ok' ? 'ok' : 'loading'; setT(() => poll(s), 0)?.unref?.(); }
649
+ }
650
+ note();
651
+ },
652
+ isEnabled: () => enabled,
653
+ /** The rows to draw, in order: [{ row, status, text, error }]. Empty when hidden. */
654
+ view() { return enabled ? rows.map((r) => st.get(r.id)).filter(Boolean) : []; },
655
+ state: (id) => st.get(id) || null,
656
+ probe,
657
+ stop() { stopped = true; for (const s of st.values()) clearT(s.timer); }
658
+ };
659
+ }
660
+
661
+ const ageMark = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`; };
662
+
663
+ /** One row's display -> { text, role }. Pure; the caller clips to the width. */
664
+ export function rowDisplay({ row, status, text, error, at }, now = Date.now()) {
665
+ if (status === 'error' && text && at != null && now - at <= STALE_MAX_MS)
666
+ return { text: `${text} · ${ageMark(now - at)} old`, role: row.colour || 'prose' };
667
+ if (status === 'error') return { text: `⚠ ${rowLabel(row)}: ${String(error || 'failed').slice(0, 40)}`, role: 'error' };
668
+ if (status === 'loading' || !text) return { text: `${rowLabel(row)} …`, role: row.colour || 'prose' };
669
+ return { text, role: row.colour || 'prose' };
670
+ }
671
+
672
+ /** The width a label column takes for these rows (labels align across rows), 0 when none has a label. */
673
+ export const labelWidth = (states) => Math.min(16, Math.max(0, ...(states || []).map((st) => [...String(st?.row?.label || st?.seg?.label || '')].length)));
674
+
675
+ /**
676
+ * One row as coloured segments -> [[text, role, bold?]]. A row without a label or contract fields draws exactly
677
+ * as rowDisplay always has (one run); a labelled or contract row reads `✔ Label │ value text ▲3`. Roles are
678
+ * palette roles (never a grey): label for the name and separators, value for the number, the tone for the text.
679
+ */
680
+ export function rowParts(st, { now = Date.now(), labelW = 0 } = {}) {
681
+ // Never a bare label: an ok state with nothing to show draws as still loading ("QPE …").
682
+ if (st.status === 'ok' && !String(st.text ?? '').trim() && !st.seg?.value && !st.seg?.text) st = { ...st, status: 'loading' };
683
+ const d = rowDisplay(st, now);
684
+ const { row, seg } = st;
685
+ const live = st.status === 'ok' || (st.status === 'error' && d.role !== 'error');
686
+ if (!live || st.status === 'loading') return [[d.text, d.role]];
687
+ const label = row.label || seg?.label || '';
688
+ if (seg?.cells?.length) { // TUI-QPE: one cells row on its own (quox rows test); RowsBar uses layoutRows
689
+ const out = label ? [[label + ' ', 'brand', true]] : [];
690
+ seg.cells.forEach((c, i) => { if (i) out.push([CELL_SEP, 'label']); for (const p of c.parts) out.push(partSeg(p)); });
691
+ if (st.status === 'error') out.push([` · ${ageMark(now - st.at)} old`, 'warn']);
692
+ return out;
693
+ }
694
+ const toneR = seg?.tone ? toneRole(seg.tone) : null;
695
+ if (!label && !seg?.tone && !seg?.value && !seg?.trend) return [[d.text, d.role]];
696
+ const out = [];
697
+ if (seg?.tone && TONE_GLYPH[seg.tone]) out.push([TONE_GLYPH[seg.tone] + ' ', toneR]);
698
+ if (label) out.push([label.padEnd(Math.max(labelW, [...label].length)), 'label', true], [' │ ', 'label']);
699
+ const role = toneR || row.colour || 'prose';
700
+ if (seg) {
701
+ if (seg.value) out.push([seg.value, 'value', true]);
702
+ if (seg.text) out.push([(seg.value ? ' ' : '') + seg.text, role]);
703
+ if (seg.trend) out.push([' ' + seg.trend, 'label']);
704
+ } else out.push([st.text, role]);
705
+ if (st.status === 'error') out.push([` · ${ageMark(now - st.at)} old`, 'warn']);
706
+ return out;
707
+ }
708
+
709
+ /** Fit segments into `width` columns (code points), ending in … when cut. */
710
+ export function clipParts(parts, width) {
711
+ const out = []; let used = 0;
712
+ const w = Math.max(1, width);
713
+ for (const part of parts) {
714
+ const [t, role, bold] = part;
715
+ const cs = [...String(t)];
716
+ if (used + cs.length <= w) { out.push(part); used += cs.length; continue; }
717
+ const room = w - used - 1;
718
+ if (room > 0) out.push([cs.slice(0, room).join(''), role, bold]);
719
+ out.push(['…', role, bold]);
720
+ return out;
721
+ }
722
+ return out;
723
+ }
724
+
725
+ /* ---- cells layout (TUI-QPE) ---------------------------------------------------------------- */
726
+
727
+ export const CELL_SEP = ' │ ';
728
+ const W = (t) => cellWidth(String(t));
729
+ const cellW = (c) => c.parts.reduce((a, p) => a + W(p.text), 0);
730
+ const cellsW = (cs) => cs.reduce((a, c, i) => a + cellW(c) + (i ? W(CELL_SEP) : 0), 0);
731
+
732
+ /**
733
+ * cells -> the cells that fit in `room` columns. Whole items go, lowest `keep` first (a tie drops the rightmost); an
734
+ * item with no keep stays. If even the kept items do not fit, the line ends at a word boundary with … (never mid-word).
735
+ */
736
+ /** Drop the one lowest-keep item (a tie drops the rightmost; a part takes its group with it). -> new cells, or null. */
737
+ export function dropLowest(cells) {
738
+ let best = null;
739
+ cells.forEach((c, i) => {
740
+ const cand = (keep, pos, j) => { if (keep == null) return; if (!best || keep < best.keep || (keep === best.keep && pos > best.pos)) best = { keep, pos, i, j }; };
741
+ cand(c.keep, i * 1000 + 999, -1);
742
+ c.parts.forEach((p, j) => cand(p.keep, i * 1000 + j, j));
743
+ });
744
+ if (!best) return null;
745
+ const cs = cells.map((c) => ({ ...c, parts: [...c.parts] }));
746
+ if (best.j < 0) cs.splice(best.i, 1);
747
+ else {
748
+ const c = cs[best.i]; const g = c.parts[best.j].group;
749
+ c.parts = lead(g ? c.parts.filter((p) => p.group !== g) : c.parts.filter((_, j) => j !== best.j));
750
+ if (!c.parts.length) cs.splice(best.i, 1);
751
+ }
752
+ return cs;
753
+ }
754
+
755
+ export function fitCells(cells, room) {
756
+ let cs = (cells || []).map((c) => ({ ...c, parts: c.parts.map((p) => ({ ...p })) }));
757
+ while (cellsW(cs) > room) { const next = dropLowest(cs); if (!next) break; cs = next; }
758
+ if (cellsW(cs) <= room) return { cells: cs, clipped: false };
759
+ // Still too wide with only the must-keep items: cut at the last word that fits.
760
+ const out = []; let used = 0; const max = Math.max(1, room - 1);
761
+ for (let i = 0; i < cs.length; i++) {
762
+ const parts = [];
763
+ if (i) { if (used + W(CELL_SEP) > max) break; used += W(CELL_SEP); }
764
+ let full = true;
765
+ for (const p of cs[i].parts) {
766
+ const w = W(p.text);
767
+ if (used + w <= max) { parts.push(p); used += w; continue; }
768
+ const words = p.text.split(/(\s+)/); let t = '';
769
+ for (const wd of words) { if (used + W(t + wd) > max) break; t += wd; }
770
+ t = t.replace(/\s+$/, '');
771
+ if (t.trim()) { parts.push({ ...p, text: t }); used += W(t); } // whole words only, so this is a word boundary
772
+ full = false; break;
773
+ }
774
+ if (parts.length) out.push({ ...cs[i], parts });
775
+ if (!full) break;
776
+ }
777
+ if (!out.length) out.push({ parts: [{ text: '' }] });
778
+ const last = out[out.length - 1].parts; last[last.length - 1] = { ...last[last.length - 1], text: last[last.length - 1].text.replace(/\s+$/, '') };
779
+ last.push({ text: '…' });
780
+ return { cells: out, clipped: true };
781
+ }
782
+
783
+ /** A cell whose first item was dropped must not start with its joiner (" · CLI 578/958"): trim a leading " · ". */
784
+ const lead = (parts) => (parts.length && /^\s*·\s*/.test(parts[0].text) ? [{ ...parts[0], text: parts[0].text.replace(/^\s*·\s*/, '') }, ...parts.slice(1)] : parts);
785
+
786
+ /** One part -> a [text, role, bold] segment (the RowsBar shape). Tones are palette roles, never grey. */
787
+ const partSeg = (p) => [p.text, p.tone ? toneRole(p.tone) : 'prose', Boolean(p.bold)];
788
+
789
+ /** States whose seg has cells and something to show. */
790
+ const hasCells = (st) => Boolean(st?.seg?.cells?.length) && (st.status === 'ok' || (st.status === 'error' && st.text));
791
+
792
+ /**
793
+ * The rows RowsBar draws at `cols`, in order -> [{ st, parts }]: rows below their min_cols are left out; cells rows
794
+ * are fitted, and rows sharing a `grid` share a lead (label) column and pad their cells so the separators line up.
795
+ * Other rows keep the classic rowParts + clipParts path, unchanged.
796
+ */
797
+ /**
798
+ * Rows -> one state per drawn line: a row whose seg has `lines` becomes one state per line (ids `<id>.<n>`, the row's
799
+ * label on the first only, the row's grid unless a line names its own). Other rows pass through unchanged.
800
+ */
801
+ export function expandLines(states) {
802
+ const out = [];
803
+ for (const st of states || []) {
804
+ const ls = st?.seg?.lines;
805
+ if (!ls?.length || !(st.status === 'ok' || (st.status === 'error' && st.text))) { out.push(st); continue; }
806
+ ls.forEach((seg, i) => out.push({ ...st, row: { ...st.row, id: `${st.row.id}.${i + 1}`, label: i === 0 ? st.row.label : '' },
807
+ text: seg.text, seg: { ...seg, grid: seg.grid || st.seg.grid || `#${st.row.id}`, ...(i === 0 ? {} : { label: undefined }) } }));
808
+ }
809
+ return out;
810
+ }
811
+
812
+ export function layoutRows(states, cols, { now = Date.now() } = {}) {
813
+ const shown = expandLines(states).filter((st) => !(st?.seg?.minCols && cols < st.seg.minCols)).slice(0, MAX_LINES);
814
+ const room = Math.max(1, cols - 2);
815
+ const labelW = labelWidth(shown.filter((st) => !hasCells(st)));
816
+ const groups = new Map();
817
+ for (const st of shown) if (hasCells(st)) { const g = st.seg.grid || `#${st.row.id}`; if (!groups.has(g)) groups.set(g, []); groups.get(g).push(st); }
818
+ const done = new Map();
819
+ for (const list of groups.values()) {
820
+ const leadOf = (st) => String(st.row.label || st.seg.label || '');
821
+ const leadW = Math.min(16, Math.max(0, ...list.map((st) => W(leadOf(st)))));
822
+ const leadCols = leadW ? leadW + 1 : 0;
823
+ const roomL = room - leadCols;
824
+ const base = list.map((st) => {
825
+ const cells = st.seg.cells.map((c) => ({ ...c, parts: [...c.parts] }));
826
+ return cells;
827
+ });
828
+ // `keep` is ONE priority across the grid. The layout drops the shortest prefix of the grid-wide order (lowest keep
829
+ // first; a tie drops the rightmost, then the lower row) that lets every row fit with its columns lined up. A wider
830
+ // window can only shorten that prefix, so it never shows less (2026-10-20: a greedy per-row pass did, at 68/69 cols).
831
+ const units = [];
832
+ base.forEach((cells, r) => cells.forEach((c, i) => {
833
+ if (c.keep != null) units.push({ r, i, j: -1, keep: c.keep, pos: i * 1000 + 999 });
834
+ const seenG = new Set();
835
+ c.parts.forEach((p, j) => {
836
+ if (p.keep == null) return;
837
+ if (p.group) { if (seenG.has(p.group)) return; seenG.add(p.group); }
838
+ units.push({ r, i, j, group: p.group, keep: p.keep, pos: i * 1000 + j });
839
+ });
840
+ }));
841
+ units.sort((x, y) => x.keep - y.keep || y.pos - x.pos || y.r - x.r);
842
+ const build = (k) => {
843
+ const gone = units.slice(0, k);
844
+ return base.map((cells, r) => cells.map((c, i) => {
845
+ if (gone.some((u) => u.r === r && u.i === i && u.j < 0)) return null;
846
+ const parts = c.parts.filter((p, j) => !gone.some((u) => u.r === r && u.i === i && u.j >= 0 && (u.group ? p.group === u.group : u.j === j)));
847
+ return parts.length ? { ...c, parts: lead(parts) } : null;
848
+ }).filter(Boolean));
849
+ };
850
+ const colWidths = (rows) => { const w = []; for (const cs of rows) cs.forEach((c, i) => { if (i < cs.length - 1) w[i] = Math.max(w[i] || 0, cellW(c)); }); return w; };
851
+ const alignedW = (cs, w) => cs.reduce((a, c, i) => a + (i < cs.length - 1 ? Math.max(w[i] || 0, cellW(c)) : cellW(c)) + (i ? W(CELL_SEP) : 0), 0);
852
+ let rows = null;
853
+ for (let k = 0; k <= units.length; k++) {
854
+ const cand = build(k);
855
+ // A row whose must-keep items alone are too wide is cut at a word below and left out of the column widths.
856
+ const over = cand.map((cs) => cellsW(cs) > roomL);
857
+ const w = colWidths(cand.filter((_, r) => !over[r]));
858
+ if (cand.every((cs, r) => over[r] ? !units.slice(k).some((u) => u.r === r) : alignedW(cs, w) <= roomL)) { rows = cand; break; }
859
+ }
860
+ rows ??= build(units.length);
861
+ const over = rows.map((cs) => cellsW(cs) > roomL);
862
+ const colW = colWidths(rows.filter((_, r) => !over[r]));
863
+ list.forEach((st, r) => {
864
+ const f = over[r] ? fitCells(rows[r], roomL) : { cells: rows[r], clipped: false };
865
+ const pads = f.cells.map((c, i) => (!f.clipped && i < f.cells.length - 1 ? Math.max(0, (colW[i] || 0) - cellW(c)) : 0));
866
+ const out = [];
867
+ if (leadCols) out.push([leadOf(st) + ' '.repeat(Math.max(0, leadW - W(leadOf(st)))) + ' ', 'brand', true]);
868
+ f.cells.forEach((c, i) => {
869
+ if (i) out.push([CELL_SEP, 'label']);
870
+ for (const p of c.parts) out.push(partSeg(p));
871
+ if (pads[i]) out.push([' '.repeat(pads[i]), 'prose']);
872
+ });
873
+ done.set(st, out);
874
+ });
875
+ // The age of a stale value is said ONCE per grid and only in space the fit left over, never in the fit budget:
876
+ // appended per line it pushed whole items out every minute as the data aged (coordinator 2026-10-20 03:39Z).
877
+ // It goes on the first stale line that has room, else the line with the most room; with no room it is left out.
878
+ const stale = list.filter((st) => st.status === 'error' && st.at != null);
879
+ if (stale.length) {
880
+ const age = ` · ${ageMark(now - Math.min(...stale.map((st) => st.at)))} old`;
881
+ const used = (st) => done.get(st).reduce((a, [t]) => a + W(t), 0);
882
+ const roomOf = (st) => room - used(st);
883
+ const pick = stale.find((st) => roomOf(st) >= W(age)) || [...list].sort((a, b) => roomOf(b) - roomOf(a))[0];
884
+ if (pick && roomOf(pick) >= W(age)) done.get(pick).push([age, 'warn']);
885
+ }
886
+ }
887
+ return shown.map((st) => ({ st, parts: done.get(st) || clipParts(rowParts(st, { now, labelW }), room) }));
888
+ }
889
+
890
+ /** /qpe rows N: keep the first n rows of a grid (0 hides the grid). Rows not in the grid are untouched. */
891
+ export function limitGrid(states, grid, n) {
892
+ if (n == null || !Number.isInteger(n) || n < 0) return states;
893
+ let seen = 0;
894
+ return (states || []).filter((st) => st?.seg?.grid !== grid || ++seen <= n);
895
+ }
896
+
897
+ /* ---- local-only store for cmd rows -------------------------------------------------------- */
898
+
899
+ export const localRowsFile = () => join(homedir(), '.config', 'quox', 'quoxon-rows.local.json');
900
+
901
+ /** The local cmd rows. Never throws. */
902
+ export function loadLocalCmdRows(file = localRowsFile()) {
903
+ try { return normRows(JSON.parse(readFileSync(file, 'utf8')), { cmd: true }).filter((r) => r.source.type === 'cmd'); }
904
+ catch { return []; }
905
+ }
906
+ /** -> true when written. Only cmd rows are ever written here. */
907
+ export function saveLocalCmdRows(rows, file = localRowsFile()) {
908
+ try {
909
+ mkdirSync(dirname(file), { recursive: true });
910
+ writeFileSync(file, JSON.stringify({ rows: rows.filter((r) => r.source.type === 'cmd') }, null, 2), { mode: 0o600 });
911
+ return true;
912
+ } catch { return false; }
913
+ }
914
+
915
+
916
+ /* ---- share and import --------------------------------------------------------------------- */
917
+
918
+ export const BUNDLE_KIND = 'quox.status-rows';
919
+
920
+ /**
921
+ * Rows -> a share bundle. ONLY syncable rows go in: a command row runs a shell on the machine that holds it and is
922
+ * never shared (it is listed in `skipped`). A row that names a secret is refused (in `refused`).
923
+ */
924
+ export function shareBundle(rows) {
925
+ const out = []; const skipped = []; const refused = [];
926
+ for (const r of rows || []) {
927
+ if (!syncable(r)) { skipped.push(r.id); continue; }
928
+ const why = secretInRow(r);
929
+ if (why) { refused.push({ id: r.id, why }); continue; }
930
+ out.push(r);
931
+ }
932
+ return { bundle: { kind: BUNDLE_KIND, version: 1, rows: out }, skipped, refused };
933
+ }
934
+
935
+ /**
936
+ * Untrusted JSON -> { rows, commands, errors }. rows are syncable and valid; commands are cmd rows, NEVER added by
937
+ * an import on their own (the caller shows each command and needs a person to say yes to it, one by one).
938
+ */
939
+ export function readBundle(input) {
940
+ let v = input;
941
+ if (typeof v === 'string') { try { v = JSON.parse(v); } catch { return { rows: [], commands: [], errors: ['That is not JSON.'] }; } }
942
+ const list = Array.isArray(v) ? v : v && typeof v === 'object' && (v.kind === undefined || v.kind === BUNDLE_KIND) && Array.isArray(v.rows) ? v.rows : null;
943
+ if (!list) return { rows: [], commands: [], errors: [`Not a status-row bundle (expected {"kind":"${BUNDLE_KIND}","rows":[...]}).`] };
944
+ const rows = []; const commands = []; const errors = [];
945
+ for (const x of list.slice(0, 20)) {
946
+ const c = checkRow(x);
947
+ const id = cleanText(x?.id, 24) || '?';
948
+ if (c.error) { errors.push(`${id}: ${c.error}`); continue; }
949
+ const why = secretInRow(c.row);
950
+ if (why) { errors.push(`${id}: ${why}`); continue; }
951
+ (syncable(c.row) ? rows : commands).push(c.row);
952
+ }
953
+ return { rows, commands, errors };
954
+ }