quoxcli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,584 @@
1
+ /**
2
+ * HTTP client for QuoxCLI
3
+ *
4
+ * Wraps native fetch with:
5
+ * - Bearer token injection
6
+ * - Service-aware URL routing (auth:3101, collector:9848, etc.)
7
+ * - Auto-refresh on 401
8
+ * - Retry with exponential backoff for transient errors
9
+ * - Debug logging
10
+ */
11
+
12
+ import { resolveActing, resolveActingToken } from './actingAs.js';
13
+ import { detectCallerSource } from './callerSource.js';
14
+ import { QuoxApiError, QuoxNetworkError, QuoxAuthError, QuoxTransientAuthError } from './errors.js';
15
+
16
+ const RETRY_STATUSES = new Set([429, 502, 503]);
17
+ const MAX_RETRIES = 3;
18
+ const RETRY_DELAYS = [1000, 3000, 9000];
19
+
20
+ // Generate a ULID-like correlation ID (timestamp + random, sortable, unique)
21
+ // quox#558: undici reports every TLS trust failure as a bare "fetch failed",
22
+ // which sent people debugging services that were up. Name the real cause and
23
+ // the sanctioned fix (trust the CA; never disable verification).
24
+ const TLS_TRUST_CODES = new Set([
25
+ 'SELF_SIGNED_CERT_IN_CHAIN',
26
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
27
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
28
+ 'CERT_HAS_EXPIRED',
29
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
30
+ ]);
31
+
32
+ export function describeNetworkError(service, baseUrl, err) {
33
+ const code = err?.cause?.code || err?.code;
34
+ let msg = `Could not connect to ${service} service at ${baseUrl}: ${err.message}`;
35
+ if (code && TLS_TRUST_CODES.has(code)) {
36
+ msg += `\n TLS: certificate not trusted (${code}). For a self-signed dev edge, trust its CA:`
37
+ + `\n export NODE_EXTRA_CA_CERTS=~/.config/quox/quox-dev-edge.pem`
38
+ + `\n Never disable TLS verification (NODE_TLS_REJECT_UNAUTHORIZED=0) instead.`;
39
+ } else if (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO') {
40
+ // quox#532 defect 1: the profile says https:// but the service answered
41
+ // PLAINTEXT (classic: url stamped https://localhost:3000 against plain
42
+ // http local services). Say "did you mean http://" instead of a raw
43
+ // fetch failed.
44
+ msg += `\n The server answered plaintext to a TLS handshake: this profile's url says https:// but the service speaks http.`
45
+ + `\n Did you mean ${String(baseUrl).replace(/^https:/, 'http:')} ? Fix it with: quox config set url <http-url>`;
46
+ } else if (code === 'ECONNREFUSED') {
47
+ // quox#548: "connection refused" could not distinguish service-absent
48
+ // from wrong-URL, and on boxes running a project-prefixed parallel stack
49
+ // the service often IS up, on a +10000 offset port. Say so instead of a
50
+ // dead end. Per-profile stamped service URLs also go stale silently
51
+ // (that half of quox#548 stays open) — the override is the escape hatch.
52
+ let offsetHint = '';
53
+ try {
54
+ const u = new URL(baseUrl);
55
+ if (u.port) offsetHint = ` (a project-prefixed stack would serve it at :${Number(u.port) + 10000})`;
56
+ } catch { /* baseUrl unparsable: skip the arithmetic, keep the rest */ }
57
+ msg += `\n Nothing is listening there: the service is absent, or this profile's URL is stale${offsetHint}.`
58
+ + `\n Check \`quox doctor\`, or override for this shell: export QUOX_${String(service).toUpperCase()}_URL=<url>`;
59
+ }
60
+ return msg;
61
+ }
62
+
63
+ function generateCorrelationId() {
64
+ const t = Date.now().toString(36);
65
+ const r = Math.random().toString(36).slice(2, 10);
66
+ return `cli-${t}-${r}`;
67
+ }
68
+
69
+ /**
70
+ * HITL-CONTEXT (owner 2026-10-07): name the calling session and profile so an
71
+ * approval this call triggers says "VULCAN (CLI) · profile moo" instead of a
72
+ * bare agent id. Display-only on the server; never used for authorization.
73
+ * Session: $QUOX_SESSION (the Claude/terminal session name).
74
+ */
75
+ export function callerContextHeaders(config, env = process.env, detectDeps = {}) {
76
+ const headers = {};
77
+ const clean = (v, n) => String(v).replace(/[^\w .:@+\-/]/g, '').trim().slice(0, n);
78
+ // QUOX_SESSION wins; otherwise auto-detect (QUOX_STREAM, attached agent,
79
+ // held slot, parent process) so the card never says only "CLI".
80
+ let session = env.QUOX_SESSION && clean(env.QUOX_SESSION, 48);
81
+ if (!session) {
82
+ try { session = detectCallerSource(env, detectDeps).label; } catch { session = null; }
83
+ }
84
+ if (session) headers['X-Quox-Session'] = clean(session, 48);
85
+ let profile = null;
86
+ try { profile = typeof config?.getActiveProfileName === 'function' ? config.getActiveProfileName() : null; } catch { profile = null; }
87
+ if (profile) {
88
+ const p = clean(profile, 32);
89
+ if (p) headers['X-Quox-Profile'] = p;
90
+ }
91
+ return headers;
92
+ }
93
+
94
+ /**
95
+ * The --debug "Body:" line. A sensitive request prints no body; otherwise top-level secret keys and
96
+ * any nested `credentials` object are masked (a top-level-only denylist printed vault create keys).
97
+ */
98
+ export function debugBody(body, sensitive = false) {
99
+ if (sensitive) return '[redacted: sensitive request]';
100
+ const sanitized = { ...body };
101
+ for (const key of ['password', 'token', 'access_token', 'refresh_token', 'secret', 'api_key', 'mfa_code', 'credentials']) {
102
+ if (sanitized[key]) sanitized[key] = '***';
103
+ }
104
+ return JSON.stringify(sanitized);
105
+ }
106
+
107
+ export function createClient(config, auth) {
108
+ let refreshing = null;
109
+ // After a failed refresh, back off instead of latching off forever. The
110
+ // permanent latch was correct for 2-second CLI invocations but killed
111
+ // long-lived daemons (2026-08-23: both resident supervisors poll-looped
112
+ // to death after ONE transient refresh failure, because this instance
113
+ // never tried again for the rest of its life). A cooldown keeps the
114
+ // don't-hammer-auth property without the death sentence.
115
+ const REFRESH_RETRY_COOLDOWN_MS = 60 * 1000;
116
+ let refreshFailedAt = 0;
117
+ const correlationId = generateCorrelationId();
118
+
119
+ async function request(method, path, options = {}) {
120
+ const {
121
+ service = 'auth',
122
+ body,
123
+ query,
124
+ headers: extraHeaders = {},
125
+ timeout = 30000,
126
+ raw = false,
127
+ retries = MAX_RETRIES,
128
+ noAuth = false,
129
+ noActAs = false,
130
+ sensitive = false,
131
+ } = options;
132
+
133
+ const baseUrl = config.getServiceUrl(service);
134
+ const url = new URL(path, baseUrl);
135
+
136
+ if (query) {
137
+ for (const [key, value] of Object.entries(query)) {
138
+ if (value != null) url.searchParams.set(key, String(value));
139
+ }
140
+ }
141
+
142
+ const fetchHeaders = {
143
+ 'X-Quox-Correlation': correlationId,
144
+ 'X-Quox-Client': 'cli',
145
+ ...callerContextHeaders(config),
146
+ ...extraHeaders,
147
+ };
148
+
149
+ // Inject auth. Precedence: a stored USER token always wins over an
150
+ // ambient QUOX_SERVICE_KEY. The service key is the automation/daemon
151
+ // fallback for when nobody has logged in — a long-running process
152
+ // shelling out to this CLI has no human session and no refresh token,
153
+ // so it can't participate in the login/refresh dance below.
154
+ //
155
+ // The other order was a live bug (quoxai/quox#404): dev boxes export
156
+ // QUOX_SERVICE_KEY globally for quoxpert-mcp, so every command after a
157
+ // successful `quox login` sent X-Service-Key instead of the user's
158
+ // bearer token. The auth service's user endpoints (/api/auth/me and
159
+ // friends) don't accept X-Service-Key, so login printed success (it
160
+ // uses a raw fetch, bypassing this client) and everything after it 401'd.
161
+ //
162
+ // SEC: a custom header like X-Service-Key is NOT stripped by fetch on a
163
+ // cross-origin redirect, unlike Authorization (which the spec drops).
164
+ // That makes the long-lived, higher-privilege service key MORE exposed to
165
+ // a rogue-redirect harvest than an ordinary user's bearer token. Refuse to
166
+ // follow redirects at all when the service key is in play, so the key is
167
+ // never resent to a host we did not choose. Scoped to the service-key path
168
+ // deliberately: normal login flows keep their existing redirect behaviour.
169
+ // QUOX_FORCE_SERVICE_KEY inverts the precedence above for a caller that
170
+ // OWNS its service key and must never borrow the shared config's stored
171
+ // session — a long-running daemon (discord-pro bridge, soak sampler) shares
172
+ // ~/.config/quox/config.json, so whatever profile another process logged in
173
+ // last leaks in as a stored token and wins. When that token drifts to an
174
+ // identity the collector rejects, the daemon goes dark (2026-09-04: the
175
+ // bridge 400'd for hours on a resident JWT). The force flag is
176
+ // opt-in, so the #404 interactive-dev-box case is untouched.
177
+ let usingServiceKey = false;
178
+ // quox#772: opt-in acting identity (--as / QUOX_AS). Replaces the bearer
179
+ // with the agent's attach token; throws (no request sent) if not attached.
180
+ const acting = !noAuth && !noActAs ? await resolveActing() : { name: null };
181
+ const actingAs = acting.name;
182
+ if (actingAs) {
183
+ const heartbeat = {
184
+ post: (p, o) => request('POST', p, { ...o, noActAs: true }),
185
+ get: (p, o) => request('GET', p, { ...o, noActAs: true }),
186
+ };
187
+ const attachToken = await resolveActingToken(actingAs, heartbeat, { implicit: acting.source === 'default' });
188
+ fetchHeaders['Authorization'] = `Bearer ${attachToken}`;
189
+ } else if (!noAuth) {
190
+ const tokens = auth.getTokens();
191
+ const serviceKey = process.env.QUOX_SERVICE_KEY;
192
+ const forceServiceKey = !!process.env.QUOX_FORCE_SERVICE_KEY && !!serviceKey;
193
+ if (forceServiceKey) {
194
+ fetchHeaders['X-Service-Key'] = serviceKey;
195
+ usingServiceKey = true;
196
+ } else if (tokens?.access_token) {
197
+ fetchHeaders['Authorization'] = `Bearer ${tokens.access_token}`;
198
+ } else if (serviceKey) {
199
+ fetchHeaders['X-Service-Key'] = serviceKey;
200
+ usingServiceKey = true;
201
+ }
202
+ }
203
+
204
+ if (body && !(body instanceof FormData)) {
205
+ fetchHeaders['Content-Type'] = 'application/json';
206
+ }
207
+
208
+ const controller = new AbortController();
209
+ const timer = setTimeout(() => controller.abort(), timeout);
210
+
211
+ const isDebug = process.argv.includes('--debug');
212
+
213
+ if (isDebug) {
214
+ console.error(`-> ${method} ${url.toString()}`);
215
+ if (body && !(body instanceof FormData)) {
216
+ console.error(` Body: ${debugBody(body, sensitive).slice(0, 200)}`);
217
+ }
218
+ }
219
+
220
+ let lastError;
221
+
222
+ for (let attempt = 0; attempt <= retries; attempt++) {
223
+ try {
224
+ const res = await fetch(url.toString(), {
225
+ method,
226
+ headers: fetchHeaders,
227
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
228
+ signal: controller.signal,
229
+ ...(usingServiceKey ? { redirect: 'manual' } : {}),
230
+ });
231
+
232
+ // See the redirect note above: with redirect:'manual' a 3xx comes back
233
+ // as a real response instead of being followed. Surface it loudly —
234
+ // a Quox service should never redirect a CLI call, so this is either a
235
+ // misconfiguration or someone trying to harvest the service key.
236
+ if (usingServiceKey && res.status >= 300 && res.status < 400) {
237
+ throw new QuoxApiError(
238
+ `Refused to follow a ${res.status} redirect while authenticating with QUOX_SERVICE_KEY `
239
+ + `(would resend the service key to ${res.headers.get('location') || 'an unknown host'}). `
240
+ + 'Point QUOX_URL at the real collector instead of a redirecting front end.',
241
+ res.status,
242
+ );
243
+ }
244
+
245
+ if (isDebug) {
246
+ console.error(`<- ${res.status} ${res.statusText} (${method} ${path})`);
247
+ }
248
+
249
+ // Auto-refresh on 401 — but only when the locally-tracked token
250
+ // actually looks expired. A 401 can also come from a route that
251
+ // requires entirely different credentials (e.g. a service-key-gated
252
+ // /internal/* route hit with a valid, non-expired user token) — that
253
+ // is not a session-expiry signal, and attempting a refresh anyway
254
+ // risks the refresh call itself failing and wiping the whole stored
255
+ // profile over what is really just "wrong token for this route".
256
+ // In that case, skip the refresh and fall through to the normal
257
+ // error handling below so the original 401/403 is surfaced as-is.
258
+ const looksExpired = typeof auth.isExpired === 'function' ? auth.isExpired() : true;
259
+
260
+ const refreshCoolingDown = refreshFailedAt && (Date.now() - refreshFailedAt < REFRESH_RETRY_COOLDOWN_MS);
261
+ if (res.status === 401 && !noAuth && !actingAs && attempt === 0 && !refreshCoolingDown && looksExpired) {
262
+ try {
263
+ if (!refreshing) {
264
+ const serviceUrl = config.getServiceUrl('auth');
265
+ refreshing = auth.refresh(serviceUrl).finally(() => { refreshing = null; });
266
+ }
267
+ await refreshing;
268
+
269
+ // Update auth header and retry
270
+ const newTokens = auth.getTokens();
271
+ if (newTokens?.access_token) {
272
+ fetchHeaders['Authorization'] = `Bearer ${newTokens.access_token}`;
273
+ }
274
+ continue;
275
+ } catch (refreshErr) {
276
+ // Genuine refresh failure — start the retry cooldown and surface
277
+ // a clear re-login prompt. A daemon's next request after the
278
+ // cooldown gets another chance; see REFRESH_RETRY_COOLDOWN_MS.
279
+ //
280
+ // CARRY THE REAL REASON. A bare `catch {}` here replaced every
281
+ // refresh failure with "Session expired", which is a GUESS at the
282
+ // cause: auth.refresh() distinguishes a dead token from an
283
+ // unhealthy service from a malformed request, and all three arrived
284
+ // as the same sentence telling the user to log in again. That cost
285
+ // hours on 2026-08-24, when the real cause was the CLI sending the
286
+ // refresh token in a header the server never reads.
287
+ clearTimeout(timer);
288
+ if (refreshErr && refreshErr.transient) {
289
+ // Auth is only temporarily unavailable (429 / restart): no
290
+ // cooldown, and keep the type so a listener can back off.
291
+ throw new QuoxTransientAuthError(`Re-authentication temporarily unavailable. ${refreshErr.message}`, refreshErr.retryAfterMs ?? null);
292
+ }
293
+ refreshFailedAt = Date.now();
294
+ const why = refreshErr?.message ? ` ${refreshErr.message}` : '';
295
+ throw new QuoxAuthError(`Session expired. Run \`quox login\` to re-authenticate.${why}`);
296
+ }
297
+ }
298
+
299
+ // Retry transient errors
300
+ if (RETRY_STATUSES.has(res.status) && attempt < retries) {
301
+ const delay = RETRY_DELAYS[attempt] || 9000;
302
+ if (isDebug) {
303
+ console.error(` Retrying in ${delay}ms (attempt ${attempt + 1}/${retries})...`);
304
+ }
305
+ await sleep(delay);
306
+ continue;
307
+ }
308
+
309
+ clearTimeout(timer);
310
+
311
+ if (raw) return res;
312
+
313
+ if (!res.ok) {
314
+ let data;
315
+ try {
316
+ data = await res.json();
317
+ } catch {
318
+ data = { error: 'unknown', message: res.statusText };
319
+ }
320
+ // A permission error while we silently fell back to the SERVICE
321
+ // KEY is almost never a permissions problem: the service key
322
+ // carries no user identity, so any route gated on who you are
323
+ // refuses it. Say that, because the raw error sends people to fix
324
+ // the wrong thing. Live cost, 2026-08-23: the instance owner hit
325
+ // `403 instance_admin_required` on `quox admin config`, and it was
326
+ // filed as a missing-role bug (quox#470) with a chicken-and-egg
327
+ // theory about who could grant it. He had held instance-admin
328
+ // since March. His session had simply expired and the CLI had
329
+ // quietly downgraded the request to a key with no user attached.
330
+ let message = data.message || `Request failed with status ${res.status}`;
331
+ // quox#617: surface the server's machine-readable `reason`. A vault
332
+ // update returns 403 with `reason: 'mfa_required'` for an MFA-enabled
333
+ // owner (a step-up gate) vs `reason: 'role_<r>_lacks_capability_<cap>'`
334
+ // for a genuine missing-permission — two very different fixes that
335
+ // read as one opaque "you do not have permission" once the reason is
336
+ // dropped. The body already carries it (and reaches .body below); we
337
+ // just also fold it into the human message so the WHY is visible
338
+ // without --debug. Skipped when the message already contains it.
339
+ if (data.reason && !String(message).includes(data.reason)) {
340
+ message += ` (reason: ${data.reason})`;
341
+ }
342
+ if (usingServiceKey && (res.status === 403 || res.status === 401)) {
343
+ message += `\nNote: this request used the service key (QUOX_SERVICE_KEY) because no user session was stored for this profile. `
344
+ + `The service key has no user identity, so anything gated on WHO you are will refuse it regardless of your real permissions. `
345
+ + `Run \`quox login\` (or \`quox -p <profile> login\`) and retry before treating this as a permissions problem.`;
346
+ }
347
+ // LL-3d: carry the WHOLE body, not just message/detail. Endpoints
348
+ // that answer with structured context (the licensing 409's
349
+ // bound_instance_name / bound[] / max_servers, the 402's plugin)
350
+ // had it flattened away here, so command handlers could only print
351
+ // "Request failed with status 409".
352
+ throw new QuoxApiError(
353
+ res.status,
354
+ data.error || 'api_error',
355
+ message,
356
+ data.detail || data.details,
357
+ data
358
+ );
359
+ }
360
+
361
+ // Handle empty responses (204 No Content)
362
+ if (res.status === 204) return { success: true };
363
+
364
+ const contentType = res.headers.get('content-type') || '';
365
+ if (contentType.includes('application/json')) {
366
+ return await res.json();
367
+ }
368
+ return { success: true, text: await res.text() };
369
+
370
+ } catch (err) {
371
+ clearTimeout(timer);
372
+
373
+ if (err instanceof QuoxApiError || err instanceof QuoxAuthError) {
374
+ throw err;
375
+ }
376
+
377
+ if (err.name === 'AbortError') {
378
+ throw new QuoxNetworkError(`Request timed out after ${timeout}ms`);
379
+ }
380
+
381
+ lastError = err;
382
+
383
+ if (attempt < retries && isTransientError(err)) {
384
+ const delay = RETRY_DELAYS[attempt] || 9000;
385
+ await sleep(delay);
386
+ continue;
387
+ }
388
+
389
+ throw new QuoxNetworkError(
390
+ describeNetworkError(service, baseUrl, err),
391
+ err
392
+ );
393
+ }
394
+ }
395
+
396
+ throw lastError || new QuoxNetworkError('Request failed after retries');
397
+ }
398
+
399
+ /**
400
+ * Open a Server-Sent Events (SSE) stream and yield parsed events.
401
+ *
402
+ * Mirrors the collector's `POST /chat/stream` contract (quox#782): when
403
+ * `options.body` is given it is sent as a JSON POST body so prompts never
404
+ * appear in the URL; `options.query` remains for non-sensitive params.
405
+ * Response is `text/event-stream`
406
+ * with `data: <json>\n\n` frames (plus `: ping` keep-alive comments).
407
+ *
408
+ * Each yielded value is the parsed JSON payload of one `data:` frame
409
+ * (e.g. `{ type: 'connected', ... }`, `{ type: 'chunk', content: '...' }`,
410
+ * `{ type: 'done', ... }`, `{ type: 'error', error: '...' }`). The
411
+ * generator returns naturally on `done`/`error` events or when the
412
+ * server closes the connection.
413
+ *
414
+ * @param {string} path - e.g. '/chat/stream'
415
+ * @param {object} options
416
+ * @param {string} [options.service='collector']
417
+ * @param {object} [options.query] - query string params (never put prompts here)
418
+ * @param {object} [options.body] - JSON body; switches the request to POST
419
+ * @param {object} [options.headers] - extra headers
420
+ * @param {AbortSignal} [options.signal] - external abort signal (e.g. Ctrl+C)
421
+ * @param {boolean} [options.untilClose] - keep reading past done/error until the server closes (quoxon: turn.finished arrives after done)
422
+ */
423
+ async function* stream(path, options = {}) {
424
+ const {
425
+ service = 'collector',
426
+ query,
427
+ body: streamBody,
428
+ headers: extraHeaders = {},
429
+ signal: externalSignal,
430
+ untilClose = false,
431
+ } = options;
432
+
433
+ const baseUrl = config.getServiceUrl(service);
434
+ const url = new URL(path, baseUrl);
435
+
436
+ if (query) {
437
+ for (const [key, value] of Object.entries(query)) {
438
+ if (value != null) url.searchParams.set(key, String(value));
439
+ }
440
+ }
441
+
442
+ const fetchHeaders = {
443
+ Accept: 'text/event-stream',
444
+ 'X-Quox-Correlation': correlationId,
445
+ ...(streamBody !== undefined ? { 'Content-Type': 'application/json' } : {}),
446
+ ...extraHeaders,
447
+ };
448
+
449
+ // Same precedence as request() above, including the QUOX_FORCE_SERVICE_KEY
450
+ // override for daemons that own their key and must not borrow the shared
451
+ // config's stored session.
452
+ const streamActingR = await resolveActing();
453
+ const streamActing = streamActingR.name;
454
+ const streamTokens = auth.getTokens();
455
+ let streamServiceKey = null;
456
+ const streamForceKey = !!process.env.QUOX_FORCE_SERVICE_KEY && !!process.env.QUOX_SERVICE_KEY;
457
+ if (streamActing) {
458
+ const heartbeat = { post: (p, o) => request('POST', p, { ...o, noActAs: true }), get: (p, o) => request('GET', p, { ...o, noActAs: true }) };
459
+ fetchHeaders['Authorization'] = `Bearer ${await resolveActingToken(streamActing, heartbeat, { implicit: streamActingR.source === 'default' })}`;
460
+ } else if (streamForceKey) {
461
+ streamServiceKey = process.env.QUOX_SERVICE_KEY;
462
+ fetchHeaders['X-Service-Key'] = streamServiceKey;
463
+ } else if (streamTokens?.access_token) {
464
+ fetchHeaders['Authorization'] = `Bearer ${streamTokens.access_token}`;
465
+ } else if (process.env.QUOX_SERVICE_KEY) {
466
+ streamServiceKey = process.env.QUOX_SERVICE_KEY;
467
+ fetchHeaders['X-Service-Key'] = streamServiceKey;
468
+ }
469
+
470
+ const isDebug = process.argv.includes('--debug');
471
+ if (isDebug) {
472
+ console.error(`-> ${streamBody !== undefined ? 'POST' : 'GET'}(stream) ${url.toString()}`);
473
+ }
474
+
475
+ let res;
476
+ try {
477
+ res = await fetch(url.toString(), {
478
+ method: streamBody !== undefined ? 'POST' : 'GET',
479
+ headers: fetchHeaders,
480
+ ...(streamBody !== undefined ? { body: JSON.stringify(streamBody) } : {}),
481
+ signal: externalSignal,
482
+ // Same redirect protection as the non-streaming path above: never let
483
+ // a 3xx resend X-Service-Key to a host we did not choose.
484
+ ...(streamServiceKey ? { redirect: 'manual' } : {}),
485
+ });
486
+ } catch (err) {
487
+ throw new QuoxNetworkError(
488
+ describeNetworkError(service, baseUrl, err),
489
+ err
490
+ );
491
+ }
492
+
493
+ if (!res.ok) {
494
+ let data;
495
+ try {
496
+ data = await res.json();
497
+ } catch {
498
+ data = { error: 'unknown', message: res.statusText };
499
+ }
500
+ throw new QuoxApiError(
501
+ res.status,
502
+ data.error || 'api_error',
503
+ data.message || `Stream request failed with status ${res.status}`,
504
+ data.detail || data.details,
505
+ data
506
+ );
507
+ }
508
+
509
+ if (!res.body) {
510
+ throw new QuoxNetworkError('Stream response had no body.');
511
+ }
512
+
513
+ const reader = res.body.getReader();
514
+ const decoder = new TextDecoder();
515
+ let buffer = '';
516
+
517
+ try {
518
+ while (true) {
519
+ const { done, value } = await reader.read();
520
+ if (done) break;
521
+
522
+ buffer += decoder.decode(value, { stream: true });
523
+ const lines = buffer.split('\n');
524
+ buffer = lines.pop() ?? '';
525
+
526
+ for (const line of lines) {
527
+ if (!line.startsWith('data:')) continue; // skip blank lines and ": ping" comments
528
+ const raw = line.slice(5).trim();
529
+ if (!raw || raw === '[DONE]') continue;
530
+
531
+ let event;
532
+ try {
533
+ event = JSON.parse(raw);
534
+ } catch {
535
+ if (isDebug) console.error(` [stream] skipping malformed frame: ${raw.slice(0, 100)}`);
536
+ continue;
537
+ }
538
+
539
+ yield event;
540
+
541
+ if (!untilClose && (event.type === 'done' || event.type === 'error')) {
542
+ return;
543
+ }
544
+ }
545
+ }
546
+ } finally {
547
+ // cancel first so the HTTP body is torn down, not just unlocked
548
+ try { reader.cancel().catch(() => {}); } catch { /* already closed */ }
549
+ try { reader.releaseLock(); } catch { /* already released */ }
550
+ }
551
+ }
552
+
553
+ return {
554
+ get(path, options) {
555
+ return request('GET', path, options);
556
+ },
557
+ post(path, options) {
558
+ return request('POST', path, options);
559
+ },
560
+ patch(path, options) {
561
+ return request('PATCH', path, options);
562
+ },
563
+ put(path, options) {
564
+ return request('PUT', path, options);
565
+ },
566
+ delete(path, options) {
567
+ return request('DELETE', path, options);
568
+ },
569
+ request,
570
+ stream,
571
+ };
572
+ }
573
+
574
+ function sleep(ms) {
575
+ return new Promise(resolve => setTimeout(resolve, ms));
576
+ }
577
+
578
+ function isTransientError(err) {
579
+ const msg = err.message || '';
580
+ return msg.includes('ECONNRESET') ||
581
+ msg.includes('ECONNREFUSED') ||
582
+ msg.includes('ETIMEDOUT') ||
583
+ msg.includes('fetch failed');
584
+ }