quoxcli 0.0.0-stage → 0.3.1

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 (516) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +144 -2
  3. package/bin/quox.js +265 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +151 -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/agent-schedules.js +26 -0
  9. package/lib/api/endpoints/agents.js +63 -0
  10. package/lib/api/endpoints/assistants.js +62 -0
  11. package/lib/api/endpoints/audit.js +14 -0
  12. package/lib/api/endpoints/auth.js +50 -0
  13. package/lib/api/endpoints/awareness.js +34 -0
  14. package/lib/api/endpoints/away.js +15 -0
  15. package/lib/api/endpoints/backup-schedules.js +20 -0
  16. package/lib/api/endpoints/backups.js +86 -0
  17. package/lib/api/endpoints/brain.js +140 -0
  18. package/lib/api/endpoints/budgets.js +62 -0
  19. package/lib/api/endpoints/calendarq.js +71 -0
  20. package/lib/api/endpoints/catalog.js +14 -0
  21. package/lib/api/endpoints/chat.js +61 -0
  22. package/lib/api/endpoints/consent.js +26 -0
  23. package/lib/api/endpoints/contactsq.js +68 -0
  24. package/lib/api/endpoints/conversations.js +46 -0
  25. package/lib/api/endpoints/custom-roles.js +92 -0
  26. package/lib/api/endpoints/divisions.js +56 -0
  27. package/lib/api/endpoints/domains.js +38 -0
  28. package/lib/api/endpoints/engagement.js +231 -0
  29. package/lib/api/endpoints/engine.js +11 -0
  30. package/lib/api/endpoints/entity.js +50 -0
  31. package/lib/api/endpoints/executions.js +45 -0
  32. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  33. package/lib/api/endpoints/file.js +20 -0
  34. package/lib/api/endpoints/files.js +40 -0
  35. package/lib/api/endpoints/github.js +21 -0
  36. package/lib/api/endpoints/governance.js +43 -0
  37. package/lib/api/endpoints/hat.js +66 -0
  38. package/lib/api/endpoints/inbox.js +52 -0
  39. package/lib/api/endpoints/inboxq.js +120 -0
  40. package/lib/api/endpoints/index.js +242 -0
  41. package/lib/api/endpoints/invites.js +19 -0
  42. package/lib/api/endpoints/llm-policy.js +43 -0
  43. package/lib/api/endpoints/lucid.js +89 -0
  44. package/lib/api/endpoints/members.js +32 -0
  45. package/lib/api/endpoints/memory.js +94 -0
  46. package/lib/api/endpoints/mfa.js +32 -0
  47. package/lib/api/endpoints/mind.js +13 -0
  48. package/lib/api/endpoints/mon.js +184 -0
  49. package/lib/api/endpoints/notesq.js +43 -0
  50. package/lib/api/endpoints/objectives.js +68 -0
  51. package/lib/api/endpoints/orgs.js +38 -0
  52. package/lib/api/endpoints/plugins.js +152 -0
  53. package/lib/api/endpoints/powers.js +75 -0
  54. package/lib/api/endpoints/presence.js +33 -0
  55. package/lib/api/endpoints/prompts.js +69 -0
  56. package/lib/api/endpoints/qlarity.js +122 -0
  57. package/lib/api/endpoints/quoxbox.js +55 -0
  58. package/lib/api/endpoints/quoxchat.js +54 -0
  59. package/lib/api/endpoints/quoxflow.js +32 -0
  60. package/lib/api/endpoints/quoxplan.js +121 -0
  61. package/lib/api/endpoints/quoxseo.js +114 -0
  62. package/lib/api/endpoints/reach.js +47 -0
  63. package/lib/api/endpoints/router.js +17 -0
  64. package/lib/api/endpoints/runs.js +38 -0
  65. package/lib/api/endpoints/safety.js +53 -0
  66. package/lib/api/endpoints/scim.js +46 -0
  67. package/lib/api/endpoints/scopes.js +14 -0
  68. package/lib/api/endpoints/security.js +69 -0
  69. package/lib/api/endpoints/setup.js +90 -0
  70. package/lib/api/endpoints/skills.js +200 -0
  71. package/lib/api/endpoints/sso.js +67 -0
  72. package/lib/api/endpoints/stepup.js +20 -0
  73. package/lib/api/endpoints/tags.js +32 -0
  74. package/lib/api/endpoints/teams.js +56 -0
  75. package/lib/api/endpoints/telephony.js +46 -0
  76. package/lib/api/endpoints/tg.js +56 -0
  77. package/lib/api/endpoints/tools.js +50 -0
  78. package/lib/api/endpoints/traces.js +157 -0
  79. package/lib/api/endpoints/triggers.js +96 -0
  80. package/lib/api/endpoints/user-library.js +41 -0
  81. package/lib/api/endpoints/users.js +20 -0
  82. package/lib/api/endpoints/v1.js +585 -0
  83. package/lib/api/endpoints/vault.js +192 -0
  84. package/lib/api/endpoints/voice.js +22 -0
  85. package/lib/api/endpoints/volt.js +84 -0
  86. package/lib/api/endpoints/ward.js +47 -0
  87. package/lib/api/endpoints/webhooks.js +46 -0
  88. package/lib/api/endpoints/workflows.js +169 -0
  89. package/lib/api/endpoints/x.js +32 -0
  90. package/lib/api/endpoints.js +15 -0
  91. package/lib/commands/actions.js +61 -0
  92. package/lib/commands/admin.js +375 -0
  93. package/lib/commands/aee.js +99 -0
  94. package/lib/commands/agent.js +337 -0
  95. package/lib/commands/agentAttach.js +1528 -0
  96. package/lib/commands/approvals.js +119 -0
  97. package/lib/commands/aside.js +24 -0
  98. package/lib/commands/ask.js +144 -0
  99. package/lib/commands/assistant.js +169 -0
  100. package/lib/commands/auth.js +464 -0
  101. package/lib/commands/awareness.js +88 -0
  102. package/lib/commands/backup.js +377 -0
  103. package/lib/commands/brain.js +1085 -0
  104. package/lib/commands/budget.js +241 -0
  105. package/lib/commands/build.js +170 -0
  106. package/lib/commands/calendar.js +216 -0
  107. package/lib/commands/capabilities.js +34 -0
  108. package/lib/commands/chat.js +295 -0
  109. package/lib/commands/claude.js +380 -0
  110. package/lib/commands/config-cmd.js +106 -0
  111. package/lib/commands/consent.js +84 -0
  112. package/lib/commands/contacts.js +1016 -0
  113. package/lib/commands/contactsSelftest.js +132 -0
  114. package/lib/commands/conversation.js +105 -0
  115. package/lib/commands/dashboard.js +43 -0
  116. package/lib/commands/department.js +96 -0
  117. package/lib/commands/dev.js +620 -0
  118. package/lib/commands/devResident.js +326 -0
  119. package/lib/commands/devSupervisor.js +1062 -0
  120. package/lib/commands/discord.js +132 -0
  121. package/lib/commands/doctor.js +181 -0
  122. package/lib/commands/domain.js +114 -0
  123. package/lib/commands/done.js +347 -0
  124. package/lib/commands/engagement.js +597 -0
  125. package/lib/commands/entity.js +119 -0
  126. package/lib/commands/facebook.js +212 -0
  127. package/lib/commands/fetchAllowlist.js +71 -0
  128. package/lib/commands/file.js +165 -0
  129. package/lib/commands/fleet.js +1189 -0
  130. package/lib/commands/governance.js +647 -0
  131. package/lib/commands/gripe.js +355 -0
  132. package/lib/commands/hat.js +374 -0
  133. package/lib/commands/helpers.js +96 -0
  134. package/lib/commands/hyperion.js +58 -0
  135. package/lib/commands/inbox.js +173 -0
  136. package/lib/commands/instagram.js +264 -0
  137. package/lib/commands/integration.js +100 -0
  138. package/lib/commands/inventory.js +115 -0
  139. package/lib/commands/invites.js +45 -0
  140. package/lib/commands/licence.js +139 -0
  141. package/lib/commands/linkedin.js +178 -0
  142. package/lib/commands/llm-policy.js +117 -0
  143. package/lib/commands/llm.js +45 -0
  144. package/lib/commands/lucid.js +597 -0
  145. package/lib/commands/mail.js +531 -0
  146. package/lib/commands/memory.js +361 -0
  147. package/lib/commands/mind.js +71 -0
  148. package/lib/commands/mon.js +581 -0
  149. package/lib/commands/monitoring.js +86 -0
  150. package/lib/commands/motion.js +22 -0
  151. package/lib/commands/notes.js +1246 -0
  152. package/lib/commands/notesBatch.js +160 -0
  153. package/lib/commands/notesBridges.js +56 -0
  154. package/lib/commands/notesLinks.js +60 -0
  155. package/lib/commands/notification.js +57 -0
  156. package/lib/commands/nucleus.js +123 -0
  157. package/lib/commands/objective.js +240 -0
  158. package/lib/commands/offload.js +89 -0
  159. package/lib/commands/onboard.js +329 -0
  160. package/lib/commands/open.js +56 -0
  161. package/lib/commands/org.js +276 -0
  162. package/lib/commands/peek.js +28 -0
  163. package/lib/commands/plan.js +944 -0
  164. package/lib/commands/plugin-doctor.js +1091 -0
  165. package/lib/commands/plugin.js +649 -0
  166. package/lib/commands/policy.js +78 -0
  167. package/lib/commands/powers.js +296 -0
  168. package/lib/commands/presence.js +89 -0
  169. package/lib/commands/prompt.js +350 -0
  170. package/lib/commands/provider.js +159 -0
  171. package/lib/commands/qlarity.js +706 -0
  172. package/lib/commands/quoxbox.js +270 -0
  173. package/lib/commands/quoxchat.js +275 -0
  174. package/lib/commands/quoxseo.js +378 -0
  175. package/lib/commands/reach.js +159 -0
  176. package/lib/commands/role.js +286 -0
  177. package/lib/commands/router.js +77 -0
  178. package/lib/commands/rows.js +358 -0
  179. package/lib/commands/run.js +187 -0
  180. package/lib/commands/safety.js +107 -0
  181. package/lib/commands/scim.js +88 -0
  182. package/lib/commands/scope.js +58 -0
  183. package/lib/commands/security.js +157 -0
  184. package/lib/commands/server.js +292 -0
  185. package/lib/commands/service.js +89 -0
  186. package/lib/commands/settings.js +99 -0
  187. package/lib/commands/setup.js +569 -0
  188. package/lib/commands/skillManifest.js +113 -0
  189. package/lib/commands/skillRightsFormat.js +38 -0
  190. package/lib/commands/skills.js +687 -0
  191. package/lib/commands/slot.js +1451 -0
  192. package/lib/commands/sso.js +229 -0
  193. package/lib/commands/status.js +137 -0
  194. package/lib/commands/tag.js +91 -0
  195. package/lib/commands/team.js +142 -0
  196. package/lib/commands/tg.js +240 -0
  197. package/lib/commands/tiktok.js +218 -0
  198. package/lib/commands/tips.js +23 -0
  199. package/lib/commands/tool.js +304 -0
  200. package/lib/commands/trace.js +530 -0
  201. package/lib/commands/trigger.js +223 -0
  202. package/lib/commands/update.js +46 -0
  203. package/lib/commands/vault.js +1447 -0
  204. package/lib/commands/vm.js +237 -0
  205. package/lib/commands/voice.js +213 -0
  206. package/lib/commands/volt.js +570 -0
  207. package/lib/commands/walkthrough.js +32 -0
  208. package/lib/commands/watch.js +167 -0
  209. package/lib/commands/webhook.js +137 -0
  210. package/lib/commands/welcome.js +14 -0
  211. package/lib/commands/workflow.js +660 -0
  212. package/lib/commands/x.js +448 -0
  213. package/lib/core/actingAs.js +136 -0
  214. package/lib/core/activeSummary.js +186 -0
  215. package/lib/core/aee.js +53 -0
  216. package/lib/core/agentAlias.js +163 -0
  217. package/lib/core/approvalContext.js +61 -0
  218. package/lib/core/aside.js +54 -0
  219. package/lib/core/askDecision.js +60 -0
  220. package/lib/core/auth.js +501 -0
  221. package/lib/core/banner.js +143 -0
  222. package/lib/core/brainPrime.js +90 -0
  223. package/lib/core/callerSource.js +129 -0
  224. package/lib/core/claudeLs.js +94 -0
  225. package/lib/core/cliBuild.js +62 -0
  226. package/lib/core/client.js +693 -0
  227. package/lib/core/commandRegistrars.js +121 -0
  228. package/lib/core/completions.js +163 -0
  229. package/lib/core/config.js +669 -0
  230. package/lib/core/confirm.js +37 -0
  231. package/lib/core/customHats.js +168 -0
  232. package/lib/core/deeplinks.js +76 -0
  233. package/lib/core/doneEngineFetch.js +227 -0
  234. package/lib/core/errors.js +441 -0
  235. package/lib/core/global-flags.js +190 -0
  236. package/lib/core/governedSession.js +402 -0
  237. package/lib/core/hyperion.js +102 -0
  238. package/lib/core/inboxResolution.js +65 -0
  239. package/lib/core/lazyCommands.js +122 -0
  240. package/lib/core/liveSessionsReport.js +112 -0
  241. package/lib/core/offload.js +504 -0
  242. package/lib/core/org-override.js +52 -0
  243. package/lib/core/orgResolve.js +70 -0
  244. package/lib/core/output.js +239 -0
  245. package/lib/core/peek.js +126 -0
  246. package/lib/core/prodEnv.js +7 -0
  247. package/lib/core/profileUsers.js +51 -0
  248. package/lib/core/promptCancel.js +18 -0
  249. package/lib/core/quoxglyph.js +158 -0
  250. package/lib/core/repoPath.js +11 -0
  251. package/lib/core/residentAlarm.js +282 -0
  252. package/lib/core/residentAuth.js +243 -0
  253. package/lib/core/residentPump.js +229 -0
  254. package/lib/core/residentSeed.js +106 -0
  255. package/lib/core/rootOptions.js +39 -0
  256. package/lib/core/router.js +168 -0
  257. package/lib/core/selfUpdate.js +188 -0
  258. package/lib/core/sessionHeartbeat.js +192 -0
  259. package/lib/core/sessionsOverview.js +224 -0
  260. package/lib/core/slotKeeper.js +527 -0
  261. package/lib/core/slotWake.js +120 -0
  262. package/lib/core/supervisorEngine.js +933 -0
  263. package/lib/core/systemdInstaller.js +256 -0
  264. package/lib/core/version.js +87 -0
  265. package/lib/core/vm.js +361 -0
  266. package/lib/core/vmTui.js +86 -0
  267. package/lib/core/walkthrough.js +35 -0
  268. package/lib/hyperion/deck.js +29 -0
  269. package/lib/lucid/LucidCard.jsx +68 -0
  270. package/lib/lucid/LucidIntro.jsx +92 -0
  271. package/lib/lucid/blind.js +71 -0
  272. package/lib/lucid/cards.js +86 -0
  273. package/lib/lucid/deck.js +32 -0
  274. package/lib/lucid/fmt.js +139 -0
  275. package/lib/lucid/markdown.js +81 -0
  276. package/lib/lucid/motion.js +56 -0
  277. package/lib/lucid/sitrep.js +173 -0
  278. package/lib/plugins/loader.js +93 -0
  279. package/lib/quoxon/activity.js +116 -0
  280. package/lib/quoxon/aocl.js +117 -0
  281. package/lib/quoxon/api.js +952 -0
  282. package/lib/quoxon/app.js +4032 -0
  283. package/lib/quoxon/approvalFacts.js +94 -0
  284. package/lib/quoxon/approvalsView.js +325 -0
  285. package/lib/quoxon/asideView.js +40 -0
  286. package/lib/quoxon/ask.js +121 -0
  287. package/lib/quoxon/away.js +52 -0
  288. package/lib/quoxon/bang.js +224 -0
  289. package/lib/quoxon/bulkRisk.js +17 -0
  290. package/lib/quoxon/capabilities.js +82 -0
  291. package/lib/quoxon/cells.js +53 -0
  292. package/lib/quoxon/commandShort.js +112 -0
  293. package/lib/quoxon/commands.js +479 -0
  294. package/lib/quoxon/connect.js +654 -0
  295. package/lib/quoxon/connectCard.js +45 -0
  296. package/lib/quoxon/consent.js +86 -0
  297. package/lib/quoxon/context.js +68 -0
  298. package/lib/quoxon/copy.js +34 -0
  299. package/lib/quoxon/countdownLine.js +11 -0
  300. package/lib/quoxon/demo.autoplay.js +29 -0
  301. package/lib/quoxon/demo.fixtures.js +24 -0
  302. package/lib/quoxon/demo.js +76 -0
  303. package/lib/quoxon/devObserver.js +20 -0
  304. package/lib/quoxon/dividerAocl.js +191 -0
  305. package/lib/quoxon/dividerRule.js +57 -0
  306. package/lib/quoxon/dragonModes.js +150 -0
  307. package/lib/quoxon/dragonView.js +41 -0
  308. package/lib/quoxon/earlyInput.js +40 -0
  309. package/lib/quoxon/echo.js +15 -0
  310. package/lib/quoxon/egg.js +36 -0
  311. package/lib/quoxon/events.js +677 -0
  312. package/lib/quoxon/feedback.js +50 -0
  313. package/lib/quoxon/gallery.js +237 -0
  314. package/lib/quoxon/handoffOffer.js +78 -0
  315. package/lib/quoxon/hatSwitch.js +37 -0
  316. package/lib/quoxon/headless.js +161 -0
  317. package/lib/quoxon/hostUrls.js +41 -0
  318. package/lib/quoxon/indicator.js +197 -0
  319. package/lib/quoxon/intent.js +52 -0
  320. package/lib/quoxon/keys.js +164 -0
  321. package/lib/quoxon/launcher.js +103 -0
  322. package/lib/quoxon/main.js +176 -0
  323. package/lib/quoxon/markdown.js +341 -0
  324. package/lib/quoxon/markdownSettle.js +19 -0
  325. package/lib/quoxon/matrixDomains.json +46 -0
  326. package/lib/quoxon/mentions.js +90 -0
  327. package/lib/quoxon/mind.js +103 -0
  328. package/lib/quoxon/mode.js +46 -0
  329. package/lib/quoxon/model.js +311 -0
  330. package/lib/quoxon/modelPrefs.js +345 -0
  331. package/lib/quoxon/motion/clock.js +70 -0
  332. package/lib/quoxon/motion/color.js +35 -0
  333. package/lib/quoxon/motion/frame.js +14 -0
  334. package/lib/quoxon/motion/level.js +91 -0
  335. package/lib/quoxon/motion/reveal.js +54 -0
  336. package/lib/quoxon/motion/ring.js +79 -0
  337. package/lib/quoxon/mouse.js +211 -0
  338. package/lib/quoxon/noai.js +57 -0
  339. package/lib/quoxon/objects/agent.js +61 -0
  340. package/lib/quoxon/objects/away.js +72 -0
  341. package/lib/quoxon/objects/crystal.js +100 -0
  342. package/lib/quoxon/objects/fetch.js +182 -0
  343. package/lib/quoxon/objects/fleet.js +149 -0
  344. package/lib/quoxon/objects/health.js +186 -0
  345. package/lib/quoxon/objects/index.js +106 -0
  346. package/lib/quoxon/objects/ladder.js +71 -0
  347. package/lib/quoxon/objects/memory.js +44 -0
  348. package/lib/quoxon/objects/mirror.js +137 -0
  349. package/lib/quoxon/objects/model.js +118 -0
  350. package/lib/quoxon/objects/peek.js +17 -0
  351. package/lib/quoxon/objects/proof.js +110 -0
  352. package/lib/quoxon/objects/prove.js +130 -0
  353. package/lib/quoxon/objects/replay.js +125 -0
  354. package/lib/quoxon/objects/sitrep.js +58 -0
  355. package/lib/quoxon/objects/skill.js +105 -0
  356. package/lib/quoxon/objects/view.js +87 -0
  357. package/lib/quoxon/objects/workflow.js +119 -0
  358. package/lib/quoxon/observers.js +42 -0
  359. package/lib/quoxon/onboarding/copy.js +263 -0
  360. package/lib/quoxon/onboarding/credential.js +108 -0
  361. package/lib/quoxon/onboarding/detect.js +22 -0
  362. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  363. package/lib/quoxon/onboarding/failureCause.js +33 -0
  364. package/lib/quoxon/onboarding/finish.js +134 -0
  365. package/lib/quoxon/onboarding/index.js +94 -0
  366. package/lib/quoxon/onboarding/installRunner.js +284 -0
  367. package/lib/quoxon/onboarding/intent.js +53 -0
  368. package/lib/quoxon/onboarding/journey.js +73 -0
  369. package/lib/quoxon/onboarding/preflight.js +156 -0
  370. package/lib/quoxon/onboarding/probe.js +65 -0
  371. package/lib/quoxon/onboarding/runner.js +58 -0
  372. package/lib/quoxon/onboarding/view.js +682 -0
  373. package/lib/quoxon/orgLabel.js +10 -0
  374. package/lib/quoxon/orgSwitch.js +33 -0
  375. package/lib/quoxon/ownBroadcast.js +22 -0
  376. package/lib/quoxon/palette.js +43 -0
  377. package/lib/quoxon/print.js +98 -0
  378. package/lib/quoxon/profileSwitch.js +53 -0
  379. package/lib/quoxon/promptHistory.js +41 -0
  380. package/lib/quoxon/providers.js +48 -0
  381. package/lib/quoxon/qart.js +348 -0
  382. package/lib/quoxon/qdragon.js +410 -0
  383. package/lib/quoxon/qdragonFly.js +61 -0
  384. package/lib/quoxon/qdragonScenes.js +282 -0
  385. package/lib/quoxon/qeyes.js +213 -0
  386. package/lib/quoxon/qglyph.js +143 -0
  387. package/lib/quoxon/qhello.js +58 -0
  388. package/lib/quoxon/qmotion.js +132 -0
  389. package/lib/quoxon/render.js +1167 -0
  390. package/lib/quoxon/resend.js +72 -0
  391. package/lib/quoxon/roster.js +307 -0
  392. package/lib/quoxon/route.js +39 -0
  393. package/lib/quoxon/rows.js +711 -0
  394. package/lib/quoxon/rowsStore.js +80 -0
  395. package/lib/quoxon/rules.js +130 -0
  396. package/lib/quoxon/screenContext.js +150 -0
  397. package/lib/quoxon/screenFrames.js +254 -0
  398. package/lib/quoxon/session.js +238 -0
  399. package/lib/quoxon/sessionDead.js +17 -0
  400. package/lib/quoxon/signin.js +125 -0
  401. package/lib/quoxon/skills.js +30 -0
  402. package/lib/quoxon/slash/agents.js +112 -0
  403. package/lib/quoxon/slash/argSources.js +65 -0
  404. package/lib/quoxon/slash/args.js +65 -0
  405. package/lib/quoxon/slash/automate.js +89 -0
  406. package/lib/quoxon/slash/broadcast.js +140 -0
  407. package/lib/quoxon/slash/broadcastRun.js +108 -0
  408. package/lib/quoxon/slash/card.js +30 -0
  409. package/lib/quoxon/slash/challenge.js +114 -0
  410. package/lib/quoxon/slash/cliCommand.js +53 -0
  411. package/lib/quoxon/slash/cliLeaves.json +948 -0
  412. package/lib/quoxon/slash/compact.js +90 -0
  413. package/lib/quoxon/slash/confirm.js +30 -0
  414. package/lib/quoxon/slash/crystallize.js +69 -0
  415. package/lib/quoxon/slash/dashboard.js +136 -0
  416. package/lib/quoxon/slash/draft.js +54 -0
  417. package/lib/quoxon/slash/hatWalk.js +206 -0
  418. package/lib/quoxon/slash/inverses.js +77 -0
  419. package/lib/quoxon/slash/killer.js +24 -0
  420. package/lib/quoxon/slash/killerApi.js +16 -0
  421. package/lib/quoxon/slash/ledger.js +88 -0
  422. package/lib/quoxon/slash/listView.js +30 -0
  423. package/lib/quoxon/slash/modelCmd.js +261 -0
  424. package/lib/quoxon/slash/palette.js +118 -0
  425. package/lib/quoxon/slash/paletteController.js +169 -0
  426. package/lib/quoxon/slash/panels.js +116 -0
  427. package/lib/quoxon/slash/picker.js +56 -0
  428. package/lib/quoxon/slash/restart.js +202 -0
  429. package/lib/quoxon/slash/resume.js +59 -0
  430. package/lib/quoxon/slash/rowCmd.js +157 -0
  431. package/lib/quoxon/slash/rowWalk.js +175 -0
  432. package/lib/quoxon/slash/settings.js +60 -0
  433. package/lib/quoxon/slash/spawn.js +177 -0
  434. package/lib/quoxon/slash/stepUp.js +274 -0
  435. package/lib/quoxon/slash/stepUpCard.js +60 -0
  436. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  437. package/lib/quoxon/slash/undoApi.js +38 -0
  438. package/lib/quoxon/slash/undoCmd.js +49 -0
  439. package/lib/quoxon/slash/undoWish.js +15 -0
  440. package/lib/quoxon/slash/watch.js +164 -0
  441. package/lib/quoxon/slash/wish.js +23 -0
  442. package/lib/quoxon/slash/wishCmd.js +32 -0
  443. package/lib/quoxon/slotChat.js +300 -0
  444. package/lib/quoxon/slotFormat.js +195 -0
  445. package/lib/quoxon/slotRun.js +131 -0
  446. package/lib/quoxon/sound/index.js +186 -0
  447. package/lib/quoxon/sound/mapping.js +120 -0
  448. package/lib/quoxon/sound/output.js +52 -0
  449. package/lib/quoxon/sound/synth.js +122 -0
  450. package/lib/quoxon/stray.js +22 -0
  451. package/lib/quoxon/suggestCommand.js +59 -0
  452. package/lib/quoxon/term.js +141 -0
  453. package/lib/quoxon/textBuffer.js +416 -0
  454. package/lib/quoxon/theme.js +286 -0
  455. package/lib/quoxon/tips.js +433 -0
  456. package/lib/quoxon/toolError.js +41 -0
  457. package/lib/quoxon/transcript.js +188 -0
  458. package/lib/quoxon/web/qface.js +89 -0
  459. package/lib/quoxon/welcome.js +52 -0
  460. package/lib/quoxon/whatsnew.js +136 -0
  461. package/lib/quoxon/whatsnew.jsonl +89 -0
  462. package/lib/quoxon/windowOrg.js +113 -0
  463. package/lib/server/diag.js +129 -0
  464. package/lib/server/doctor.js +71 -0
  465. package/lib/server/install.js +149 -0
  466. package/lib/server/pluginEnable.js +161 -0
  467. package/lib/server/skew.js +56 -0
  468. package/lib/server/snapshot.js +131 -0
  469. package/lib/server/status.js +128 -0
  470. package/lib/server/sys.js +65 -0
  471. package/lib/server/uninstall.js +63 -0
  472. package/lib/server/update.js +234 -0
  473. package/lib/toolOutputShape.js +315 -0
  474. package/lib/tui/app.jsx +289 -0
  475. package/lib/tui/components/Breadcrumb.jsx +42 -0
  476. package/lib/tui/components/ChatInput.jsx +68 -0
  477. package/lib/tui/components/Confirm.jsx +32 -0
  478. package/lib/tui/components/DataTable.jsx +270 -0
  479. package/lib/tui/components/DetailPanel.jsx +241 -0
  480. package/lib/tui/components/HelpOverlay.jsx +67 -0
  481. package/lib/tui/components/Layout.jsx +129 -0
  482. package/lib/tui/components/Palette.jsx +141 -0
  483. package/lib/tui/components/SearchInput.jsx +125 -0
  484. package/lib/tui/components/Sparkline.jsx +55 -0
  485. package/lib/tui/components/StatusBar.jsx +133 -0
  486. package/lib/tui/components/Toast.jsx +52 -0
  487. package/lib/tui/hooks/useApi.js +44 -0
  488. package/lib/tui/hooks/useHealth.js +90 -0
  489. package/lib/tui/hooks/usePolledApi.js +86 -0
  490. package/lib/tui/hooks/useTerminalSize.js +46 -0
  491. package/lib/tui/index.js +56 -0
  492. package/lib/tui/intro/Intro.jsx +332 -0
  493. package/lib/tui/jsx-loader.js +32 -0
  494. package/lib/tui/keys.js +87 -0
  495. package/lib/tui/registry.js +49 -0
  496. package/lib/tui/theme.js +103 -0
  497. package/lib/tui/views/AdminView.jsx +398 -0
  498. package/lib/tui/views/AgentView.jsx +204 -0
  499. package/lib/tui/views/BackupView.jsx +225 -0
  500. package/lib/tui/views/BrainView.jsx +223 -0
  501. package/lib/tui/views/ChatView.jsx +468 -0
  502. package/lib/tui/views/FilesView.jsx +218 -0
  503. package/lib/tui/views/FleetView.jsx +234 -0
  504. package/lib/tui/views/GovernanceView.jsx +453 -0
  505. package/lib/tui/views/HealthView.jsx +216 -0
  506. package/lib/tui/views/HyperionDeck.jsx +79 -0
  507. package/lib/tui/views/InboxView.jsx +228 -0
  508. package/lib/tui/views/LucidDeck.jsx +719 -0
  509. package/lib/tui/views/MainMenu.jsx +123 -0
  510. package/lib/tui/views/MemoryView.jsx +377 -0
  511. package/lib/tui/views/ObjectivesView.jsx +230 -0
  512. package/lib/tui/views/OrgView.jsx +229 -0
  513. package/lib/tui/views/ToolView.jsx +221 -0
  514. package/lib/tui/views/VaultView.jsx +422 -0
  515. package/lib/tui/views/WorkflowView.jsx +329 -0
  516. package/package.json +75 -4
@@ -0,0 +1,1451 @@
1
+ /**
2
+ * Slot commands — DEVSUITE-SLOTS Phase 5 (`quox slot`).
3
+ *
4
+ * A slot is a fixed, org-curated address (T1..T8, or named: `triage`,
5
+ * `builder`) that a dev session checks out and releases (see
6
+ * services/collector/lib/devSlotsStore.js, phase 3). Phase 4 derives a
7
+ * Matrix room per slot; phase 5 (this file) adds the say/read message seam
8
+ * through that room. Sessions never hold Matrix credentials — say/read go
9
+ * through the collector routes only (POST/GET /api/v1/slots/:name/say and
10
+ * /messages), never a direct Matrix client in the CLI.
11
+ */
12
+
13
+ import { presenceText } from './presence.js';
14
+ import { readInstalledBuild, CLI_DIR } from '../core/cliBuild.js';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, openSync, closeSync } from 'node:fs';
16
+ import { spawn } from 'node:child_process';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ import { formatDetail, formatOutput, printSuccess, printWarning, printInfo } from '../core/output.js';
21
+ import { events, printEvent } from '../core/quoxglyph.js';
22
+ import { resolveEndpoint } from '../api/endpoints.js';
23
+ import { QuoxApiError, QuoxNetworkError } from '../core/errors.js';
24
+ import { resolveOrgId } from './helpers.js';
25
+ import { parseBroadcastArgs, broadcastSummary, deliveryLine, parseAckArgs } from '../quoxon/slash/broadcast.js';
26
+ import { claudeSessionInfo, procStartTime, resolveMyAttach, readSlotState } from './agentAttach.js';
27
+ import { runKeeper, keeperDir, makeProcessProbe, sameProcess } from '../core/slotKeeper.js';
28
+ import { loadConfig } from '../core/config.js';
29
+ import { createAuth } from '../core/auth.js';
30
+ import { createClient } from '../core/client.js';
31
+
32
+ // Local record of the slots checked out from this box, written at checkout
33
+ // and cleared at release. Consumers (the slot-inbox hook) re-verify each
34
+ // entry against the registry before trusting it, so a stale entry from a
35
+ // crashed session degrades to "not ours" rather than to wrong mail.
36
+ export function heldSlotsPath() {
37
+ return join(homedir(), '.quox', 'held-slots.json');
38
+ }
39
+
40
+ function readHeldSlots(path = heldSlotsPath()) {
41
+ try {
42
+ if (!existsSync(path)) return {};
43
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
44
+ return parsed && typeof parsed === 'object' ? parsed : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ // Atomic write (tmp + rename): readers (the slot-inbox hook) never see a
51
+ // half-written file, and two sessions pruning at once cannot corrupt it.
52
+ function writeHeldSlots(all, path) {
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ const tmp = `${path}.${process.pid}.tmp`;
55
+ writeFileSync(tmp, JSON.stringify(all, null, 2));
56
+ renameSync(tmp, path);
57
+ }
58
+
59
+ // `info` is claudeSessionInfo() ({ sessionKey, pid, starttime }) or null.
60
+ // Entries written before quox#662 are { session, recorded_at } only, and
61
+ // still read fine: they just carry no process binding.
62
+ // HOLDER-SAY (2026-10-17): `profile` is the CLI profile whose identity took the hold. The server binds the hold
63
+ // to that identity (holder_identity), so `say` must speak through the same profile, not whatever the default is now.
64
+ export function recordHeldSlot(name, session, path = heldSlotsPath(), info = null, member = null, profile = null) {
65
+ try {
66
+ const all = readHeldSlots(path);
67
+ // P10: a joining member must not clobber a sibling session's HOLDER entry
68
+ // (same slot name, same box); the member's label lives in its attach state.
69
+ const prev = all[name];
70
+ if (member && prev && !prev.member && prev.sessionKey && info && prev.sessionKey !== info.sessionKey) return;
71
+ all[name] = {
72
+ session,
73
+ ...(info ? { sessionKey: info.sessionKey, pid: info.pid, starttime: info.starttime } : {}),
74
+ // P10: joined an already-held slot as a member (not the holder).
75
+ ...(member ? { member: true, splinter: member.splinter ?? null, member_key: member.member_key ?? null } : {}),
76
+ ...(profile ? { profile } : {}),
77
+ recorded_at: new Date().toISOString(),
78
+ };
79
+ writeHeldSlots(all, path);
80
+ } catch {
81
+ // Best effort only: failing to write this note must never fail a
82
+ // checkout that the server already accepted.
83
+ }
84
+ }
85
+
86
+ export function forgetHeldSlot(name, path = heldSlotsPath()) {
87
+ try {
88
+ const all = readHeldSlots(path);
89
+ if (!(name in all)) return;
90
+ delete all[name];
91
+ writeHeldSlots(all, path);
92
+ } catch {
93
+ // Same posture: a release that succeeded server-side stands.
94
+ }
95
+ }
96
+
97
+ // Drop entries whose owning process is dead or was replaced (pid reuse).
98
+ // Entries with no pid (pre-#662) cannot be checked and are left alone.
99
+ export function pruneHeldSlots(probe, path = heldSlotsPath()) {
100
+ try {
101
+ const all = readHeldSlots(path);
102
+ let changed = false;
103
+ for (const [name, e] of Object.entries(all)) {
104
+ if (e && e.pid && !sameProcess(probe, e.pid, e.starttime)) {
105
+ delete all[name];
106
+ changed = true;
107
+ }
108
+ }
109
+ if (changed) writeHeldSlots(all, path);
110
+ } catch {
111
+ // Housekeeping only: never fail a verb over it.
112
+ }
113
+ }
114
+
115
+ // This session's own live binding for a slot, or null.
116
+ function ownHeldEntry(name, cur, probe, path = heldSlotsPath()) {
117
+ if (!cur) return null;
118
+ const e = readHeldSlots(path)[name];
119
+ if (!e || !e.sessionKey || e.sessionKey !== cur.sessionKey) return null;
120
+ if (!sameProcess(probe, e.pid, e.starttime)) return null;
121
+ return e;
122
+ }
123
+
124
+ // Default keeper launcher: detached, stdio to the keeper log, unref'd, so it
125
+ // outlives the checkout command and is not tied to this terminal.
126
+ // Stop a slot's keeper when the slot is released, rather than letting it
127
+ // linger until its next heartbeat 409s. Only signals a pid whose cmdline is
128
+ // genuinely `slot keep <name>`, so a recycled lock pid is never killed.
129
+ function defaultStopKeeper(name) {
130
+ try {
131
+ const pid = parseInt(readFileSync(join(keeperDir(), `${name}.pid`), 'utf-8'), 10);
132
+ if (!Number.isFinite(pid) || pid === process.pid) return;
133
+ let cmd = '';
134
+ try { cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf-8').split('\0').join(' '); } catch { return; }
135
+ if (!cmd.includes(`slot keep ${name} `) && !cmd.endsWith(`slot keep ${name}`)) return;
136
+ process.kill(pid, 'SIGTERM');
137
+ } catch { /* no keeper, or already gone */ }
138
+ }
139
+
140
+ // Only ever spawn a keeper from the real quox binary. Under a test runner
141
+ // process.argv[1] is the runner's worker entry, and spawning it as
142
+ // `slot keep` launched a crashing stray process (seen 2026-10-03).
143
+ export function isQuoxEntry(argv1) {
144
+ return /(^|[\\/])quox(\.js)?$/.test(String(argv1 || ''));
145
+ }
146
+
147
+ export function validTmuxPane(p) {
148
+ return typeof p === 'string' && /^%\d+$/.test(p);
149
+ }
150
+
151
+ function defaultSpawnKeeper(name, session, profile, claude) {
152
+ if (!isQuoxEntry(process.argv[1])) throw new Error('not running as the quox CLI');
153
+ const dir = keeperDir();
154
+ mkdirSync(dir, { recursive: true });
155
+ const fd = openSync(join(dir, `${name}.log`), 'a');
156
+ try {
157
+ // The keeper runs DETACHED: once this checkout exits it is re-parented
158
+ // away from claude and could not rediscover the session by walking ppid,
159
+ // so it is told which claude process it serves.
160
+ const args = [process.argv[1], ...(profile ? ['--profile', profile] : []), 'slot', 'keep', name, '--session', session,
161
+ ...(claude && claude.pid ? ['--claude-pid', String(claude.pid), '--claude-start', String(claude.starttime ?? '')] : []),
162
+ // QUOX-CLAUDE-3: the tmux pane this session runs in, so the detached
163
+ // keeper can inject /compact on a remote control request.
164
+ ...(validTmuxPane(process.env.TMUX_PANE) ? ['--tmux-pane', process.env.TMUX_PANE] : [])];
165
+ const child = spawn(process.execPath, args, { detached: true, stdio: ['ignore', fd, fd], env: process.env });
166
+ child.unref();
167
+ } finally {
168
+ closeSync(fd);
169
+ }
170
+ }
171
+
172
+ // P11: "is this name one typo away from a real one?" Capped edit distance;
173
+ // we only care about the yes/no, never the exact number.
174
+ function isNearMiss(a, b) {
175
+ if (!a || !b) return false;
176
+ const x = String(a).toLowerCase();
177
+ const y = String(b).toLowerCase();
178
+ if (x === y) return false;
179
+ if (Math.abs(x.length - y.length) > 2) return false;
180
+ const prev = Array.from({ length: y.length + 1 }, (_, i) => i);
181
+ for (let i = 1; i <= x.length; i++) {
182
+ let last = prev[0];
183
+ prev[0] = i;
184
+ for (let j = 1; j <= y.length; j++) {
185
+ const tmp = prev[j];
186
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, last + (x[i - 1] === y[j - 1] ? 0 : 1));
187
+ last = tmp;
188
+ }
189
+ }
190
+ return prev[y.length] <= 2;
191
+ }
192
+
193
+ // Unforgeable authorship (migrations/aee/022): from_label is the caller's
194
+ // chosen display name and stays useful (many sessions share one
195
+ // authenticated account, so the label is the only thing that tells them
196
+ // apart). author_identity is the server-derived, unforgeable field. When
197
+ // they agree (the common case, including "no label supplied" — the server
198
+ // falls back from_label to the identity) there is nothing extra to show.
199
+ // When they disagree, a reader needs to be able to tell WHO actually
200
+ // posted, not just what they called themselves — this is exactly the
201
+ // impersonation case (a session's posts rendering as "QUOX-SECURITY", a
202
+ // different session entirely, because from_label was unchecked free text).
203
+ export function formatFromCell(m) {
204
+ const label = m.from || '-';
205
+ const identity = m.author_identity || null;
206
+ if (!identity) return label;
207
+ if (String(label).toLowerCase() === String(identity).toLowerCase()) return label;
208
+ return `${label} [verified: ${identity}]`;
209
+ }
210
+
211
+ // P10: render claim/address state next to the message text. Old servers send
212
+ // neither field, so the text is returned untouched.
213
+ export function messageText(m) {
214
+ const tags = [];
215
+ if (m.claimed_by) tags.push(`[claimed by ${m.claimed_by}]`);
216
+ if (m.addressed_to) tags.push(`[@${m.addressed_to}]`);
217
+ return tags.length ? `${tags.join(' ')} ${m.text}` : m.text;
218
+ }
219
+
220
+ // Is this message for the member described by held entry `e`? Unaddressed,
221
+ // @all and @M<id> are for everyone; @S<n> only for that splinter. @M<id>
222
+ // names a mission (QUOXIDENTITY P12), and until missions exist there is no
223
+ // registry to resolve it against: matching it to a member key orphaned every
224
+ // mission-addressed message (no member ever got a claim step).
225
+ function addressedToMe(m, e) {
226
+ const a = m.addressed_to;
227
+ if (!a || a === 'all' || /^M/.test(a)) return true;
228
+ return a === e.splinter;
229
+ }
230
+
231
+ function ageFrom(iso) {
232
+ if (!iso) return null;
233
+ const ms = Date.now() - new Date(iso).getTime();
234
+ if (!Number.isFinite(ms) || ms < 0) return null;
235
+ const s = Math.floor(ms / 1000);
236
+ if (s < 60) return `${s}s`;
237
+ const m = Math.floor(s / 60);
238
+ if (m < 60) return `${m}m`;
239
+ const h = Math.floor(m / 60);
240
+ if (h < 24) return `${h}h`;
241
+ return `${Math.floor(h / 24)}d`;
242
+ }
243
+
244
+ // Held-but-never-read must be unmistakable at a glance (owner incident,
245
+ // 2026-08-22/23: a heartbeating holder that never reads its room looks
246
+ // exactly like an attended one). Free/stale slots and a held slot that is
247
+ // fully caught up render blank, same as every other empty cell here.
248
+ function unreadCell(slot) {
249
+ if (slot.state !== 'held') return null;
250
+ const n = slot.unread_count || 0;
251
+ if (n === 0) return null;
252
+ return slot.last_read_at ? `${n} unread` : `${n} unread (NEVER READ)`;
253
+ }
254
+
255
+ // Stale-since honesty (P1-TRUST slice 3, owner incident: `slot list` showed
256
+ // a stale slot's HEARTBEAT as "4m ago" — reading like a live heartbeat, not
257
+ // a slot the sweep already gave up on). sweepStale stamps last_heartbeat_at
258
+ // to the exact moment it swept a held slot to 'stale' (devSlotsStore.js
259
+ // sweepStale), so that timestamp IS "stale since" once state is 'stale';
260
+ // the API surfaces it distinctly as `stale_since` (falls back to
261
+ // last_heartbeat_at for a response predating that field). HEARTBEAT stays
262
+ // exactly what it always was for a held slot; a stale or free slot never
263
+ // shows a HEARTBEAT age again, only STALE-SINCE does that job now.
264
+ function staleSinceCell(slot) {
265
+ if (slot.state !== 'stale') return null;
266
+ const since = slot.stale_since || slot.last_heartbeat_at || null;
267
+ const age = ageFrom(since);
268
+ return age ? `stale ${age}` : null;
269
+ }
270
+
271
+ function toRow(slot) {
272
+ // Option B assign model (plan v2 note #2831): a free slot with an
273
+ // assigned_to still shows a blank HOLDER, so surface the expected holder
274
+ // in the same column as "-> <name>" rather than adding a new column.
275
+ const holder = slot.holder_session
276
+ || (slot.state === 'free' && slot.assigned_to ? `-> ${slot.assigned_to}` : null);
277
+ // CHANNEL honesty (2026-09-21): the slot row carries discord_channel_id and
278
+ // matrix_room_id once provisioned, but `list` never surfaced them — so a
279
+ // correctly-provisioned slot looked identical to an unprovisioned one, which
280
+ // is exactly why slots read as "not spawning channels". Show which surfaces
281
+ // are wired (full ids stay in --output json and are long/noisy for a table).
282
+ const channel = [
283
+ slot.discord_channel_id ? 'discord' : null,
284
+ slot.matrix_room_id ? 'matrix' : null,
285
+ ].filter(Boolean).join('+') || null;
286
+ return {
287
+ NAME: slot.name,
288
+ STATE: slot.state,
289
+ HOLDER: holder,
290
+ CHANNEL: channel,
291
+ HOST: slot.host_id || null,
292
+ AGE: ageFrom(slot.state === 'held' || slot.state === 'stale' ? slot.checked_out_at : slot.created_at),
293
+ HEARTBEAT: slot.state === 'held' && ageFrom(slot.last_heartbeat_at) ? `${ageFrom(slot.last_heartbeat_at)} ago` : null,
294
+ 'STALE-SINCE': staleSinceCell(slot),
295
+ UNREAD: unreadCell(slot),
296
+ };
297
+ }
298
+
299
+ // Room provisioning after a fresh checkout measured ~5s live; 6 x 2.5s
300
+ // covers it with margin without hanging a genuinely broken say for long.
301
+ const SAY_PROVISION_ATTEMPTS = 6;
302
+ const SAY_PROVISION_DELAY_MS = 2500;
303
+
304
+ const CONTROL_WAIT_MS = 40000;
305
+ const CONTROL_WAIT_STEP_MS = 2000;
306
+
307
+ // quox#757/#733: how long `slot wait` keeps retrying 401/403 after the first
308
+ // consecutive auth failure before treating it as real.
309
+ const WAIT_AUTH_WINDOW_MS = 10 * 60 * 1000;
310
+ // Definitive server refusals: never retried, even inside the window.
311
+ const PERMANENT_SLOT_CODES = new Set(['not_slot_holder', 'slot_retired', 'licence_revoked', 'license_revoked']);
312
+ function isPermanentSlotRefusal(err) {
313
+ return [err?.code, err?.body?.code, err?.body?.error].some((c) => typeof c === 'string' && PERMANENT_SLOT_CODES.has(c.trim().toLowerCase().replace(/\s+/g, '_')));
314
+ }
315
+
316
+ export function registerSlotCommands(program, ctx) {
317
+ const { client, auth, config } = ctx;
318
+ const sleep = ctx.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
319
+ // Injectable process seams (tests): who am I, is that pid alive, launch keeper.
320
+ const deps = {
321
+ claudeSessionInfo,
322
+ probe: makeProcessProbe(procStartTime),
323
+ spawnKeeper: (n, sess, claude) => defaultSpawnKeeper(n, sess, program.opts().profile, claude),
324
+ stopKeeper: defaultStopKeeper,
325
+ ...(ctx.deps || {}),
326
+ };
327
+
328
+ // HOLDER-SAY: the profile this invocation runs as (null when unknown, e.g. in tests without a config).
329
+ const profileName = () => { try { return ctx.config?.getActiveProfileName?.() || null; } catch { return null; } };
330
+ const explicitProfile = () => Boolean(ctx.config?._explicitProfile) || Boolean(String(process.env.QUOX_PROFILE || '').trim());
331
+ // HOLDER-SAY: the client a say into `name` as session `from` goes through. The hold is bound server-side to the
332
+ // identity that checked it out; when this box recorded that a DIFFERENT local profile took it, speak through that
333
+ // profile (the same identity, its own credentials). The server's holder check is unchanged: another identity
334
+ // still cannot speak as the holder. An explicit -p / QUOX_PROFILE always wins (the caller said who they are).
335
+ function sayClientFor(name, from) {
336
+ if (!from || explicitProfile()) return { client, profile: null };
337
+ const e = readHeldSlots()[name];
338
+ const cur = profileName();
339
+ if (!e || e.member || e.session !== from || !e.profile || !cur || e.profile === cur) return { client, profile: null };
340
+ try {
341
+ const c = deps.clientForProfile ? deps.clientForProfile(e.profile) : (() => { const cfg = loadConfig(e.profile); return createClient(cfg, createAuth(cfg)); })();
342
+ return c ? { client: c, profile: e.profile } : { client, profile: null };
343
+ } catch { return { client, profile: null }; }
344
+ }
345
+
346
+ // Session identity (quox#662). Holder names are free text and every verb
347
+ // used to default differently, so EMAIL-AGENTS vs email-agents 409'd. One
348
+ // rule now: --session wins; else the binding this very Claude session
349
+ // recorded at checkout; else $QUOX_STREAM; else the verb's own fallback.
350
+ function resolveSession(options, name, fallback) {
351
+ const held = ownHeldEntry(name, deps.claudeSessionInfo(), deps.probe);
352
+ if (options.session) {
353
+ if (held && held.session !== options.session) {
354
+ printWarning(`--session ${options.session} differs from the session that holds ${name} from this Claude session (${held.session}); using --session as given.`);
355
+ }
356
+ return options.session;
357
+ }
358
+ if (held) return held.session;
359
+ // P10: held-slots.json is keyed by slot name, so a same-box sibling
360
+ // splinter's attach can own that entry. This session's own attach slot
361
+ // state (per-session dir) is the authority for its label on that slot.
362
+ const att = attachedSlotLabel(name);
363
+ if (att) return att;
364
+ return process.env.QUOX_STREAM || fallback;
365
+ }
366
+
367
+ function attachedSlotLabel(name) {
368
+ try {
369
+ const mine = resolveMyAttach();
370
+ if (!mine?.agentId) return null;
371
+ const st = readSlotState(mine.agentId);
372
+ return st && st.name === name && st.session_label ? st.session_label : null;
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ // SLOTS-REPLY: the slot THIS session holds, sent as from_slot so a reply can reach
379
+ // it (a free-text --session label is not an address). Only this Claude session's
380
+ // own live bindings count; outside Claude, an entry recorded under the same
381
+ // session name. The server re-checks the hold (same_user) and drops a bad claim.
382
+ function senderSlot(from) {
383
+ try {
384
+ const cur = deps.claudeSessionInfo();
385
+ const all = readHeldSlots();
386
+ let mine = Object.entries(all).filter(([, e]) => e && !e.member && (cur
387
+ ? e.sessionKey && e.sessionKey === cur.sessionKey && sameProcess(deps.probe, e.pid, e.starttime)
388
+ : from && String(e.session || '').toLowerCase() === String(from).toLowerCase() && (!e.pid || sameProcess(deps.probe, e.pid, e.starttime))));
389
+ if (!mine.length) {
390
+ const mineAtt = resolveMyAttach();
391
+ const st = mineAtt?.agentId ? readSlotState(mineAtt.agentId) : null;
392
+ return st && st.name ? st.name : null;
393
+ }
394
+ const named = mine.filter(([, e]) => from && e.session === from);
395
+ if (named.length) mine = named;
396
+ mine.sort((a, b) => String(b[1].recorded_at || '').localeCompare(String(a[1].recorded_at || '')));
397
+ return mine[0][0];
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+
403
+ // P10: the collector recognises splinter members only via the ATTACH token.
404
+ // When this Claude session is attached to an agent whose recorded slot is
405
+ // `name`, send that token (same noAuth + Authorization pattern as
406
+ // releaseAttachLease); otherwise nothing, so the profile token is used.
407
+ function attachAuthFor(name) {
408
+ try {
409
+ const mine = resolveMyAttach();
410
+ if (!mine?.token) return {};
411
+ const st = readSlotState(mine.agentId);
412
+ if (!st || st.name !== name) return {};
413
+ return { noAuth: true, headers: { Authorization: `Bearer ${mine.token}` } };
414
+ } catch {
415
+ return {};
416
+ }
417
+ }
418
+
419
+ const slot = program
420
+ .command('slot')
421
+ .description('Manage DEVSUITE-SLOTS: checkout/release the fixed session addresses, say/read into a slot room')
422
+ // Every slot verb sweeps held-slots.json for entries whose Claude session
423
+ // has exited, so the file cannot accumulate dead bindings.
424
+ .hook('preAction', () => pruneHeldSlots(deps.probe));
425
+
426
+ slot
427
+ .command('list')
428
+ .description('List slots for the active org')
429
+ .action(async () => {
430
+ auth.requireAuth();
431
+ const ep = resolveEndpoint('v1.slots.list');
432
+ const data = await client.get(ep.path, { service: ep.service, query: { org_id: resolveOrgId(auth, program) } });
433
+ formatOutput((data.slots || []).map(toRow), {
434
+ format: program.opts().output,
435
+ columns: ['NAME', 'STATE', 'HOLDER', 'CHANNEL', 'HOST', 'AGE', 'HEARTBEAT', 'STALE-SINCE', 'UNREAD'],
436
+ emptyMessage: 'No slots yet. Create one with `quox slot create <name>`.',
437
+ // --json gets the API records (data keys), never the table cells.
438
+ raw: data.slots || [],
439
+ });
440
+ // PRESENCE: who is away (approvals to them queue). Table output only; --json has humans in the records.
441
+ if (program.opts().output !== 'json') for (const h of (data.humans || []).filter((x) => x && x.away)) printInfo(` ⏸ ${presenceText(h)}`);
442
+ });
443
+
444
+ slot
445
+ .command('create')
446
+ .description('Create a new slot')
447
+ .argument('<name>', 'slot name, e.g. builder or t1')
448
+ .action(async (name) => {
449
+ auth.requireAuth();
450
+ const ep = resolveEndpoint('v1.slots.create');
451
+ const data = await client.post(ep.path, { service: ep.service, body: { name, org_id: resolveOrgId(auth, program) } });
452
+ formatDetail(data.slot || data, { format: program.opts().output, title: `Slot created: ${name}` });
453
+ });
454
+
455
+ slot
456
+ .command('retire')
457
+ .description('Retire (delete) a slot')
458
+ .argument('<name>', 'slot name')
459
+ .option('--delete-channel', 'also delete the slot\'s Discord channel (default: keep it, marked disconnected)')
460
+ .action(async (name, opts = {}) => {
461
+ auth.requireAuth();
462
+ const ep = resolveEndpoint('v1.slots.retire');
463
+ const body = { name, retire: true, org_id: resolveOrgId(auth, program) };
464
+ if (opts.deleteChannel) body.delete_channel = true;
465
+ const data = await client.post(ep.path, { service: ep.service, body });
466
+ printSuccess(`Slot ${name} retired.`);
467
+ if (opts.deleteChannel && data.channel) {
468
+ if (data.channel.deleted) printSuccess('Discord channel deleted.');
469
+ else printWarning(`Discord channel NOT deleted${data.channel.error ? `: ${data.channel.error}` : ''}.`);
470
+ }
471
+ if (program.opts().output === 'json') formatDetail(data.slot || data, { format: 'json' });
472
+ });
473
+
474
+ const printOrphanNotes = (data) => {
475
+ if (data.orphans_error) printWarning(`Orphan channels: ${data.orphans_error}.`);
476
+ if (data.orphan_count) printInfo(`${data.orphan_count} orphan Discord channel(s) found; ${data.orphans_note || 'not listed for this caller'}.`);
477
+ };
478
+
479
+ slot
480
+ .command('prune')
481
+ .description('Retire old unused slots (free, idle longer than --older-than days). Dry run unless --yes')
482
+ .option('--older-than <days>', 'idle threshold in days', '14')
483
+ .option('--keep-channels', 'keep the Discord channels (default: delete them)')
484
+ .option('--keep-orphans', 'skip the orphan Discord channel sweep (channels of slots that no longer exist)')
485
+ .option('--yes', 'actually retire; without it this is a dry run')
486
+ .action(async (opts = {}) => {
487
+ auth.requireAuth();
488
+ const days = Number(opts.olderThan === undefined ? 14 : opts.olderThan);
489
+ if (!Number.isFinite(days) || days < 0) throw new Error('--older-than must be a non-negative number of days');
490
+ const ep = resolveEndpoint('v1.slots.prune');
491
+ const execute = opts.yes === true;
492
+ const data = await client.post(ep.path, {
493
+ service: ep.service,
494
+ body: { older_than_days: days, dry_run: !execute, delete_channels: !opts.keepChannels, include_orphans: !opts.keepOrphans, org_id: resolveOrgId(auth, program) },
495
+ });
496
+ if (!execute) {
497
+ const rows = (data.candidates || []).map((c) => ({ NAME: c.name, 'LAST-ACTIVITY': c.last_activity, CHANNEL: c.discord_channel_id ? 'discord' : '-' }));
498
+ formatOutput(rows, {
499
+ format: program.opts().output,
500
+ columns: ['NAME', 'LAST-ACTIVITY', 'CHANNEL'],
501
+ emptyMessage: `No free slots idle for more than ${days} days.`,
502
+ raw: data.candidates || [],
503
+ });
504
+ if (rows.length) printInfo(`DRY RUN: ${rows.length} slot(s) would be retired. Re-run with --yes to retire these.`);
505
+ printOrphanNotes(data);
506
+ if (Array.isArray(data.orphans) && data.orphans.length) {
507
+ formatOutput(data.orphans.map((o) => ({ SLOT: o.slot_name, CHANNEL: o.channel_id })), {
508
+ format: program.opts().output, columns: ['SLOT', 'CHANNEL'], emptyMessage: 'No orphan channels.', raw: data.orphans,
509
+ });
510
+ printInfo(`${data.orphans.length} orphan Discord channel(s) (no live slot in any org) would be deleted. Re-run with --yes, or --keep-orphans to skip.`);
511
+ }
512
+ return;
513
+ }
514
+ const rows = (data.results || []).map((r) => ({
515
+ NAME: r.name,
516
+ RETIRED: r.retired ? 'yes' : 'no',
517
+ CHANNEL: r.channel ? (r.channel.deleted ? 'deleted' : `kept (${r.channel.error || 'not deleted'})`) : '-',
518
+ }));
519
+ formatOutput(rows, {
520
+ format: program.opts().output,
521
+ columns: ['NAME', 'RETIRED', 'CHANNEL'],
522
+ emptyMessage: 'Nothing to prune.',
523
+ raw: data.results || [],
524
+ });
525
+ printOrphanNotes(data);
526
+ if (Array.isArray(data.orphan_results) && data.orphan_results.length) {
527
+ formatOutput(data.orphan_results.map((o) => ({ SLOT: o.slot_name, CHANNEL: o.channel_id, DELETED: o.deleted ? 'yes' : `no (${o.error || 'not deleted'})` })), {
528
+ format: program.opts().output, columns: ['SLOT', 'CHANNEL', 'DELETED'], emptyMessage: 'No orphan channels.', raw: data.orphan_results,
529
+ });
530
+ }
531
+ });
532
+
533
+ slot
534
+ .command('checkout')
535
+ .description('Check out a slot for this session')
536
+ .argument('<name>', 'slot name')
537
+ .option('--session <s>', 'session name holding the slot (default: $QUOX_STREAM, else "unnamed")')
538
+ .option('--kind <kind>', 'interactive|loop', 'interactive')
539
+ .option('--host <h>', 'host_id this session is running on')
540
+ .option('--force', 'take the slot even when it is assigned to another session')
541
+ .option('--create', 'create the slot first if it does not already exist, then check it out')
542
+ .option('--no-keep', 'do not start the background hold keeper (heartbeats every 5m while this Claude session lives)')
543
+ .action(async (name, options) => {
544
+ auth.requireAuth();
545
+ const holderSession = options.session || process.env.QUOX_STREAM || 'unnamed';
546
+ const ep = resolveEndpoint('v1.slots.checkout', { name });
547
+ try {
548
+ const data = await client.post(ep.path, {
549
+ service: ep.service,
550
+ body: {
551
+ holder_session: holderSession, holder_kind: options.kind, host_id: options.host || null,
552
+ org_id: resolveOrgId(auth, program), force: options.force === true, create: options.create === true,
553
+ },
554
+ });
555
+ const joined = data.joined === true ? (data.member || {}) : null;
556
+ if (joined) {
557
+ printSuccess(`Joined slot ${name} as member ${joined.splinter ?? joined.member_key ?? '?'}`);
558
+ } else {
559
+ formatDetail(data.slot || data, { format: program.opts().output, title: `Slot checked out: ${name}` });
560
+ }
561
+ printEvent(events.assigned('quox', `slot ${name}`, holderSession));
562
+ // Record the binding locally so anything on this box that needs to
563
+ // know "which slots are ours" does not have to guess an identity.
564
+ // Found the hard way, 2026-08-23: the slot-inbox hook resolved held
565
+ // slots through $QUOX_STREAM, which a fresh shell inherits stale (a
566
+ // session cannot reliably set it for its own later shells), so the
567
+ // hook silently matched nothing and the very session that shipped it
568
+ // kept missing its own mail. The act of checking out is the only
569
+ // moment the binding is known for certain, so it gets written down
570
+ // here rather than re-derived later.
571
+ const me = deps.claudeSessionInfo();
572
+ recordHeldSlot(name, holderSession, heldSlotsPath(), me, joined, profileName());
573
+ // Owner incident, 2026-08-22/23: "you cant bind to a slot and then
574
+ // immediately ignore the channel it creates". Checkout hands back a
575
+ // live address (a Matrix/Discord room), not just a lock; say so once,
576
+ // and name the exact command that watches it.
577
+ //
578
+ // Slots audit, 2026-10-02 (Discord gripe "dev slots are created for
579
+ // sessions but nothing ever replies"): the soft hint above was not
580
+ // enough. Session ASSISTANTQ checked out, never armed the listener,
581
+ // the owner's Discord message sat unread, and with no heartbeat the
582
+ // hold lapsed after 15m, so its later `say` 403'd too (quox#661).
583
+ // The listener is what makes a slot two-way, so call it REQUIRED and
584
+ // say how to run it so it actually wakes an agent session.
585
+ // Hold keeper (quox#662/B): only under a Claude session; residents and
586
+ // supervisors run their own heartbeats.
587
+ let keeping = false;
588
+ if (me && options.keep !== false) {
589
+ try {
590
+ deps.spawnKeeper(name, holderSession, { pid: me.pid, starttime: me.starttime });
591
+ keeping = true;
592
+ printInfo(`hold keeper running for ${name}: heartbeats every 5m while this Claude session lives (log: ~/.quox/keepers/${name}.log; opt out with --no-keep).`);
593
+ } catch (kerr) {
594
+ printWarning(`could not start the hold keeper (${kerr.message}); keep the hold alive with \`quox slot wait ${name}\`.`);
595
+ }
596
+ }
597
+ printInfo(`${name} is a live address, not just a lock. REQUIRED next: run \`quox slot wait ${name} --session ${holderSession}\` in the background ` +
598
+ `(Claude Code: Bash with run_in_background) and re-arm it after every wake. It is what hears Discord/Matrix replies` +
599
+ (keeping
600
+ ? ': without it replies go unheard (the hold keeper keeps the hold itself).'
601
+ : `, and its heartbeat keeps the hold: without it replies go unheard and the hold lapses after 15m.`));
602
+ } catch (err) {
603
+ if (err instanceof QuoxApiError && err.status === 409) {
604
+ // Two distinct 409s now: someone is HOLDING it, or it is ASSIGNED
605
+ // to someone else and this session is not them.
606
+ if (err.detail?.assigned_to) {
607
+ throw new Error(`slot ${name} is assigned to ${err.detail.assigned_to}; pass --force to take it anyway`);
608
+ }
609
+ const holder = err.detail?.holder_session || 'another session';
610
+ // POKE-WAKE: the server refuses a same-session re-checkout under a different identity (a hold
611
+ // taken as a human, re-checked-out as the resident). When this box's binding proves the hold
612
+ // belongs to THIS claude process, the slot is already ours: refresh the keeper (a newer build
613
+ // replaces an older keeper) instead of failing. Nothing server-side changes.
614
+ const me = deps.claudeSessionInfo();
615
+ const rec = readHeldSlots()[name];
616
+ if (me && holder === holderSession && rec && rec.session === holderSession && !rec.member
617
+ && Number(rec.pid) === Number(me.pid) && String(rec.starttime) === String(me.starttime)) {
618
+ printInfo(`slot ${name} is already held by this session (${holderSession}); refreshing its hold keeper.`);
619
+ if (options.keep !== false) {
620
+ try {
621
+ deps.spawnKeeper(name, holderSession, { pid: me.pid, starttime: me.starttime });
622
+ } catch (kerr) {
623
+ printWarning(`could not start the hold keeper (${kerr.message}).`);
624
+ }
625
+ }
626
+ return;
627
+ }
628
+ throw new Error(`slot ${name} is held by ${holder}`);
629
+ }
630
+ // fleet #449: a bare "404 Not Found: unknown" against an instance
631
+ // that has no such slot (or no SLOTS at all) reads as "you typed the
632
+ // name wrong". Slots are per-instance: say which instance was asked
633
+ // and that the slot may live elsewhere.
634
+ if (err instanceof QuoxApiError && err.status === 404) {
635
+ const base = typeof config?.getBaseUrl === 'function' ? config.getBaseUrl() : 'this instance';
636
+ throw new Error(
637
+ `slot ${name} was not found on ${base}. Slots are per-instance: this one may live on a different ` +
638
+ `Quox instance (point the CLI there with a profile and authenticate first), or SLOTS may not be ` +
639
+ `deployed on this instance at all. \`quox slot list\` shows what THIS instance has.`
640
+ );
641
+ }
642
+ throw err;
643
+ }
644
+ });
645
+
646
+ slot
647
+ .command('release')
648
+ .description('Release a slot')
649
+ .argument('<name>', 'slot name')
650
+ .option('--force', 'release even if held by another session')
651
+ .option('--session <s>', 'session name holding the slot (default: $QUOX_STREAM)')
652
+ .action(async (name, options) => {
653
+ auth.requireAuth();
654
+ const ep = resolveEndpoint('v1.slots.release', { name });
655
+ const relSession = resolveSession(options, name, undefined);
656
+ const data = await client.post(ep.path, {
657
+ service: ep.service,
658
+ ...attachAuthFor(name),
659
+ body: { holder_session: relSession || undefined, force: !!options.force, org_id: resolveOrgId(auth, program) },
660
+ });
661
+ // Never print a tick the server did not earn: the returned slot must actually be free (or handed to
662
+ // someone else), not still held by the session we just asked to release.
663
+ const after = data?.slot;
664
+ if (after && after.state !== 'free' && !data.promoted && (options.force || (relSession && after.holder_session === relSession))) {
665
+ throw new Error(`slot ${name} was NOT released: the server still shows it ${after.state} by ${after.holder_session || 'unknown'}`);
666
+ }
667
+ formatDetail(data.slot || data, { format: program.opts().output, title: `Slot released: ${name}` });
668
+ printEvent(events.released('quox', `slot ${name}`, relSession || 'this box'));
669
+ forgetHeldSlot(name);
670
+ try { deps.stopKeeper(name); } catch { /* best effort */ }
671
+ });
672
+
673
+ slot
674
+ .command('control')
675
+ .description('Send a control command to the session holding a slot (owner/admin only). Commands: compact, status')
676
+ .argument('<name>', 'slot name')
677
+ .argument('<command>', 'control command (compact | status | wake)')
678
+ .option('--wait', 'wait up to 40s for the keeper to ack and print its answer (always on for status)')
679
+ .action(async (name, command, options) => {
680
+ auth.requireAuth();
681
+ const orgId = resolveOrgId(auth, program);
682
+ const ep = resolveEndpoint('v1.slots.control', { name });
683
+ const data = await client.post(ep.path, {
684
+ service: ep.service,
685
+ body: { command, org_id: orgId },
686
+ });
687
+ printEvent(events.updated('quox', `slot ${name} control ${command}`, data.control_id, 'requested'));
688
+ if (command !== 'status' && !options.wait) return;
689
+ const get = resolveEndpoint('v1.slots.control.get', { name, id: data.control_id });
690
+ for (let waited = 0; waited < CONTROL_WAIT_MS; waited += CONTROL_WAIT_STEP_MS) {
691
+ await sleep(CONTROL_WAIT_STEP_MS);
692
+ const row = await client.get(get.path, { service: get.service, query: { org_id: orgId } });
693
+ if (row && row.status && row.status !== 'pending') {
694
+ console.log(`${row.status}: ${row.result || '(no result)'}`);
695
+ return;
696
+ }
697
+ }
698
+ printWarning(`requested, no answer yet after ${CONTROL_WAIT_MS / 1000}s (control ${data.control_id}); the keeper may be down or the session not under tmux`);
699
+ });
700
+
701
+ slot
702
+ .command('heartbeat')
703
+ .description('Send a heartbeat for a held slot')
704
+ .argument('<name>', 'slot name')
705
+ .option('--session <s>', 'session name holding the slot (default: $QUOX_STREAM)')
706
+ .action(async (name, options) => {
707
+ auth.requireAuth();
708
+ const holderSession = resolveSession(options, name, 'unnamed');
709
+ const ep = resolveEndpoint('v1.slots.heartbeat', { name });
710
+ const data = await client.post(ep.path, { service: ep.service, ...attachAuthFor(name), body: { holder_session: holderSession, org_id: resolveOrgId(auth, program) } });
711
+ formatDetail(data.slot || data, { format: program.opts().output, title: `Heartbeat: ${name}` });
712
+ });
713
+
714
+ slot
715
+ .command('assign')
716
+ .description('Assign a slot to an expected holder (session name); slot stays free until claimed')
717
+ .argument('<name>', 'slot name')
718
+ .argument('[session]', 'session name to assign as the expected holder')
719
+ .option('--clear', 'clear the assignment')
720
+ .action(async (name, session, options) => {
721
+ auth.requireAuth();
722
+ if (!options.clear && !session) throw new Error('provide a session name, or use --clear to unassign');
723
+ const ep = resolveEndpoint('v1.slots.assign', { name });
724
+ const data = await client.post(ep.path, {
725
+ service: ep.service,
726
+ body: { assigned_to: options.clear ? null : session, org_id: resolveOrgId(auth, program) },
727
+ });
728
+ formatDetail(data.slot || data, { format: program.opts().output, title: `Slot ${name} assignment updated` });
729
+ printSuccess(options.clear ? `${name} unassigned.` : `${name} assigned to ${session}.`);
730
+ });
731
+
732
+ slot
733
+ .command('claim')
734
+ .description('Claim every free slot assigned to this session')
735
+ // "claim" and "checkout" are near-synonyms and only one takes a slot
736
+ // name, so `claim <name>` is a natural reach (the owner hit it live,
737
+ // 2026-08-21). Catch the positional and name the verb they wanted
738
+ // instead of commander's bare "too many arguments".
739
+ .argument('[name]', 'with <messageId>: the shared slot whose message to claim')
740
+ .argument('[messageId]', 'claim this one message (P10 shared slot) instead of assigned slots')
741
+ .option('--session <s>', 'session name (default: $QUOX_STREAM)')
742
+ .action(async (name, messageId, options) => {
743
+ if (name && messageId) {
744
+ // P10 SPLINTER-2: atomic per-message claim (200 claimed / 409 claimed_by).
745
+ auth.requireAuth();
746
+ const from = resolveSession(options, name, null);
747
+ const mep = resolveEndpoint('v1.slots.messageClaim', { name, id: messageId });
748
+ try {
749
+ await client.post(mep.path, {
750
+ service: mep.service,
751
+ ...attachAuthFor(name),
752
+ body: { ...(from ? { holder_session: from } : {}), org_id: resolveOrgId(auth, program) },
753
+ });
754
+ } catch (err) {
755
+ if (err instanceof QuoxApiError && err.status === 409) {
756
+ const by = err.detail?.claimed_by || err.body?.claimed_by || 'another member';
757
+ printWarning(`Already claimed by ${by}`);
758
+ process.exitCode = 1;
759
+ return;
760
+ }
761
+ throw err;
762
+ }
763
+ printSuccess(`Claimed message ${messageId} in ${name}`);
764
+ return;
765
+ }
766
+ if (name) {
767
+ throw new Error(
768
+ `claim takes no slot name: it claims every free slot assigned to your session. `
769
+ + `To take one specific slot, use: quox slot checkout ${name} --session <your-session>`,
770
+ );
771
+ }
772
+ auth.requireAuth();
773
+ const session = options.session || process.env.QUOX_STREAM;
774
+ if (!session) throw new Error('no session name: pass --session or set QUOX_STREAM');
775
+ const ep = resolveEndpoint('v1.slots.claim');
776
+ const data = await client.post(ep.path, {
777
+ service: ep.service,
778
+ body: { session, org_id: resolveOrgId(auth, program) },
779
+ });
780
+ const claimed = data.claimed || [];
781
+ if (claimed.length > 0) {
782
+ printSuccess(`Claimed: ${claimed.join(', ')}`);
783
+ } else {
784
+ // P11: claiming nothing was reported as a SUCCESS, which is how a
785
+ // typo'd assign turned into a slot sitting "awaiting claim" forever
786
+ // with nobody aware. Zero claims is a warning, and when an assignment
787
+ // exists under a near-miss name we say so, because that is almost
788
+ // always what happened.
789
+ printWarning(`No slots are assigned to "${session}", so nothing was claimed.`);
790
+ try {
791
+ const listEp = resolveEndpoint('v1.slots.list');
792
+ const listed = await client.get(listEp.path, {
793
+ service: listEp.service,
794
+ query: { org_id: resolveOrgId(auth, program) },
795
+ });
796
+ const waiting = (listed.slots || []).filter((s) => s.assigned_to);
797
+ const near = waiting.filter((s) => isNearMiss(session, s.assigned_to));
798
+ if (near.length) {
799
+ for (const s of near) {
800
+ printInfo(` slot "${s.name}" is waiting for "${s.assigned_to}" — did you mean --session ${s.assigned_to}?`);
801
+ }
802
+ } else if (waiting.length) {
803
+ printInfo(` slots waiting on a claim: ${waiting.map((s) => `${s.name} -> ${s.assigned_to}`).join(', ')}`);
804
+ } else {
805
+ printInfo(' no slot in this org is waiting for a claim. Assign one first with `quox slot assign`.');
806
+ }
807
+ } catch {
808
+ // The claim result is the answer; the hint is a bonus, never a failure.
809
+ }
810
+ }
811
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
812
+ });
813
+
814
+ // QUOXON-ROOMS: a slot is a group room. join = subscribe + post without holding it (checkout stays the
815
+ // exclusive task-ownership concept). Membership is server-side, org-scoped.
816
+ async function joinRoom(name, session, person) {
817
+ const ep = resolveEndpoint('v1.slots.join', { name });
818
+ return client.post(ep.path, { service: ep.service, ...attachAuthFor(name), body: { session, ...(person ? { person } : {}), org_id: resolveOrgId(auth, program) } });
819
+ }
820
+ slot
821
+ .command('join')
822
+ .description('Join a slot as a room: read its live stream and post to it without holding it')
823
+ .argument('<name>', 'slot name')
824
+ .option('--session <s>', 'session name to join as (default: $QUOX_STREAM)')
825
+ .option('--person <p>', 'who you act for, shown as "session (person)"')
826
+ .action(async (name, options) => {
827
+ auth.requireAuth();
828
+ const session = options.session || process.env.QUOX_STREAM;
829
+ if (!session) throw new Error('no session name: pass --session or set QUOX_STREAM');
830
+ const data = await joinRoom(name, session, options.person);
831
+ printSuccess(data.already ? `${session} is already in ${name}.` : `${session} joined ${name}. Read: quox slot wait ${name} --session ${session}; post: quox slot say ${name} --room --session ${session} <text>`);
832
+ });
833
+ slot
834
+ .command('leave')
835
+ .description('Leave a slot room you joined (does not release a hold)')
836
+ .argument('<name>', 'slot name')
837
+ .option('--session <s>', 'session name (default: $QUOX_STREAM)')
838
+ .action(async (name, options) => {
839
+ auth.requireAuth();
840
+ const session = options.session || process.env.QUOX_STREAM;
841
+ if (!session) throw new Error('no session name: pass --session or set QUOX_STREAM');
842
+ const ep = resolveEndpoint('v1.slots.leave', { name });
843
+ const data = await client.post(ep.path, { service: ep.service, ...attachAuthFor(name), body: { session, org_id: resolveOrgId(auth, program) } });
844
+ printSuccess(data.removed ? `${session} left ${name}.` : `${session} was not in ${name}.`);
845
+ });
846
+ slot
847
+ .command('rooms')
848
+ .description('List the slot rooms a session has joined')
849
+ .option('--session <s>', 'session name (default: $QUOX_STREAM)')
850
+ .action(async (options) => {
851
+ auth.requireAuth();
852
+ const session = options.session || process.env.QUOX_STREAM;
853
+ if (!session) throw new Error('no session name: pass --session or set QUOX_STREAM');
854
+ const ep = resolveEndpoint('v1.slots.rooms');
855
+ const data = await client.get(ep.path, { service: ep.service, query: { session, org_id: resolveOrgId(auth, program) } });
856
+ formatOutput((data.rooms || []).map((r) => ({ ROOM: r.slot_name, JOINED: r.joined_at })), {
857
+ format: program.opts().output, columns: ['ROOM', 'JOINED'], emptyMessage: `${session} has joined no rooms.`, raw: data.rooms || [],
858
+ });
859
+ });
860
+
861
+ slot
862
+ .command('say')
863
+ .description("Say something into a slot's Matrix room (use - to read a multi-line, Markdown message from stdin)")
864
+ .argument('<name>', 'slot name')
865
+ .argument('<text...>', 'message text')
866
+ .option('--session <s>', 'session name to speak as (default: $QUOX_STREAM)')
867
+ .option('--room', 'post as a joined room member (no hold needed); signed "session (person)" by the server')
868
+ .option('--run <command>', 'ask the human to run ONE command in their own terminal (the text is why); the quox TUI shows it in full and runs it only on their y. Never run server-side')
869
+ .action(async (name, textParts, options) => {
870
+ auth.requireAuth();
871
+ // SLOT-RUN: same rule as the collector (one line, max 2000, no control/bidi characters), checked before sending.
872
+ if (options.run !== undefined) {
873
+ const run = String(options.run);
874
+ if (!run.trim()) throw new Error('--run is empty');
875
+ if (run.length > 2000) throw new Error('--run too long (max 2000 characters)');
876
+ if (/[\u0000-\u001f\u007f\u0080-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/.test(run)) throw new Error('--run must be one command on one line, without control, bidi or zero-width characters');
877
+ }
878
+ // `say <name> -` reads the message from stdin (owner 2026-10-05: slot
879
+ // channels were "enormous single paragraphs" because multi-line text is
880
+ // awkward as a shell argument). Line breaks and Markdown survive as-is.
881
+ let text = textParts.join(' ');
882
+ if (textParts.length === 1 && textParts[0] === '-') {
883
+ const input = ctx.stdin || process.stdin;
884
+ let buf = '';
885
+ for await (const chunk of input) buf += chunk;
886
+ text = buf.replace(/\s+$/, '');
887
+ if (!text.trim()) throw new Error('empty message on stdin; nothing to say');
888
+ }
889
+ // Never send the literal "unnamed": it made a post unattributable, so
890
+ // own-post filters could not recognise the sender's own messages and
891
+ // leaked them back as unread mail (a self-loop for a resident). With
892
+ // no session name, send nothing and let the server attribute from the
893
+ // authenticated identity.
894
+ const from = resolveSession(options, name, null);
895
+ const ep = resolveEndpoint('v1.slots.say', { name });
896
+ if (options.room && !from) throw new Error('--room needs a session: pass --session or set QUOX_STREAM');
897
+ const body = { text, ...(options.room ? { room_session: from } : from ? { from } : {}), org_id: resolveOrgId(auth, program) };
898
+ if (options.run !== undefined) body.run = String(options.run);
899
+ // SLOTS-REPLY: carry the sender's own slot so the recipient's reply reaches it.
900
+ // A room post already has its address (the room), so it carries none.
901
+ const fromSlot = options.room ? null : senderSlot(from);
902
+ if (fromSlot) body.from_slot = fromSlot;
903
+ // A freshly created slot gets its Matrix room a few seconds AFTER
904
+ // checkout returns (provisioning is async), so a say straight after
905
+ // checkout used to fail with 409 "no Matrix room yet" (slots audit
906
+ // 2026-10-02, measured ~5s). Ride out exactly that race; any other
907
+ // 409 is real and surfaces immediately.
908
+ let data;
909
+ const sayVia = options.room ? { client, profile: null } : sayClientFor(name, from);
910
+ if (sayVia.profile) printInfo(`Speaking as profile ${sayVia.profile}: it checked out ${name} for ${from}, and the hold is bound to its identity.`);
911
+ for (let attempt = 1; ; attempt += 1) {
912
+ try {
913
+ data = await sayVia.client.post(ep.path, { service: ep.service, ...attachAuthFor(name), body });
914
+ break;
915
+ } catch (err) {
916
+ // The collector sends {error: '<text>'}, which the client stores
917
+ // as err.code, so read every field the text can land in.
918
+ const said = err instanceof QuoxApiError
919
+ ? [err.message, err.code, err.body?.error, err.body?.message].filter(Boolean).join(' ') : '';
920
+ const pending = err instanceof QuoxApiError && err.status === 409 && /no Matrix room yet/i.test(said);
921
+ if (pending && attempt < SAY_PROVISION_ATTEMPTS) {
922
+ await sleep(SAY_PROVISION_DELAY_MS);
923
+ continue;
924
+ }
925
+ if (err instanceof QuoxApiError && err.status === 403 && !err.detail?.holder_session && /not the current holder|not slot holder/i.test(said)) {
926
+ // quox#661: the hold LAPSED (no heartbeat, so the 15m TTL freed
927
+ // it) and the bare "held by nobody" read as a permissions bug.
928
+ const s = from || '<session>';
929
+ throw new Error(
930
+ `you no longer hold slot ${name}: the hold lapsed (no heartbeat for 15m) or was released. ` +
931
+ `Re-claim it with \`quox slot checkout ${name} --session ${s}\`, then keep \`quox slot wait ${name} --session ${s}\` ` +
932
+ `running in the background so replies are heard and the hold stays fresh.`
933
+ );
934
+ }
935
+ // A non-holder who has joined the room: post as a room member instead of failing.
936
+ if (err instanceof QuoxApiError && err.status === 403 && !options.room && from && /not (the current )?slot holder/i.test(said)) {
937
+ try {
938
+ data = await client.post(ep.path, { service: ep.service, ...attachAuthFor(name), body: { ...body, from: undefined, room_session: from } });
939
+ break;
940
+ } catch { /* not a member either: surface the original error with its join hint */ }
941
+ }
942
+ // HOLDER-SAY: the session name matches the hold, but this CLI speaks as a different identity than the one
943
+ // that took it (e.g. the default profile changed since checkout). Never bypassed: say exactly what to do.
944
+ if (err instanceof QuoxApiError && err.status === 403 && from && err.detail?.holder_session === from && /not (the current )?slot holder|not the current holder/i.test(said)) {
945
+ throw new Error(
946
+ `${name} is held by ${from}, but that hold is bound to the identity that checked it out, and this CLI ` +
947
+ `(profile ${profileName() || 'default'}) speaks as a different one. Say it as the profile that checked it out ` +
948
+ `(\`quox -p <that profile> slot say ${name} --session ${from} ...\`), or re-take the hold as this identity: ` +
949
+ `\`quox slot release ${name} --session ${from} && quox slot checkout ${name} --session ${from}\`.`
950
+ );
951
+ }
952
+ throw err;
953
+ }
954
+ }
955
+ printSuccess(`Said into ${name}.`);
956
+ if (!options.room && !fromSlot) {
957
+ printWarning(`${from || 'This session'} holds no slot, so replies cannot reach it. Claim one: quox slot checkout <name> --create --session ${from || '<session>'}`);
958
+ } else if (fromSlot && data?.from_slot_dropped) {
959
+ printWarning(`${fromSlot} was not accepted as your reply address (${data.from_slot_dropped}), so replies cannot reach you; re-check it out: quox slot checkout ${fromSlot} --session ${from || '<session>'}`);
960
+ }
961
+ reportPresence(name, data?.presence);
962
+ reportHumanPresence(data?.human_presence);
963
+ reportBridgeDelivery(data?.bridges);
964
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
965
+ });
966
+
967
+ // QUOXON-BROADCAST: one call fans a message out to every held slot in the org (or --slots).
968
+ slot
969
+ .command('broadcast')
970
+ .description('Broadcast a message to every held slot in this org (--slots to pick, --priority urgent, --ack to track acknowledgements)')
971
+ .argument('<text...>', 'message text')
972
+ .option('--slots <list>', 'comma-separated slot names instead of every held slot')
973
+ .option('--priority <p>', 'normal or urgent', 'normal')
974
+ .option('--ack', 'ask recipients to acknowledge with /ack <id>')
975
+ .option('--session <s>', 'session name to sign as (default: $QUOX_STREAM)')
976
+ .action(async (textParts, options) => {
977
+ auth.requireAuth();
978
+ const parsed = parseBroadcastArgs([
979
+ options.slots ? `--slots ${options.slots}` : '', `--priority ${options.priority}`, options.ack ? '--ack' : '', textParts.join(' '),
980
+ ].filter(Boolean).join(' '));
981
+ if (!parsed.ok) throw new Error(parsed.message);
982
+ const from = options.session || process.env.QUOX_STREAM || null;
983
+ const ep = resolveEndpoint('v1.slots.broadcast');
984
+ const data = await client.post(ep.path, {
985
+ service: ep.service, retries: 0,
986
+ body: { text: parsed.text, priority: parsed.priority, ack: parsed.ack, ...(parsed.slots ? { slots: parsed.slots } : {}), ...(from ? { from } : {}), org_id: resolveOrgId(auth, program) },
987
+ });
988
+ if (program.opts().output === 'json') return formatDetail(data, { format: 'json' });
989
+ for (const [line, kind] of broadcastSummary(data)) (kind === 'warn' ? printWarning : printInfo)(line);
990
+ reportHumanPresence(data?.human_presence);
991
+ return undefined;
992
+ });
993
+
994
+ slot
995
+ .command('broadcast-status')
996
+ .description('Delivered / acknowledged / awaiting counts for a broadcast')
997
+ .argument('<id>', 'broadcast id (bc_...)')
998
+ .action(async (id) => {
999
+ auth.requireAuth();
1000
+ const ep = resolveEndpoint('v1.slots.broadcast.get', { id });
1001
+ const data = await client.get(ep.path, { service: ep.service, retries: 0, query: { org_id: resolveOrgId(auth, program) } });
1002
+ if (program.opts().output === 'json') return formatDetail(data, { format: 'json' });
1003
+ printInfo(deliveryLine(data));
1004
+ for (const d of data.deliveries || []) printInfo(` ${d.slot}: ${d.status}${d.room ? `, room ${d.room}` : ''}${d.acked ? ', acknowledged' : ''}`);
1005
+ return undefined;
1006
+ });
1007
+
1008
+ slot
1009
+ .command('ack')
1010
+ .description('Acknowledge a broadcast on behalf of a slot you hold')
1011
+ .argument('<id>', 'broadcast id (bc_...)')
1012
+ .requiredOption('--slot <name>', 'the slot acknowledging')
1013
+ .action(async (id, options) => {
1014
+ auth.requireAuth();
1015
+ const a = parseAckArgs(id);
1016
+ if (!a.ok) throw new Error(a.message);
1017
+ const ep = resolveEndpoint('v1.slots.broadcast.ack', { id });
1018
+ const data = await client.post(ep.path, { service: ep.service, retries: 0, body: { slot: options.slot, org_id: resolveOrgId(auth, program) } });
1019
+ if (program.opts().output === 'json') return formatDetail(data, { format: 'json' });
1020
+ printSuccess(`Acknowledged ${id} as ${options.slot}. ${deliveryLine(data)}`);
1021
+ return undefined;
1022
+ });
1023
+
1024
+ /**
1025
+ * Print what actually reached each bridge.
1026
+ *
1027
+ * Why this exists (2026-08-18): `slot say` printed "Said into demo." in the
1028
+ * same second the collector logged a 502 rendering that line to Telegram. The
1029
+ * say genuinely had succeeded (the durable record is the cache row and the
1030
+ * AEE envelope), so reporting failure would have been wrong too. What was
1031
+ * missing was the SECOND half of the truth: where the line went, and where it
1032
+ * did not. The owner's report was "I say something and never see it", and
1033
+ * nothing on screen could tell them which bridge was broken.
1034
+ *
1035
+ * Silent when no bridges are configured: an org that has not set one up does
1036
+ * not need a line about it on every say.
1037
+ */
1038
+ function agoText(iso) {
1039
+ const m = iso ? Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000)) : null;
1040
+ return m == null || Number.isNaN(m) ? 'a while ago' : m < 1 ? 'just now' : m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`;
1041
+ }
1042
+ // PRESENCE (owner 2026-10-16): the human this slot belongs to is away; approvals will queue. Silent otherwise.
1043
+ function reportHumanPresence(hp) {
1044
+ if (hp && hp.away && hp.notice) printInfo(` ⏸ ${hp.notice}`);
1045
+ }
1046
+ // Same wording as the TUI (quoxon/slotChat presenceLine). Silent when an older server sends no presence.
1047
+ function reportPresence(name, presence) {
1048
+ if (!presence || !presence.state) return;
1049
+ if (presence.state === 'listening') printInfo(` ✔ delivered to ${name} (listening now)`);
1050
+ else if (presence.state === 'nobody_holds') printInfo(` · nobody holds this slot`);
1051
+ else printInfo(` · not listening right now (${presence.last_checked_in_at ? `last checked in ${agoText(presence.last_checked_in_at)}` : 'no check-in on record'})`);
1052
+ }
1053
+ function reportBridgeDelivery(bridges) {
1054
+ if (!Array.isArray(bridges) || bridges.length === 0) return;
1055
+ for (const b of bridges) {
1056
+ const where = b.target ? `${b.platform} (${b.target})` : b.platform || 'bridge';
1057
+ if (b.ok === true) printInfo(` rendered to ${where}`);
1058
+ else if (b.ok === null) printInfo(` ${where}: still sending, check \`quox slot read\``);
1059
+ else printWarning(` NOT rendered to ${where}: ${b.reason}`);
1060
+ }
1061
+ }
1062
+
1063
+ async function readMessages(name, options) {
1064
+ auth.requireAuth();
1065
+ const ep = resolveEndpoint('v1.slots.messages', { name });
1066
+ const query = {};
1067
+ if (options.since) query.since = options.since;
1068
+ if (options.limit) query.limit = options.limit;
1069
+ // Passing the session marks the channel read up to now, but only when
1070
+ // this session is genuinely the current holder (the collector enforces
1071
+ // that, same as wait): a bystander reading with --session set to
1072
+ // someone else's name marks nobody caught up.
1073
+ const session = resolveSession(options, name, null);
1074
+ if (session) query.holder_session = session;
1075
+ query.org_id = resolveOrgId(auth, program);
1076
+ const data = await client.get(ep.path, { service: ep.service, ...attachAuthFor(name), query });
1077
+ formatOutput((data.messages || []).map((m) => ({
1078
+ TIME: m.created_at, FROM: formatFromCell(m), SOURCE: m.source, TEXT: messageText(m),
1079
+ })), {
1080
+ format: program.opts().output,
1081
+ columns: ['TIME', 'FROM', 'SOURCE', 'TEXT'],
1082
+ emptyMessage: `No messages yet in ${name}.`,
1083
+ // --json gets the API records (created_at/from/source/text), never
1084
+ // the table cells — same rule as slot list.
1085
+ raw: data.messages || [],
1086
+ });
1087
+ }
1088
+
1089
+ slot
1090
+ .command('read')
1091
+ .description("Read a slot's recent room messages (cached; see quox slot peek for the last few)")
1092
+ .argument('<name>', 'slot name')
1093
+ .option('--since <ts>', 'only messages after this ISO-8601 timestamp')
1094
+ .option('--limit <n>', 'max messages (default 50)', (v) => parseInt(v, 10))
1095
+ .option('--session <s>', 'your session name: marks the channel read if you are the current holder (default: $QUOX_STREAM)')
1096
+ .action(readMessages);
1097
+
1098
+ slot
1099
+ .command('peek')
1100
+ .description('Alias of `quox slot read <name> --limit 10`')
1101
+ .argument('<name>', 'slot name')
1102
+ .option('--session <s>', 'your session name: marks the channel read if you are the current holder (default: $QUOX_STREAM)')
1103
+ .action((name, options) => readMessages(name, { ...options, limit: 10 }));
1104
+
1105
+ slot
1106
+ .command('wait')
1107
+ .description('Block until someone speaks into the slot (long-poll; heartbeats your hold while waiting)')
1108
+ .argument('<name>', 'slot name')
1109
+ .option('--session <s>', 'your session name: heartbeats the hold and filters out your own says (default: $QUOX_STREAM)')
1110
+ .option('--since <ts>', 'resume from this ISO-8601 cursor (from a previous wait timeout)')
1111
+ .option('--timeout <mins>', 'give up after this many minutes of silence (default: wait forever)', (v) => parseFloat(v))
1112
+ .option('--once', 'single server round-trip (~50s max), exit even if silent')
1113
+ .option('--join', 'join the slot as a room first (read without holding it; your own posts are not echoed)')
1114
+ .option('--also <slots>', 'also listen on these peer slots (comma-separated, read-only): hear replies another session posts in ITS slot')
1115
+ .action(async (name, options) => {
1116
+ if (options.also) return waitMany(name, options);
1117
+ // Why this verb exists (2026-08-22): slot read-back is pull, and a
1118
+ // holding session that forgets to poll is deaf to its own channel; the
1119
+ // owner's Discord replies sat unread on quoxperticus-126's first day.
1120
+ // One background `quox slot wait` is the product-shaped watcher: it
1121
+ // exits the moment a message lands (waking whatever spawned it), and
1122
+ // the server heartbeats the hold on every round-trip, replacing the
1123
+ // separate heartbeat loop too.
1124
+ auth.requireAuth();
1125
+ const session = resolveSession(options, name, null);
1126
+ if (options.join) {
1127
+ if (!session) throw new Error('--join needs a session: pass --session or set QUOX_STREAM');
1128
+ await joinRoom(name, session, null);
1129
+ }
1130
+ const ep = resolveEndpoint('v1.slots.wait', { name });
1131
+ const deadline = Number.isFinite(options.timeout) && options.timeout > 0
1132
+ ? Date.now() + options.timeout * 60 * 1000
1133
+ : null;
1134
+ let cursor = options.since || null;
1135
+ let netFailures = 0;
1136
+ let authSince = null; // start of the current run of consecutive 401/403s
1137
+
1138
+ for (;;) {
1139
+ const query = { org_id: resolveOrgId(auth, program), timeout_ms: 50000 };
1140
+ if (cursor) query.since = cursor;
1141
+ if (session) {
1142
+ if (!options.join) query.holder_session = session; // a room member does not hold
1143
+ query.exclude_from = session;
1144
+ }
1145
+ let data;
1146
+ try {
1147
+ data = await client.get(ep.path, { service: ep.service, ...attachAuthFor(name), query, timeout: 65000 });
1148
+ netFailures = 0;
1149
+ authSince = null;
1150
+ } catch (err) {
1151
+ // An unattended watcher must ride through transient faults (a
1152
+ // collector redeploy mid-long-poll killed the first live one with
1153
+ // a bare timeout, 2026-08-22). Retry with backoff; only give up
1154
+ // after a sustained outage, and never swallow real API refusals
1155
+ // (403/404 mean something is actually wrong with the call).
1156
+ // 401 is retried within the same bounded budget: an auth-service
1157
+ // restart answers 401 for a minute or two, and treating that as
1158
+ // fatal killed every listening session during a deploy (OPUSIUS,
1159
+ // 2026-10-03). A genuinely dead token still exits after ~4 min.
1160
+ //
1161
+ // Transient faults (collector down, 5xx/429, a re-auth that hit
1162
+ // rate_limited while the auth service restarts) back off with
1163
+ // capped jittered exponential delay (Retry-After honoured) and
1164
+ // keep waiting for as long as the wait's own --timeout allows
1165
+ // (quox#747/#752/#753). Only a 401 is bounded, and only a
1166
+ // definitive auth refusal / user timeout ends the wait.
1167
+ const transient = err instanceof QuoxNetworkError
1168
+ || err?.transient === true
1169
+ || (err instanceof QuoxApiError && (err.status >= 500 || err.status === 429));
1170
+ // 401/403 are transient for a bounded WINDOW of time (not a count):
1171
+ // a collector restart answers 401 (auth warming) or 403 (licence
1172
+ // lease not loaded yet) for a minute or two (quox#733/#757). Only
1173
+ // an auth failure persisting past the window, or a body code the
1174
+ // server marks permanent, ends the wait.
1175
+ const authRetry = err instanceof QuoxApiError && (err.status === 401 || err.status === 403)
1176
+ && !isPermanentSlotRefusal(err);
1177
+ if (!(transient || authRetry) || options.once) throw err;
1178
+ if (authRetry) {
1179
+ if (authSince === null) authSince = Date.now();
1180
+ if (Date.now() - authSince >= WAIT_AUTH_WINDOW_MS) throw err;
1181
+ } else {
1182
+ authSince = null;
1183
+ }
1184
+ netFailures += 1;
1185
+ let delay = Math.min(2000 * 2 ** Math.min(netFailures - 1, 5), 60000);
1186
+ delay = Math.round(delay * (0.75 + Math.random() * 0.5));
1187
+ if (Number.isFinite(err.retryAfterMs)) delay = Math.min(Math.max(delay, err.retryAfterMs), 300000);
1188
+ if (deadline && Date.now() + delay >= deadline) {
1189
+ printInfo(`No messages in ${name} (collector unreachable: ${err.message}). Resume with --since ${cursor || '<none>'}`);
1190
+ process.exitCode = 1;
1191
+ return;
1192
+ }
1193
+ printWarning(`wait: ${err.message}, retry ${netFailures} in ${Math.round(delay / 1000)}s`);
1194
+ await sleep(delay);
1195
+ continue;
1196
+ }
1197
+ cursor = data.cursor || cursor;
1198
+
1199
+ // quox#662/C: a wait whose hold is gone used to warn once and keep
1200
+ // polling forever: deaf AND unheld. Exit non-zero so whatever
1201
+ // supervises this process (the Claude background-task wake) notices.
1202
+ const holdLost = !!session && !options.join && data.held === false;
1203
+ const holdLostLine = `HOLD LOST: ${name} is no longer held by ${session}; re-run quox slot checkout ${name} --session ${session}`;
1204
+
1205
+ if ((data.messages || []).length > 0) {
1206
+ formatOutput(data.messages.map((m) => ({
1207
+ TIME: m.created_at, FROM: formatFromCell(m), SOURCE: m.source, TEXT: messageText(m),
1208
+ })), {
1209
+ format: program.opts().output,
1210
+ columns: ['TIME', 'FROM', 'SOURCE', 'TEXT'],
1211
+ raw: data.messages,
1212
+ });
1213
+ printInfo(` resume cursor: ${cursor}`);
1214
+ if (holdLost) {
1215
+ printWarning(holdLostLine);
1216
+ process.exitCode = 3;
1217
+ return;
1218
+ }
1219
+ // Seer, 2026-10-03: the wake worked, then the session researched
1220
+ // its answer for 28 minutes without a word and without re-arming,
1221
+ // so the owner saw silence and the hold went stale. Every wake now
1222
+ // carries the two moves that keep a slot alive, in order.
1223
+ const s = session || '<session>';
1224
+ // P10: a member of a shared slot must win the claim before answering,
1225
+ // or two splinters answer the same message.
1226
+ const mine = ownHeldEntry(name, deps.claudeSessionInfo(), deps.probe);
1227
+ if (mine && mine.member === true) {
1228
+ const needing = data.messages.filter((m) => m.id != null && !m.claimed_by && addressedToMe(m, mine));
1229
+ if (needing.length > 0) {
1230
+ printInfo(` CLAIM before answering (shared slot, first claim wins; a 409 means another member has it):`);
1231
+ for (const m of needing) printInfo(` quox slot claim ${name} ${m.id} --session ${s}`);
1232
+ }
1233
+ }
1234
+ printInfo(` NEXT, before any long work: (1) acknowledge now: quox slot say ${name} "seen, on it" --session ${s}`);
1235
+ printInfo(` (2) re-arm in the background: quox slot wait ${name} --session ${s} --since ${cursor}`);
1236
+ printInfo(' then do the work and post the full answer with say. The hold lapses 15m after the last wait.');
1237
+ return;
1238
+ }
1239
+
1240
+ if (holdLost) {
1241
+ printWarning(holdLostLine);
1242
+ process.exitCode = 3;
1243
+ return;
1244
+ }
1245
+
1246
+ if (options.once || (deadline && Date.now() >= deadline)) {
1247
+ printInfo(`No messages in ${name}. Resume with --since ${cursor}`);
1248
+ process.exitCode = 1;
1249
+ return;
1250
+ }
1251
+ }
1252
+ });
1253
+
1254
+ slot
1255
+ .command('keep', { hidden: true })
1256
+ .description('Background hold keeper: heartbeat a held slot every 5m until the owning Claude session exits (spawned by checkout)')
1257
+ .argument('<name>', 'slot name')
1258
+ .option('--session <s>', 'session name holding the slot')
1259
+ .option('--claude-pid <pid>', 'pid of the claude session this keeper serves (passed by checkout)')
1260
+ .option('--claude-start <ticks>', 'start ticks of that claude process (guards against pid reuse)')
1261
+ .option('--tmux-pane <pane>', 'tmux pane id the session runs in (remote control target, e.g. %3)')
1262
+ .action(async (name, options) => {
1263
+ auth.requireAuth();
1264
+ const session = resolveSession(options, name, null);
1265
+ if (!session) throw new Error('no session name: pass --session');
1266
+ const orgId = resolveOrgId(auth, program);
1267
+ const me = options.claudePid
1268
+ ? { pid: Number(options.claudePid), starttime: options.claudeStart || null }
1269
+ : deps.claudeSessionInfo();
1270
+ const hb = resolveEndpoint('v1.slots.heartbeat', { name });
1271
+ const rel = resolveEndpoint('v1.slots.release', { name });
1272
+ const pend = resolveEndpoint('v1.slots.control.pending', { name });
1273
+ const tmuxPane = validTmuxPane(options.tmuxPane) ? options.tmuxPane : (validTmuxPane(process.env.TMUX_PANE) ? process.env.TMUX_PANE : null);
1274
+ const res = await runKeeper({
1275
+ name, session, claude: me, probe: deps.probe,
1276
+ // KEEPER-REEXEC: track the installed build; on a change start a successor with the SAME
1277
+ // args (this keeper has already dropped its lock) and exit, keeping the hold.
1278
+ buildId: () => readInstalledBuild(CLI_DIR),
1279
+ reexec: () => {
1280
+ if (!isQuoxEntry(process.argv[1])) throw new Error('not running as the quox CLI');
1281
+ const fd = openSync(join(keeperDir(), `${name}.log`), 'a');
1282
+ try {
1283
+ spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: ['ignore', fd, fd], env: process.env }).unref();
1284
+ } finally { closeSync(fd); }
1285
+ },
1286
+ sleep: ctx.sleep,
1287
+ tmuxPane,
1288
+ pollControl: async () => {
1289
+ const data = await client.get(pend.path, { service: pend.service, query: { holder_session: session, org_id: orgId } });
1290
+ return data.pending || [];
1291
+ },
1292
+ ackControl: (id, status, result) => {
1293
+ const ack = resolveEndpoint('v1.slots.control.ack', { name, id });
1294
+ return client.post(ack.path, { service: ack.service, body: { holder_session: session, org_id: orgId, status, result } });
1295
+ },
1296
+ heartbeat: () => client.post(hb.path, { service: hb.service, body: { holder_session: session, org_id: orgId } }),
1297
+ release: () => client.post(rel.path, { service: rel.service, body: { holder_session: session, force: false, org_id: orgId } }),
1298
+ forget: () => forgetHeldSlot(name),
1299
+ });
1300
+ process.exitCode = res.code;
1301
+ });
1302
+
1303
+ // quox#691 cross-slot deafness: two sessions in conversation each reply in
1304
+ // their OWN slot, so a session listening only on its own never hears the
1305
+ // answer. --also polls peer slots too, in short parallel rounds. Only the
1306
+ // session's own slot is heartbeated / marked read; peers are read-only.
1307
+ async function waitMany(name, options) {
1308
+ auth.requireAuth();
1309
+ const session = resolveSession(options, name, null);
1310
+ const peers = String(options.also).split(',').map((x) => x.trim()).filter((x) => x && x !== name);
1311
+ const slots = [name, ...peers];
1312
+ const cursors = Object.fromEntries(slots.map((sl) => [sl, options.since || null]));
1313
+ const deadline = Number.isFinite(options.timeout) && options.timeout > 0 ? Date.now() + options.timeout * 60 * 1000 : null;
1314
+ for (;;) {
1315
+ const rounds = await Promise.all(slots.map(async (sl) => {
1316
+ const ep = resolveEndpoint('v1.slots.wait', { name: sl });
1317
+ const query = { org_id: resolveOrgId(auth, program), timeout_ms: 15000 };
1318
+ if (cursors[sl]) query.since = cursors[sl];
1319
+ if (session) {
1320
+ query.exclude_from = session;
1321
+ if (sl === name) query.holder_session = session;
1322
+ }
1323
+ try {
1324
+ const data = await client.get(ep.path, { service: ep.service, query, timeout: 30000 });
1325
+ cursors[sl] = data.cursor || cursors[sl];
1326
+ return { slot: sl, messages: data.messages || [] };
1327
+ } catch (err) {
1328
+ printWarning(`wait: ${sl}: ${err.message}`);
1329
+ return { slot: sl, messages: [] };
1330
+ }
1331
+ }));
1332
+ const heard = rounds.flatMap((r) => r.messages.map((m) => ({ ...m, slot: r.slot })));
1333
+ if (heard.length) {
1334
+ formatOutput(heard.map((m) => ({ SLOT: m.slot, TIME: m.created_at, FROM: formatFromCell(m), SOURCE: m.source, TEXT: m.text })), {
1335
+ format: program.opts().output, columns: ['SLOT', 'TIME', 'FROM', 'SOURCE', 'TEXT'], raw: heard,
1336
+ });
1337
+ for (const sl of slots) printInfo(` resume cursor ${sl}: ${cursors[sl]}`);
1338
+ return;
1339
+ }
1340
+ if (options.once || (deadline && Date.now() >= deadline)) {
1341
+ printInfo(`No messages in ${slots.join(', ')}.`);
1342
+ process.exitCode = 1;
1343
+ return;
1344
+ }
1345
+ }
1346
+ }
1347
+
1348
+ // DEVSUITE-SLOTS Phase 9 — governed, org-scoped bridge-room config
1349
+ // (replaces QUOX_SLOT_BRIDGE_ROOM / QUOX_SLOT_DISCORD_ROOM as the primary
1350
+ // source; the env vars remain a fallback only, per no-env-only-config).
1351
+ const bridge = slot
1352
+ .command('bridge')
1353
+ .description('Manage the Telegram/Discord rooms a slot `say` renders into');
1354
+
1355
+ bridge
1356
+ .command('list')
1357
+ .description('Show the configured bridge rooms for the active org')
1358
+ .action(async () => {
1359
+ auth.requireAuth();
1360
+ const ep = resolveEndpoint('v1.slots.bridges.list');
1361
+ const data = await client.get(ep.path, { service: ep.service, query: { org_id: resolveOrgId(auth, program) } });
1362
+ const rooms = data.bridges || {};
1363
+ const details = data.details || {};
1364
+ // Phase 10: show WHY a bridge is trusted, not just which room id was
1365
+ // typed. An unset row and a set-but-never-verified row used to look
1366
+ // identical here, which is how a bridge that rendered to nobody kept
1367
+ // reading as configured.
1368
+ formatOutput(
1369
+ ['telegram', 'discord'].map((platform) => {
1370
+ const d = details[platform] || null;
1371
+ let status = '(unset)';
1372
+ if (rooms[platform]) {
1373
+ if (!d || d.verified_at == null) status = 'unverified';
1374
+ else status = d.verified_ok ? 'verified' : 'FAILING';
1375
+ }
1376
+ return {
1377
+ PLATFORM: platform,
1378
+ ROOM: rooms[platform] || '(unset)',
1379
+ STATUS: status,
1380
+ CHANNEL: (d && d.channel_label) || '',
1381
+ };
1382
+ }),
1383
+ {
1384
+ format: program.opts().output,
1385
+ columns: ['PLATFORM', 'ROOM', 'STATUS', 'CHANNEL'],
1386
+ emptyMessage: 'No bridge rooms configured.',
1387
+ }
1388
+ );
1389
+ });
1390
+
1391
+ bridge
1392
+ .command('set')
1393
+ .description('Set the bridge room for a platform (telegram|discord)')
1394
+ .argument('<platform>', 'telegram or discord')
1395
+ .argument('<room_id>', 'Matrix room id, e.g. !abc:quox')
1396
+ .option('--force', 'store the room even if it cannot be verified (it will be shown as unverified)')
1397
+ .action(async (platform, roomId, opts) => {
1398
+ auth.requireAuth();
1399
+ const ep = resolveEndpoint('v1.slots.bridges.set', { platform });
1400
+ // Phase 10: the collector now PROVES the room carries a slot line to
1401
+ // this platform before storing it, so the interesting path here is the
1402
+ // refusal. Print the probe's reason and the remediation rather than
1403
+ // letting a bare 422 surface, because "unprocessable entity" tells a
1404
+ // user nothing about a mis-typed room id.
1405
+ let data;
1406
+ try {
1407
+ data = await client.put(ep.path, {
1408
+ service: ep.service,
1409
+ body: { room_id: roomId, org_id: resolveOrgId(auth, program), force: !!opts.force },
1410
+ });
1411
+ } catch (err) {
1412
+ if (err instanceof QuoxApiError && err.status === 422) {
1413
+ const detail = err.detail || {};
1414
+ printWarning(`Not stored: ${err.message}`);
1415
+ const probe = detail.probe;
1416
+ if (probe) {
1417
+ printInfo(` room reachable by our relay puppet: ${probe.deliverable ? 'yes' : 'no'}`);
1418
+ printInfo(` is a ${platform} portal: ${probe.bridged ? 'yes' : 'no'}`);
1419
+ if (probe.delivery_error) printInfo(` join error: ${probe.delivery_error}`);
1420
+ if (probe.other_protocols?.length) printInfo(` bridged instead to: ${probe.other_protocols.join(', ')}`);
1421
+ }
1422
+ printInfo('Re-run with --force to store it anyway (it will be listed as unverified).');
1423
+ process.exitCode = 1;
1424
+ return;
1425
+ }
1426
+ throw err;
1427
+ }
1428
+ const entry = data.entry || null;
1429
+ if (entry && entry.verified_ok) {
1430
+ printSuccess(`${platform} bridge room set to ${roomId} (verified${entry.channel_label ? `: ${entry.channel_label}` : ''}).`);
1431
+ } else {
1432
+ printWarning(`${platform} bridge room set to ${roomId} but UNVERIFIED. Slot lines may render nowhere.`);
1433
+ }
1434
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1435
+ });
1436
+
1437
+ bridge
1438
+ .command('clear')
1439
+ .description('Clear the bridge room for a platform (telegram|discord)')
1440
+ .argument('<platform>', 'telegram or discord')
1441
+ .action(async (platform) => {
1442
+ auth.requireAuth();
1443
+ const ep = resolveEndpoint('v1.slots.bridges.set', { platform });
1444
+ const data = await client.put(ep.path, {
1445
+ service: ep.service,
1446
+ body: { room_id: null, org_id: resolveOrgId(auth, program) },
1447
+ });
1448
+ printSuccess(`${platform} bridge room cleared.`);
1449
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1450
+ });
1451
+ }