quoxcli 0.2.0 → 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 (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -10,6 +10,8 @@
10
10
  * /messages), never a direct Matrix client in the CLI.
11
11
  */
12
12
 
13
+ import { presenceText } from './presence.js';
14
+ import { readInstalledBuild, CLI_DIR } from '../core/cliBuild.js';
13
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, openSync, closeSync } from 'node:fs';
14
16
  import { spawn } from 'node:child_process';
15
17
  import { homedir } from 'node:os';
@@ -20,8 +22,12 @@ import { events, printEvent } from '../core/quoxglyph.js';
20
22
  import { resolveEndpoint } from '../api/endpoints.js';
21
23
  import { QuoxApiError, QuoxNetworkError } from '../core/errors.js';
22
24
  import { resolveOrgId } from './helpers.js';
25
+ import { parseBroadcastArgs, broadcastSummary, deliveryLine, parseAckArgs } from '../quoxon/slash/broadcast.js';
23
26
  import { claudeSessionInfo, procStartTime, resolveMyAttach, readSlotState } from './agentAttach.js';
24
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';
25
31
 
26
32
  // Local record of the slots checked out from this box, written at checkout
27
33
  // and cleared at release. Consumers (the slot-inbox hook) re-verify each
@@ -53,7 +59,9 @@ function writeHeldSlots(all, path) {
53
59
  // `info` is claudeSessionInfo() ({ sessionKey, pid, starttime }) or null.
54
60
  // Entries written before quox#662 are { session, recorded_at } only, and
55
61
  // still read fine: they just carry no process binding.
56
- export function recordHeldSlot(name, session, path = heldSlotsPath(), info = null, member = null) {
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) {
57
65
  try {
58
66
  const all = readHeldSlots(path);
59
67
  // P10: a joining member must not clobber a sibling session's HOLDER entry
@@ -65,6 +73,7 @@ export function recordHeldSlot(name, session, path = heldSlotsPath(), info = nul
65
73
  ...(info ? { sessionKey: info.sessionKey, pid: info.pid, starttime: info.starttime } : {}),
66
74
  // P10: joined an already-held slot as a member (not the holder).
67
75
  ...(member ? { member: true, splinter: member.splinter ?? null, member_key: member.member_key ?? null } : {}),
76
+ ...(profile ? { profile } : {}),
68
77
  recorded_at: new Date().toISOString(),
69
78
  };
70
79
  writeHeldSlots(all, path);
@@ -316,6 +325,24 @@ export function registerSlotCommands(program, ctx) {
316
325
  ...(ctx.deps || {}),
317
326
  };
318
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
+
319
346
  // Session identity (quox#662). Holder names are free text and every verb
320
347
  // used to default differently, so EMAIL-AGENTS vs email-agents 409'd. One
321
348
  // rule now: --session wins; else the binding this very Claude session
@@ -348,6 +375,31 @@ export function registerSlotCommands(program, ctx) {
348
375
  }
349
376
  }
350
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
+
351
403
  // P10: the collector recognises splinter members only via the ATTACH token.
352
404
  // When this Claude session is attached to an agent whose recorded slot is
353
405
  // `name`, send that token (same noAuth + Authorization pattern as
@@ -385,6 +437,8 @@ export function registerSlotCommands(program, ctx) {
385
437
  // --json gets the API records (data keys), never the table cells.
386
438
  raw: data.slots || [],
387
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)}`);
388
442
  });
389
443
 
390
444
  slot
@@ -515,7 +569,7 @@ export function registerSlotCommands(program, ctx) {
515
569
  // moment the binding is known for certain, so it gets written down
516
570
  // here rather than re-derived later.
517
571
  const me = deps.claudeSessionInfo();
518
- recordHeldSlot(name, holderSession, heldSlotsPath(), me, joined);
572
+ recordHeldSlot(name, holderSession, heldSlotsPath(), me, joined, profileName());
519
573
  // Owner incident, 2026-08-22/23: "you cant bind to a slot and then
520
574
  // immediately ignore the channel it creates". Checkout hands back a
521
575
  // live address (a Matrix/Discord room), not just a lock; say so once,
@@ -553,6 +607,24 @@ export function registerSlotCommands(program, ctx) {
553
607
  throw new Error(`slot ${name} is assigned to ${err.detail.assigned_to}; pass --force to take it anyway`);
554
608
  }
555
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
+ }
556
628
  throw new Error(`slot ${name} is held by ${holder}`);
557
629
  }
558
630
  // fleet #449: a bare "404 Not Found: unknown" against an instance
@@ -586,6 +658,12 @@ export function registerSlotCommands(program, ctx) {
586
658
  ...attachAuthFor(name),
587
659
  body: { holder_session: relSession || undefined, force: !!options.force, org_id: resolveOrgId(auth, program) },
588
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
+ }
589
667
  formatDetail(data.slot || data, { format: program.opts().output, title: `Slot released: ${name}` });
590
668
  printEvent(events.released('quox', `slot ${name}`, relSession || 'this box'));
591
669
  forgetHeldSlot(name);
@@ -596,7 +674,7 @@ export function registerSlotCommands(program, ctx) {
596
674
  .command('control')
597
675
  .description('Send a control command to the session holding a slot (owner/admin only). Commands: compact, status')
598
676
  .argument('<name>', 'slot name')
599
- .argument('<command>', 'control command (compact | status)')
677
+ .argument('<command>', 'control command (compact | status | wake)')
600
678
  .option('--wait', 'wait up to 40s for the keeper to ack and print its answer (always on for status)')
601
679
  .action(async (name, command, options) => {
602
680
  auth.requireAuth();
@@ -733,14 +811,70 @@ export function registerSlotCommands(program, ctx) {
733
811
  if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
734
812
  });
735
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
+
736
861
  slot
737
862
  .command('say')
738
863
  .description("Say something into a slot's Matrix room (use - to read a multi-line, Markdown message from stdin)")
739
864
  .argument('<name>', 'slot name')
740
865
  .argument('<text...>', 'message text')
741
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')
742
869
  .action(async (name, textParts, options) => {
743
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
+ }
744
878
  // `say <name> -` reads the message from stdin (owner 2026-10-05: slot
745
879
  // channels were "enormous single paragraphs" because multi-line text is
746
880
  // awkward as a shell argument). Line breaks and Markdown survive as-is.
@@ -759,16 +893,24 @@ export function registerSlotCommands(program, ctx) {
759
893
  // authenticated identity.
760
894
  const from = resolveSession(options, name, null);
761
895
  const ep = resolveEndpoint('v1.slots.say', { name });
762
- const body = { text, ...(from ? { from } : {}), org_id: resolveOrgId(auth, program) };
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;
763
903
  // A freshly created slot gets its Matrix room a few seconds AFTER
764
904
  // checkout returns (provisioning is async), so a say straight after
765
905
  // checkout used to fail with 409 "no Matrix room yet" (slots audit
766
906
  // 2026-10-02, measured ~5s). Ride out exactly that race; any other
767
907
  // 409 is real and surfaces immediately.
768
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.`);
769
911
  for (let attempt = 1; ; attempt += 1) {
770
912
  try {
771
- data = await client.post(ep.path, { service: ep.service, ...attachAuthFor(name), body });
913
+ data = await sayVia.client.post(ep.path, { service: ep.service, ...attachAuthFor(name), body });
772
914
  break;
773
915
  } catch (err) {
774
916
  // The collector sends {error: '<text>'}, which the client stores
@@ -790,14 +932,95 @@ export function registerSlotCommands(program, ctx) {
790
932
  `running in the background so replies are heard and the hold stays fresh.`
791
933
  );
792
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
+ }
793
952
  throw err;
794
953
  }
795
954
  }
796
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);
797
963
  reportBridgeDelivery(data?.bridges);
798
964
  if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
799
965
  });
800
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
+
801
1024
  /**
802
1025
  * Print what actually reached each bridge.
803
1026
  *
@@ -812,6 +1035,21 @@ export function registerSlotCommands(program, ctx) {
812
1035
  * Silent when no bridges are configured: an org that has not set one up does
813
1036
  * not need a line about it on every say.
814
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
+ }
815
1053
  function reportBridgeDelivery(bridges) {
816
1054
  if (!Array.isArray(bridges) || bridges.length === 0) return;
817
1055
  for (const b of bridges) {
@@ -872,6 +1110,7 @@ export function registerSlotCommands(program, ctx) {
872
1110
  .option('--since <ts>', 'resume from this ISO-8601 cursor (from a previous wait timeout)')
873
1111
  .option('--timeout <mins>', 'give up after this many minutes of silence (default: wait forever)', (v) => parseFloat(v))
874
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)')
875
1114
  .option('--also <slots>', 'also listen on these peer slots (comma-separated, read-only): hear replies another session posts in ITS slot')
876
1115
  .action(async (name, options) => {
877
1116
  if (options.also) return waitMany(name, options);
@@ -884,6 +1123,10 @@ export function registerSlotCommands(program, ctx) {
884
1123
  // separate heartbeat loop too.
885
1124
  auth.requireAuth();
886
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
+ }
887
1130
  const ep = resolveEndpoint('v1.slots.wait', { name });
888
1131
  const deadline = Number.isFinite(options.timeout) && options.timeout > 0
889
1132
  ? Date.now() + options.timeout * 60 * 1000
@@ -896,7 +1139,7 @@ export function registerSlotCommands(program, ctx) {
896
1139
  const query = { org_id: resolveOrgId(auth, program), timeout_ms: 50000 };
897
1140
  if (cursor) query.since = cursor;
898
1141
  if (session) {
899
- query.holder_session = session;
1142
+ if (!options.join) query.holder_session = session; // a room member does not hold
900
1143
  query.exclude_from = session;
901
1144
  }
902
1145
  let data;
@@ -956,7 +1199,7 @@ export function registerSlotCommands(program, ctx) {
956
1199
  // quox#662/C: a wait whose hold is gone used to warn once and keep
957
1200
  // polling forever: deaf AND unheld. Exit non-zero so whatever
958
1201
  // supervises this process (the Claude background-task wake) notices.
959
- const holdLost = !!session && data.held === false;
1202
+ const holdLost = !!session && !options.join && data.held === false;
960
1203
  const holdLostLine = `HOLD LOST: ${name} is no longer held by ${session}; re-run quox slot checkout ${name} --session ${session}`;
961
1204
 
962
1205
  if ((data.messages || []).length > 0) {
@@ -1030,6 +1273,16 @@ export function registerSlotCommands(program, ctx) {
1030
1273
  const tmuxPane = validTmuxPane(options.tmuxPane) ? options.tmuxPane : (validTmuxPane(process.env.TMUX_PANE) ? process.env.TMUX_PANE : null);
1031
1274
  const res = await runKeeper({
1032
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
+ },
1033
1286
  sleep: ctx.sleep,
1034
1287
  tmuxPane,
1035
1288
  pollControl: async () => {
@@ -100,7 +100,7 @@ export function registerStatusCommand(program, ctx) {
100
100
  // actually reaches the collector instead of being withheld.
101
101
  const data = await client.get(resolveEndpoint('v1.health.aggregate.list').path, {
102
102
  service: resolveEndpoint('v1.health.aggregate.list').service,
103
- timeout: 5000,
103
+ timeout: 15000, // #930: the aggregate fans out to several services; 5s was too tight under load
104
104
  });
105
105
 
106
106
  if (isJson) {
@@ -99,7 +99,9 @@ export function registerTeamCommands(program, ctx) {
99
99
  .argument('<teamId>', 'team ID')
100
100
  .action(async (teamId) => {
101
101
  auth.requireAuth();
102
- const data = await client.post(resolveEndpoint('teams.provisionRoom', { teamId }).path, { service: resolveEndpoint('teams.provisionRoom', { teamId }).service });
102
+ const orgId = resolveOrgId(auth, program);
103
+ const ep = resolveEndpoint('teams.provisionRoom', { orgId, teamId });
104
+ const data = await client.post(ep.path, { service: ep.service, body: {} });
103
105
  printSuccess('Team Matrix room provisioned.');
104
106
  const room = data.room || data;
105
107
  formatDetail(room, { format: program.opts().output, title: 'Matrix Room' });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * quox tips [--json]
3
+ *
4
+ * The canned "did you know" tips behind the terminal's /tip (QUOXON-TIPS). Offline: this prints the registry
5
+ * (cli/lib/quoxon/tips.js), not what is ready on your install; the terminal applies the honesty gate
6
+ * (shipped maturity, capabilities contract, /tip-settings) when it shows one. --json (or -o json) is the
7
+ * shape the dashboard and cli.quox.ai reuse.
8
+ */
9
+
10
+ import { TIPS, tipsJson, tipsReply } from '../quoxon/tips.js';
11
+
12
+ export function registerTipsCommands(program) {
13
+ program
14
+ .command('tips')
15
+ .description('List the "did you know" tips the terminal shows (/tip); --json for the registry as data')
16
+ .argument('[query...]', 'search the tips by topic or words (like /tips); bare lists every tip')
17
+ .option('--json', 'print the registry as JSON')
18
+ .action((query, opts) => {
19
+ if (opts.json || program.opts().output === 'json') { console.log(JSON.stringify(tipsJson(), null, 2)); return; }
20
+ if (query?.length) { for (const l of tipsReply(query.join(' ')).lines) console.log(l); return; }
21
+ for (const t of TIPS) console.log(`${t.id} [${t.product} · ${t.tier} · ${t.maturity}] ${t.text} Try: ${t.try}`);
22
+ });
23
+ }