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,467 @@
1
+ /**
2
+ * /connect: connections status, the secure key card's logic, the composer paste guard and the
3
+ * gentle "connect it now?" offer. Pure, apart from the api object passed in.
4
+ *
5
+ * Where a key goes (the path /chat/stream reads): the org's DEFAULT QuoxVault credential of the
6
+ * provider's integration type. The collector resolves OpenAI via auth /internal/resolve ->
7
+ * credentialResolver.resolveProviderKey(org, 'openai') -> getDefaultCredential(org, 'openai').
8
+ * The write is auth POST /api/orgs/:orgId/vault (alias of /integrations: SECRETS_WRITE gated,
9
+ * audited, AEE envelope carrying integration_type + label only). The label is "<Name> …last4",
10
+ * so the audit trail holds a fingerprint and never the key.
11
+ *
12
+ * The key itself lives only inside a secret box (closure). It is never dispatched, never put in
13
+ * the composer buffer or history, never sent to the chat stream, never written to a transcript.
14
+ */
15
+
16
+ export const PROVIDERS = {
17
+ openai: {
18
+ id: 'openai', type: 'openai', name: 'OpenAI', field: 'api_key', card: true, minLen: 20,
19
+ why: 'Quox needs an OpenAI key to use GPT models for your org.',
20
+ keyUrl: 'https://platform.openai.com/api-keys'
21
+ },
22
+ anthropic: {
23
+ id: 'anthropic', type: 'anthropic', name: 'Claude API', field: 'api_key', card: true, minLen: 20,
24
+ why: 'Quox needs an Anthropic API key to call Claude models for your org.',
25
+ keyUrl: 'https://console.anthropic.com/settings/keys'
26
+ },
27
+ // B3: no key; a server URL. /connect ollama <url> probes it server side, then saves.
28
+ ollama: { id: 'ollama', type: 'ollama', name: 'Ollama', card: false, url: true },
29
+ // B5: hosted OpenAI-compatible presets. Same secure key card; the third-party data path is stated on it.
30
+ openrouter: hosted('openrouter', 'OpenRouter', 'api_key', 'https://openrouter.ai/settings/keys',
31
+ "OpenRouter, which passes them to the model's provider"),
32
+ huggingface: hosted('huggingface', 'Hugging Face', 'api_token', 'https://huggingface.co/settings/tokens',
33
+ "Hugging Face, which passes them to the inference provider that serves the model"),
34
+ groq: hosted('groq', 'Groq', 'api_key', 'https://console.groq.com/keys', 'Groq'),
35
+ together: hosted('together', 'Together', 'api_key', 'https://api.together.ai/settings/api-keys', 'Together AI'),
36
+ // B5: self-hosted OpenAI-compatible servers: no key, a URL. Probed server side, then saved.
37
+ lmstudio: selfHosted('lmstudio', 'LM Studio', 1234),
38
+ vllm: selfHosted('vllm', 'vLLM', 8000),
39
+ // B5: any https OpenAI-compatible base URL, key optional (asked for only when the server needs one).
40
+ custom: {
41
+ id: 'custom', type: 'openai_compatible', name: 'Custom', field: 'api_key', card: false, url: true, custom: true, minLen: 8,
42
+ why: 'This endpoint wants an API key. It is stored in your org vault with the base URL.',
43
+ keyUrl: 'the provider of that endpoint',
44
+ disclosure: 'Custom endpoint: your prompts and tool results leave this server for that URL. Unless it is your own server, that is a third party. Your org\'s AI data sharing consent applies.'
45
+ }
46
+ };
47
+ function hosted(id, name, field, keyUrl, who) {
48
+ return {
49
+ id, type: id, name, field, card: true, hosted: true, minLen: 20, keyUrl,
50
+ why: `Quox needs a ${name} key to use ${name} models for your org.`,
51
+ disclosure: `Hosted: your prompts and tool results leave this server for ${who}. Your org's AI data sharing consent applies.`
52
+ };
53
+ }
54
+ function selfHosted(id, name, port) {
55
+ return { id, type: id, name, card: false, url: true, selfHosted: true, port };
56
+ }
57
+ const ALIASES = {
58
+ 'anthropic-api': 'anthropic', claude: 'anthropic', gpt: 'openai', chatgpt: 'openai',
59
+ hf: 'huggingface', 'lm-studio': 'lmstudio', 'openai-compatible': 'custom', openai_compatible: 'custom'
60
+ };
61
+ export const providerFor = (name) => PROVIDERS[ALIASES[String(name || '').toLowerCase()] || String(name || '').toLowerCase()] || null;
62
+
63
+ // ---- paste guard -------------------------------------------------------------------------------
64
+ const ANTHROPIC_RE = /\bsk-ant-[A-Za-z0-9_-]{16,}/;
65
+ const OPENROUTER_RE = /\bsk-or-(?:v1-)?[A-Za-z0-9_-]{16,}/;
66
+ const GROQ_RE = /\bgsk_[A-Za-z0-9]{20,}/;
67
+ const HF_RE = /\bhf_[A-Za-z0-9]{20,}/;
68
+ const OPENAI_RE = /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/;
69
+
70
+ /** -> { provider } when the text carries something API-key shaped, else null. */
71
+ export function looksLikeKey(text) {
72
+ const s = String(text || '');
73
+ if (ANTHROPIC_RE.test(s)) return { provider: 'anthropic' };
74
+ if (OPENROUTER_RE.test(s)) return { provider: 'openrouter' }; // must win over the generic sk- shape
75
+ if (GROQ_RE.test(s)) return { provider: 'groq' };
76
+ if (HF_RE.test(s)) return { provider: 'huggingface' };
77
+ if (OPENAI_RE.test(s)) return { provider: 'openai' };
78
+ return null;
79
+ }
80
+ export const keyWarning = (hit) => `That looks like an API key. I didn't send it. Use /connect ${hit?.provider || 'openai'} to store it safely.`;
81
+
82
+ // ---- masked input ------------------------------------------------------------------------------
83
+ /** Bullets while typing; after a paste, four bullets and the last 4 (enough to recognise the key). */
84
+ export function maskSecret(value, pasted) {
85
+ const v = String(value || '');
86
+ if (!v) return '';
87
+ if (pasted && v.length >= 8) return `••••${v.slice(-4)}`;
88
+ return '•'.repeat(Math.min(v.length, 32));
89
+ }
90
+
91
+ /** Holds the secret in a closure. Serialising the box shows nothing. */
92
+ export function createSecretBox() {
93
+ let v = ''; let pasted = false;
94
+ const clean = (t) => String(t || '').replace(/[\s\x00-\x1f\x7f]+/g, '');
95
+ return {
96
+ type(t) { v += clean(t); },
97
+ paste(t) { v += clean(t); pasted = true; },
98
+ backspace() { v = [...v].slice(0, -1).join(''); if (!v) pasted = false; },
99
+ clear() { v = ''; pasted = false; },
100
+ length: () => v.length,
101
+ last4: () => v.slice(-4),
102
+ mask: () => maskSecret(v, pasted),
103
+ /** Hands the value out once and empties the box. */
104
+ take() { const out = v; v = ''; pasted = false; return out; },
105
+ toJSON: () => '[secret]',
106
+ toString: () => '[secret]'
107
+ };
108
+ }
109
+
110
+ // ---- connections status ------------------------------------------------------------------------
111
+ const testTag = (r) => r.test_status === 'success' ? 'tested ✔' : r.test_status === 'failed' ? 'last test failed' : 'untested';
112
+ export const pickRow = (rows, type) => {
113
+ const of = rows.filter(r => r.integration_type === type);
114
+ return { def: of.find(r => r.is_default === 1 || r.is_default === true) || null, any: of[0] || null };
115
+ };
116
+
117
+ /**
118
+ * -> [[text, tone?]] lines for /connect. `res` is { ok:true, rows } from the vault list, or
119
+ * { ok:false, error }. Only the org vault is readable by a user; anything it cannot answer is "? unknown".
120
+ */
121
+ export function connectionLines(res) {
122
+ const STATUS_PROVIDERS = [PROVIDERS.openai, PROVIDERS.anthropic, PROVIDERS.ollama, PROVIDERS.openrouter, PROVIDERS.huggingface, PROVIDERS.groq, PROVIDERS.together, PROVIDERS.lmstudio, PROVIDERS.vllm, PROVIDERS.custom];
123
+ const out = [];
124
+ const ok = res && res.ok && Array.isArray(res.rows);
125
+ const rows = ok ? res.rows : [];
126
+ out.push(['Connections for this org:']);
127
+ for (const p of STATUS_PROVIDERS) {
128
+ const hint = p.custom ? '/connect custom https://<host>/v1' : p.card ? `/connect ${p.id}` : p.url ? `/connect ${p.id} http://<host>:${p.port || 11434}` : `quox vault create --type ${p.type}`;
129
+ if (!ok) { out.push([`? ${p.name} unknown (vault not readable)`, 'warn']); continue; }
130
+ const { def, any } = pickRow(rows, p.type);
131
+ if (def) out.push([`● ${p.name} connected · ${def.label || def.id} · ${testTag(def)}`, def.test_status === 'failed' ? 'warn' : 'ok']);
132
+ else if (any) out.push([`◐ ${p.name} stored but not the default · quox vault set-default ${any.id}`, 'warn']);
133
+ else out.push([`○ ${p.name} not connected · ${hint}`]);
134
+ }
135
+ out.push(['? Claude (subscription) unknown · this server has no status route for it']);
136
+ if (!ok) { out.push([` could not read the vault: ${res?.error || 'no answer'}`, 'warn']); return out; }
137
+ const known = new Set(Object.values(PROVIDERS).map(p => p.type));
138
+ const others = new Map();
139
+ for (const r of rows) {
140
+ if (known.has(r.integration_type)) continue;
141
+ const cur = others.get(r.integration_type);
142
+ if (!cur || (r.is_default && !cur.is_default)) others.set(r.integration_type, r);
143
+ }
144
+ const list = [...others.values()];
145
+ if (list.length) {
146
+ out.push(['Integrations in your vault:']);
147
+ for (const r of list.slice(0, 12)) out.push([`● ${r.integration_type} ${r.label || r.id} · ${testTag(r)}`, r.test_status === 'failed' ? 'warn' : undefined]);
148
+ if (list.length > 12) out.push([` +${list.length - 12} more · quox vault list`]);
149
+ } else {
150
+ out.push(['No other integrations yet. Connect Slack, GitHub, email and more with quox vault create --type <type>.']);
151
+ }
152
+ out.push([' An instance-wide key set by your admin can also serve chat; it is not visible from here.']);
153
+ return out;
154
+ }
155
+
156
+ // ---- gentle offer ------------------------------------------------------------------------------
157
+ const MISSING = [
158
+ [/openai[^.]*?(api key )?(not configured|not connected|missing|no api key)/i, 'openai'],
159
+ [/(anthropic|claude api)[^.]*?(api key )?(not configured|not connected|missing|no api key)/i, 'anthropic']
160
+ ];
161
+ export const offerText = (p) => `Ⓠ ${p.name} isn't connected, so I can't use it yet. Connect it now? /connect ${p.id}`;
162
+
163
+ /** -> the one-line offer when an event says a provider is missing, else null. */
164
+ export function connectOfferFor(ev) {
165
+ if (!ev || typeof ev !== 'object') return null;
166
+ if (ev.available === false && ev.provider) { const p = providerFor(ev.provider); return p?.card ? offerText(p) : null; }
167
+ if (ev.type !== 'error' && ev.type !== 'turn.finished') return null;
168
+ const s = [ev.error, ev.message, ev.detail].filter(x => typeof x === 'string').join(' ');
169
+ for (const [re, id] of MISSING) if (re.test(s)) return offerText(PROVIDERS[id]);
170
+ return null;
171
+ }
172
+
173
+ // ---- save flow ---------------------------------------------------------------------------------
174
+ const scrubOut = (s, secret) => {
175
+ let t = String(s || '');
176
+ if (secret) t = t.split(secret).join('[key]');
177
+ return t.replace(/\b(?:sk-|gsk_|hf_)[A-Za-z0-9_-]{6,}/g, '[key]');
178
+ };
179
+
180
+ /**
181
+ * Server-side live test first (nothing persisted), then the governed vault write as the org default.
182
+ * -> { ok, provider, last4, text } ; text is safe to show and to record.
183
+ */
184
+ export async function connectFlow(api, providerId, secret, extra) {
185
+ const p = providerFor(providerId);
186
+ if (!p?.card && !p?.custom) return { ok: false, provider: providerId, text: `${providerId} cannot be connected from here.` };
187
+ const key = String(secret || '');
188
+ if (key.length < p.minLen) return { ok: false, provider: p.id, text: `That is too short to be a ${p.name} key. Paste the whole key, or Esc to cancel.` };
189
+ const last4 = key.slice(-4);
190
+ const credentials = { ...(p.custom && extra ? { base_url: extra.base_url, ...(extra.default_model ? { default_model: extra.default_model } : {}) } : {}), [p.field]: key };
191
+ const label = p.custom && extra?.host ? `${p.name} · ${extra.host} …${last4}` : `${p.name} …${last4}`;
192
+ let t;
193
+ try { t = await api.testProviderKey({ type: p.type, credentials }); }
194
+ catch (e) { t = { ok: false, unreachable: true, message: e?.message }; }
195
+ if (!t?.ok) {
196
+ const why = scrubOut(t?.message || 'no reason given', key);
197
+ return { ok: false, provider: p.id, last4, text: t?.unreachable || t?.status >= 500
198
+ ? `✖ Could not test the key (${why}). Nothing was saved.`
199
+ : `✖ ${p.name} rejected the key: ${why}. Nothing was saved.` };
200
+ }
201
+ let s;
202
+ try { s = await api.saveProviderKey({ type: p.type, label, credentials, isDefault: true }); }
203
+ catch (e) { s = { ok: false, status: e?.status, message: e?.message }; }
204
+ if (!s?.ok && s?.status === 409) return { ok: false, provider: p.id, last4, text: `This key is already in your vault as "${label}". /connect shows its status.` };
205
+ if (!s?.ok) {
206
+ const why = scrubOut(s?.message || 'failed', key);
207
+ const role = s?.status === 403 ? ' Storing org keys needs the secrets.write capability; ask an org admin.' : '';
208
+ return { ok: false, provider: p.id, last4, text: `✖ The key tested fine but was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${why}).${role}` };
209
+ }
210
+ return { ok: true, provider: p.id, last4, text: `✔ ${p.name} connected · key …${last4} · tested`, ...(p.disclosure ? { disclosure: p.disclosure } : {}) };
211
+ }
212
+
213
+ // ---- card controller ---------------------------------------------------------------------------
214
+ /**
215
+ * Owns the open card and its secret box. The app routes every input event here while the card is
216
+ * open (onEvent returns true = consumed), and renders view(). note(text, tone) shows a line; emit(a)
217
+ * dispatches a transcript-visible action (only connect.started / connect.saved, provider + last4).
218
+ */
219
+ export function createConnectController({ api, note, emit, refresh }) {
220
+ const box = createSecretBox();
221
+ let card = null;
222
+ const close = () => { box.clear(); card = null; refresh(); };
223
+ async function submit() {
224
+ if (!box.length()) { card.error = 'Paste your key first, or Esc to cancel.'; refresh(); return; }
225
+ card.phase = 'saving'; card.error = null; refresh();
226
+ const p = card.provider;
227
+ const extra = card.extra;
228
+ const r = await connectFlow(api, p.id, box.take(), extra);
229
+ if (!card) return;
230
+ if (r.ok) {
231
+ card = null; refresh();
232
+ emit({ type: 'connect.saved', provider: p.id, last4: r.last4 });
233
+ note(r.text, 'ok');
234
+ if (r.disclosure) note(r.disclosure, 'warn');
235
+ note('Your next turns can use it. Servers cache keys briefly, so allow up to a minute.');
236
+ return;
237
+ }
238
+ card.phase = 'input'; card.error = r.text; refresh();
239
+ }
240
+ return {
241
+ isOpen: () => Boolean(card),
242
+ view: () => (card ? { card: { ...card }, mask: box.mask() } : null),
243
+ /** -> true when a card opened. */
244
+ open(name) {
245
+ const p = providerFor(name);
246
+ if (!p) { note(`Nothing called "${name}" to connect. /connect lists what is available.`, 'warn'); return false; }
247
+ if (p.custom) { note('Custom endpoints take a URL first: /connect custom https://<host>/v1 [model]'); return false; }
248
+ if (!p.card) { note(p.url ? `${p.name} takes a server URL, not a key: /connect ${p.id} http://<host>:${p.port || 11434} [model]` : `${p.name} takes a server URL, not a key. Add it with: quox vault create --type ${p.type}`); return false; }
249
+ if (!api.testProviderKey || !api.saveProviderKey) { note('Connecting is not available in this mode.', 'warn'); return false; }
250
+ box.clear(); card = { provider: p, phase: 'input', error: null };
251
+ emit({ type: 'connect.started', provider: p.id });
252
+ refresh();
253
+ return true;
254
+ },
255
+ /** B5: open the key card for a custom endpoint that answered 401/403; extra = { base_url, host, default_model? }. */
256
+ openCustom(extra) {
257
+ if (!api.testProviderKey || !api.saveProviderKey) { note('Connecting is not available in this mode.', 'warn'); return false; }
258
+ box.clear(); card = { provider: PROVIDERS.custom, phase: 'input', error: null, extra, subtitle: extra.host };
259
+ emit({ type: 'connect.started', provider: 'custom' });
260
+ refresh();
261
+ return true;
262
+ },
263
+ onEvent(e) {
264
+ if (!card) return false;
265
+ if (card.phase === 'saving') return true;
266
+ if (e.type === 'paste') { box.paste(e.text); card.error = null; refresh(); return true; }
267
+ if (e.type === 'text') { box.type(e.text); card.error = null; refresh(); return true; }
268
+ if (e.type !== 'key') return true;
269
+ if (e.name === 'escape' || e.name === 'ctrl-c') { close(); note('Cancelled. Nothing was stored.'); return true; }
270
+ if (e.name === 'enter') { submit(); return true; }
271
+ if (e.name === 'backspace') { box.backspace(); refresh(); return true; }
272
+ if (e.name === 'kill-line' || e.name === 'delete-word') { box.clear(); refresh(); return true; }
273
+ return true; // every other key is swallowed by the card
274
+ }
275
+ };
276
+ }
277
+
278
+ // ---- Ollama (B3): a server URL, no key -----------------------------------------------------------
279
+ /** "/connect ollama <url> [model]" rest -> { url, model } ; a bare host gets http:// and :11434. */
280
+ export function parseOllamaArg(rest) {
281
+ const [rawUrl, model] = String(rest || '').trim().split(/\s+/);
282
+ if (!rawUrl) return { url: null, model: null };
283
+ let url = rawUrl;
284
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
285
+ url = `http://${url}`;
286
+ try { const u = new URL(url); if (!u.port) { u.port = '11434'; url = u.toString().replace(/\/$/, ''); } } catch { /* the server says why */ }
287
+ }
288
+ return { url, model: model || null };
289
+ }
290
+
291
+ // Families whose Ollama builds accept tools (prefer one as the chat default).
292
+ const TOOL_FAMILIES = /^(qwen2\.5|qwen3|llama3\.[123]|mistral|mistral-nemo|mistral-small|command-r|hermes3|granite3|firefunction|nemotron|gpt-oss|smollm2)/i;
293
+ export const likelyToolCapable = (name) => TOOL_FAMILIES.test(String(name || ''));
294
+
295
+ /** The chat default: the asked model, else the first likely tool-capable one, else the first. */
296
+ export function pickOllamaDefault(models, asked) {
297
+ const names = (Array.isArray(models) ? models : []).map(m => (typeof m === 'string' ? m : m?.name)).filter(Boolean);
298
+ if (asked) {
299
+ if (names.includes(asked)) return asked;
300
+ return names.includes(`${asked}:latest`) ? `${asked}:latest` : null;
301
+ }
302
+ return names.find(likelyToolCapable) || names[0] || null;
303
+ }
304
+
305
+ const gb = (b) => (Number.isFinite(b) && b > 0 ? `${(b / 1e9).toFixed(1)} GB` : '');
306
+ /** -> [[text, tone?]] for an installed-model list. */
307
+ export function ollamaModelRows(models, current) {
308
+ const out = [];
309
+ for (const m of (models || []).slice(0, 20)) {
310
+ const facts = [m.parameter_size, gb(m.size), likelyToolCapable(m.name) ? 'tools' : null].filter(Boolean).join(' · ');
311
+ out.push([`${m.name === current ? '▸' : ' '} ${m.name}${facts ? ` ${facts}` : ''}`, m.name === current ? 'ok' : undefined]);
312
+ }
313
+ if ((models || []).length > 20) out.push([` +${models.length - 20} more`]);
314
+ return out;
315
+ }
316
+
317
+ export const OLLAMA_REACH_HINT = [
318
+ 'On the Ollama machine: start it listening on the network, OLLAMA_HOST=0.0.0.0 ollama serve',
319
+ 'and allow port 11434 through its firewall. "localhost" is not reachable from the server; use the machine\'s LAN address.'
320
+ ];
321
+
322
+ /**
323
+ * Server-side probe (GET <url>/api/tags; human org admin; nothing stored), then the governed vault
324
+ * write as the org default: { base_url, default_model, allow_private_origin? }. A private address is
325
+ * allow-listed only for the exact origin probed. -> { ok, lines: [[text, tone?]] }
326
+ */
327
+ export async function connectOllamaFlow(api, rest) {
328
+ const { url, model } = parseOllamaArg(rest);
329
+ if (!url) return { ok: false, lines: [['Usage: /connect ollama http://<host>:11434 [model]', 'warn']] };
330
+ if (!api.ollamaProbe || !api.saveProviderKey) return { ok: false, lines: [['Connecting is not available in this mode.', 'warn']] };
331
+ const p = await api.ollamaProbe(url);
332
+ if (!p?.ok) {
333
+ if (p?.status === 403) return { ok: false, lines: [['✖ Connecting Ollama needs an org admin. Nothing was saved.', 'warn']] };
334
+ const lines = [[`✖ ${p?.message || 'Ollama could not be reached'}. Nothing was saved.`, 'warn']];
335
+ if (p?.error === 'ollama_unreachable' || p?.status === 502) for (const h of OLLAMA_REACH_HINT) lines.push([` ${h}`]);
336
+ return { ok: false, lines };
337
+ }
338
+ const models = Array.isArray(p.models) ? p.models : [];
339
+ const def = pickOllamaDefault(models, model);
340
+ if (model && !def) {
341
+ return { ok: false, lines: [[`✖ ${model} is not installed on ${p.host}. On that machine run: ollama pull ${model} Nothing was saved.`, 'warn'], ...ollamaModelRows(models)] };
342
+ }
343
+ const credentials = { base_url: p.base_url, ...(def ? { default_model: def } : {}), ...(p.private ? { allow_private_origin: p.origin } : {}) };
344
+ const label = `Ollama · ${p.host}`;
345
+ let s = await api.saveProviderKey({ type: 'ollama', label, credentials, isDefault: true });
346
+ if (!s?.ok && s?.status === 409 && api.connections && api.updateProviderCredential) {
347
+ // Same host connected before: refresh that credential (URL, default model, allowance) instead.
348
+ const c = await api.connections();
349
+ const row = c?.ok ? c.rows.find(r => r.integration_type === 'ollama' && r.label === label) : null;
350
+ s = row ? await api.updateProviderCredential({ id: row.id, credentials, isDefault: true }) : s;
351
+ }
352
+ if (!s?.ok) {
353
+ const role = s?.status === 403 ? ' Storing org connections needs the secrets.write capability; ask an org admin.' : '';
354
+ const lan = s?.status === 400 && /private|internal/i.test(s?.message || '') ? ' This instance does not allow LAN integration targets yet; an operator enables allow_lan_integration_targets.' : '';
355
+ return { ok: false, lines: [[`✖ Ollama answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}${lan}`, 'warn']] };
356
+ }
357
+ const lines = [[`✔ Ollama connected · ${p.host} · ${models.length} model${models.length === 1 ? '' : 's'}${def ? ` · default ${def}` : ''}`, 'ok']];
358
+ if (p.private) lines.push([` ${p.origin} is a private address; it is allow-listed for this org's Ollama connection only.`]);
359
+ if (!models.length) lines.push([' No models installed yet. On that machine run: ollama pull qwen2.5', 'warn']);
360
+ else {
361
+ lines.push(...ollamaModelRows(models, def));
362
+ if (def && !likelyToolCapable(def)) lines.push([` ${def} may not support tools; turns then run without them. qwen2.5 or llama3.1 do.`, 'warn']);
363
+ }
364
+ lines.push([' Use it: /model ollama' + (def ? '' : ' <model>')]);
365
+ return { ok: true, host: p.host, model: def, lines };
366
+ }
367
+
368
+ // ---- B5: self-hosted OpenAI-compatible servers (LM Studio, vLLM) and custom endpoints ------------
369
+ const REACH_HINTS = {
370
+ lmstudio: [
371
+ 'On the LM Studio machine: in the Developer tab enable "Serve on Local Network" and start the server (port 1234).',
372
+ 'Allow that port through its firewall. "localhost" is not reachable from the server; use the machine\'s LAN address.'
373
+ ],
374
+ vllm: [
375
+ 'On the vLLM machine: start it listening on the network, vllm serve <model> --host 0.0.0.0 --port 8000',
376
+ 'and allow port 8000 through its firewall. "localhost" is not reachable from the server; use the machine\'s LAN address.'
377
+ ],
378
+ custom: ['Check the URL (it usually ends in /v1) and that the endpoint is reachable from the server.']
379
+ };
380
+ export const SELF_HOSTED_NOTE = 'Self-hosted: your prompts stay between this server and your machine. No third party is added.';
381
+
382
+ /** "/connect <lmstudio|vllm|custom> <url> [model]" rest -> { url, model, error? }. Bare host: http:// and the preset's port. */
383
+ export function parseCompatArg(presetId, rest) {
384
+ const p = PROVIDERS[presetId];
385
+ const [rawUrl, model] = String(rest || '').trim().split(/\s+/);
386
+ if (!rawUrl) return { url: null, model: null };
387
+ let url = rawUrl;
388
+ if (p?.custom) {
389
+ if (/^http:\/\//i.test(url)) return { url: null, model: null, error: 'Custom endpoints must be https://. For a server on your own network use /connect lmstudio or /connect vllm.' };
390
+ if (!/^https:\/\//i.test(url)) return { url: null, model: null, error: 'Custom endpoints take a full https:// base URL, for example /connect custom https://api.example.com/v1' };
391
+ return { url, model: model || null };
392
+ }
393
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
394
+ url = `http://${url}`;
395
+ try { const u = new URL(url); if (!u.port) { u.port = String(p?.port || 80); url = u.toString().replace(/\/$/, ''); } } catch { /* the server says why */ }
396
+ }
397
+ return { url, model: model || null };
398
+ }
399
+
400
+ /** Compact model rows for a compat /models list: up to 20, a tools tag when the server says so. */
401
+ export function compatModelRows(models, current) {
402
+ const out = [];
403
+ for (const m of (models || []).slice(0, 20)) {
404
+ const id = typeof m === 'string' ? m : m?.id;
405
+ if (!id) continue;
406
+ out.push([`${id === current ? '▸' : ' '} ${id}${m?.tools === true ? ' tools' : ''}`, id === current ? 'ok' : undefined]);
407
+ }
408
+ if ((models || []).length > 20) out.push([` +${models.length - 20} more`]);
409
+ return out;
410
+ }
411
+
412
+ /**
413
+ * /connect lmstudio|vllm|custom <url> [model]. Server-side probe (human org admin; nothing stored), then the
414
+ * governed vault write as the org default. Custom endpoints that answer 401/403 come back as
415
+ * { ok:false, needsKey:true, extra } so the app can open the key card carrying base_url.
416
+ * -> { ok, lines: [[text, tone?]], needsKey?, extra? }
417
+ */
418
+ export async function connectCompatFlow(api, presetId, rest) {
419
+ const p = PROVIDERS[presetId];
420
+ if (!p?.url || p.id === 'ollama') return { ok: false, lines: [[`${presetId} cannot be connected by URL.`, 'warn']] };
421
+ const { url, model, error } = parseCompatArg(p.id, rest);
422
+ const usage = p.custom ? '/connect custom https://<host>/v1 [model]' : `/connect ${p.id} http://<host>:${p.port} [model]`;
423
+ if (error) return { ok: false, lines: [[`✖ ${error}`, 'warn']] };
424
+ if (!url) return { ok: false, lines: [[`Usage: ${usage}`, 'warn']] };
425
+ if (!api.compatProbe || !api.saveProviderKey) return { ok: false, lines: [['Connecting is not available in this mode.', 'warn']] };
426
+ const pr = await api.compatProbe(p.id, url);
427
+ if (!pr?.ok) {
428
+ if (pr?.status === 403) return { ok: false, lines: [[`✖ Connecting ${p.name} needs an org admin. Nothing was saved.`, 'warn']] };
429
+ const lines = [[`✖ ${pr?.message || `${p.name} could not be reached`}. Nothing was saved.`, 'warn']];
430
+ if (pr?.error === 'provider_unreachable' || pr?.status === 502) for (const h of REACH_HINTS[p.id] || []) lines.push([` ${h}`]);
431
+ return { ok: false, lines };
432
+ }
433
+ const models = Array.isArray(pr.models) ? pr.models : [];
434
+ const ids = models.map(m => m.id);
435
+ if (pr.needs_key) {
436
+ if (p.custom) {
437
+ return { ok: false, needsKey: true, extra: { base_url: pr.base_url, host: pr.host, ...(model ? { default_model: model } : {}) },
438
+ lines: [[`${pr.host} answered but wants an API key. Opening the key card.`]] };
439
+ }
440
+ return { ok: false, lines: [[`✖ ${pr.host} wants an API key, which ${p.name} connections do not take here. Use /connect custom ${pr.base_url} instead. Nothing was saved.`, 'warn']] };
441
+ }
442
+ if (model && ids.length && !ids.includes(model)) {
443
+ return { ok: false, lines: [[`✖ ${model} is not served by ${pr.host}. Nothing was saved.`, 'warn'], ...compatModelRows(models)] };
444
+ }
445
+ const credentials = {
446
+ base_url: pr.base_url, ...(model ? { default_model: model } : {}),
447
+ ...(pr.private && !p.custom ? { allow_private_origin: pr.origin } : {})
448
+ };
449
+ const label = `${p.name} · ${pr.host}`;
450
+ let s = await api.saveProviderKey({ type: p.type, label, credentials, isDefault: true });
451
+ if (!s?.ok && s?.status === 409 && api.connections && api.updateProviderCredential) {
452
+ const c = await api.connections();
453
+ const row = c?.ok ? c.rows.find(r => r.integration_type === p.type && r.label === label) : null;
454
+ s = row ? await api.updateProviderCredential({ id: row.id, credentials, isDefault: true }) : s;
455
+ }
456
+ if (!s?.ok) {
457
+ const role = s?.status === 403 ? ' Storing org connections needs the secrets.write capability; ask an org admin.' : '';
458
+ const lan = s?.status === 400 && /private|internal/i.test(s?.message || '') ? ' This instance does not allow LAN integration targets yet; an operator enables allow_lan_integration_targets.' : '';
459
+ return { ok: false, lines: [[`✖ ${p.name} answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}${lan}`, 'warn']] };
460
+ }
461
+ const lines = [[`✔ ${p.name} connected · ${pr.host} · ${models.length} model${models.length === 1 ? '' : 's'}${model ? ` · default ${model}` : ''}`, 'ok']];
462
+ if (pr.private && !p.custom) lines.push([` ${pr.origin} is a private address; it is allow-listed for this org's ${p.name} connection only.`]);
463
+ lines.push(p.custom ? [` ${p.disclosure}`, 'warn'] : [` ${SELF_HOSTED_NOTE}`]);
464
+ lines.push(...compatModelRows(models, model));
465
+ lines.push([` Use it: /model ${p.id}${model ? '' : ' [model]'}`]);
466
+ return { ok: true, host: pr.host, model: model || null, lines };
467
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The secure /connect card. Shown in place of the composer while it is open. It renders only the
3
+ * mask from the secret box, never the value.
4
+ */
5
+ import React from 'react';
6
+ import { Box, Text } from 'ink';
7
+ import { c, clip } from './theme.js';
8
+ import { Rule } from './render.js';
9
+
10
+ const h = React.createElement;
11
+
12
+ /** card: { provider: PROVIDERS entry, phase: 'input'|'saving', error?: string }, mask: string */
13
+ export function ConnectCard({ card, mask, cols }) {
14
+ const p = card.provider;
15
+ const w = Math.max(10, cols - 4);
16
+ return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
17
+ h(Rule, { cols, color: 'sky' }),
18
+ h(Text, { color: c('sky'), bold: true }, clip(`Ⓠ Connect ${p.name}${card.subtitle ? ` · ${card.subtitle}` : ''} · goes straight to your org vault, never into chat`, cols)),
19
+ h(Text, { color: c('text') }, clip(` ${p.why}`, cols)),
20
+ p.disclosure ? h(Text, { color: c('warn'), wrap: 'wrap' }, ` ${p.disclosure}`) : null,
21
+ h(Text, { color: c('text') }, clip(` Get a key: ${p.keyUrl}`, cols)),
22
+ h(Text, { color: c('text') }, clip(` key ${mask || ''}${card.phase === 'input' ? '▏' : ''}`, cols)),
23
+ card.error ? h(Text, { color: c('warn') }, clip(` ${card.error}`, w + 2)) : null,
24
+ h(Text, { color: c('dim') }, card.phase === 'saving'
25
+ ? clip(` ◐ testing with ${p.name}, then saving…`, cols)
26
+ : clip(' paste your key · Enter test and save · Esc cancel · keys typed here stay in this card', cols)),
27
+ h(Rule, { cols, color: 'sky' }));
28
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Where am I: the slot this session holds and the agent attached to it, for the
3
+ * status line. Reads directory names and slot labels only, never token contents.
4
+ * Layout mirrors lib/core/callerSource.js (~/.quox/attach/sessions/claude-<pid>/<agent>/,
5
+ * ~/.quox/held-slots.json sessionKey claude-<pid>).
6
+ */
7
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ const clean = (v, n = 32) => String(v == null ? '' : v).replace(/[^\w .:@+\-/~]/g, '').trim().slice(0, n) || null;
12
+
13
+ const defaults = {
14
+ readName: (pid) => { try { return readFileSync(`/proc/${pid}/comm`, 'utf8').trim(); } catch { return null; } },
15
+ readPpid: (pid) => {
16
+ try {
17
+ const s = readFileSync(`/proc/${pid}/stat`, 'utf8');
18
+ const p = parseInt(s.slice(s.lastIndexOf(')') + 2).trim().split(/\s+/)[1], 10);
19
+ return Number.isFinite(p) ? p : null;
20
+ } catch { return null; }
21
+ },
22
+ attachRoot: () => join(homedir(), '.quox', 'attach', 'sessions'),
23
+ heldSlotsPath: () => join(homedir(), '.quox', 'held-slots.json'),
24
+ readdir: (d) => { try { return readdirSync(d, { withFileTypes: true }); } catch { return []; } },
25
+ readJson: (p) => { try { return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null; } catch { return null; } },
26
+ exists: existsSync,
27
+ pid: () => process.pid
28
+ };
29
+
30
+ function claudeAncestor(d, start) {
31
+ let pid = start;
32
+ for (let hop = 0; hop < 24; hop++) {
33
+ if (d.readName(pid) === 'claude') return pid;
34
+ const pp = d.readPpid(pid);
35
+ if (!pp || pp === pid || pid === 1) break;
36
+ pid = pp;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** -> { slot: string|null, agent: string|null } ; env wins, then the attach/slot files of the claude ancestor. */
42
+ export function detectSlotAgent(env = process.env, overrides = {}) {
43
+ const d = { ...defaults, ...overrides };
44
+ let slot = clean(env.QUOX_SLOT);
45
+ let agent = clean(env.QUOX_AGENT);
46
+ if (slot && agent) return { slot, agent };
47
+ let pid = null;
48
+ try { pid = claudeAncestor(d, d.pid()); } catch { pid = null; }
49
+ if (pid) {
50
+ if (!agent) {
51
+ const dir = join(d.attachRoot(), `claude-${pid}`);
52
+ const found = d.readdir(dir).filter(e => e.isDirectory() && d.exists(join(dir, e.name, 'token')));
53
+ if (found.length) {
54
+ const kit = d.readJson(join(dir, found[0].name, 'kit.json'));
55
+ agent = clean(kit && kit.display_name) || clean(found[0].name);
56
+ }
57
+ }
58
+ if (!slot) {
59
+ const all = d.readJson(d.heldSlotsPath());
60
+ if (all && typeof all === 'object') {
61
+ for (const [name, e] of Object.entries(all)) {
62
+ if (e && e.sessionKey === `claude-${pid}`) { slot = clean(name); break; }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return { slot, agent };
68
+ }
@@ -0,0 +1,33 @@
1
+ /** /copy helpers: pick the text and build the OSC 52 clipboard escape. */
2
+
3
+ export function osc52(text, env = process.env) {
4
+ const seq = `\x1b]52;c;${Buffer.from(String(text), 'utf8').toString('base64')}\x07`;
5
+ return env.TMUX ? `\x1bPtmux;${seq.replace(/\x1b/g, '\x1b\x1b')}\x1b\\` : seq;
6
+ }
7
+
8
+ /** Fenced code blocks (``` or ~~~) of a markdown string, in order, without the fence lines. */
9
+ export function codeBlocks(md) {
10
+ const out = []; let cur = null; let fence = null;
11
+ for (const line of String(md).split('\n')) {
12
+ const m = line.match(/^\s*(```+|~~~+)/);
13
+ if (cur === null) { if (m) { fence = m[1][0]; cur = []; } continue; }
14
+ if (m && m[1][0] === fence && line.trim() === m[1] ) { out.push(cur.join('\n')); cur = null; continue; }
15
+ cur.push(line);
16
+ }
17
+ if (cur && cur.length) out.push(cur.join('\n'));
18
+ return out;
19
+ }
20
+
21
+ /** arg: '' | 'N' | 'code'. -> { text } or { error } */
22
+ export function pickCopy(answers, arg) {
23
+ const list = (answers || []).filter(a => a.text && a.text.trim());
24
+ if (!list.length) return { error: 'No answer to copy yet.' };
25
+ if (arg === 'code') {
26
+ for (let i = list.length - 1; i >= 0; i--) { const b = codeBlocks(list[i].text); if (b.length) return { text: b[b.length - 1] }; }
27
+ return { error: 'No code block in the answers so far.' };
28
+ }
29
+ const n = arg ? Number(arg) : 1;
30
+ if (!Number.isInteger(n) || n < 1) return { error: 'Usage: /copy [N|code]' };
31
+ if (n > list.length) return { error: `Only ${list.length} answer${list.length === 1 ? '' : 's'} so far.` };
32
+ return { text: list[list.length - n].text };
33
+ }
@@ -0,0 +1,24 @@
1
+ /** Simulated QUOXON journey for --demo, screenshots and tests. Nothing here talks to a server. */
2
+ export const DEMO_STEPS = (turnId) => [
3
+ { wait: 150, ev: { type: 'turn.started', turn_id: turnId, conversation_id: 'demo-conv', agent: 'CommanderQ', provider: 'demo', model: 'demo-model', hat: 'operator' } },
4
+ { wait: 300, ev: { type: 'thinking', turn_id: turnId, stage: 'planning', message: 'Planning the steps' } },
5
+ { wait: 400, ev: { type: 'text.delta', turn_id: turnId, content: 'I will save that note, then restart the web service. ' } },
6
+ { wait: 300, ev: { type: 'tool.started', turn_id: turnId, call_id: 'c1', tool: 'memory_save', summary: 'save note "demo journey"' } },
7
+ { wait: 200, ev: { type: 'trace', turn_id: turnId, protocol: 'aee', label: 'envelope opened', ref: '01K4N8QXAB' } },
8
+ { wait: 700, ev: { type: 'tool.finished', turn_id: turnId, call_id: 'c1', tool: 'memory_save', status: 'ok', result_summary: 'saved', duration_ms: 640, envelope_id: 'env1', receipt: null } },
9
+ { wait: 300, ev: { type: 'tool.started', turn_id: turnId, call_id: 'c2', tool: 'host_restart', summary: 'restart web on edge-02' } },
10
+ { wait: 300, ev: { type: 'approval.requested', turn_id: turnId, call_id: 'c2', inbox_item_id: 'demo-item', approval_id: 'demo-appr', tool: 'host_restart', target: 'web on edge-02', scope: 'service restart, 1 host', risk: 'medium', dashboard_url: 'https://example.invalid/approvals/demo-item' } },
11
+ { gate: 'approval' },
12
+ { when: 'reject', wait: 200, ev: { type: 'approval.resolved', turn_id: turnId, call_id: 'c2', inbox_item_id: 'demo-item', decision: 'denied' } },
13
+ { when: 'reject', wait: 200, ev: { type: 'tool.finished', turn_id: turnId, call_id: 'c2', tool: 'host_restart', status: 'denied', result_summary: 'denied by you, not run', duration_ms: 0, envelope_id: null, receipt: null } },
14
+ { when: 'reject', wait: 300, ev: { type: 'text.delta', turn_id: turnId, content: 'Understood. The note is saved; web on edge-02 was NOT restarted because you denied it (demo data).' } },
15
+ { when: 'reject', wait: 200, ev: { type: 'turn.finished', turn_id: turnId, status: 'completed', conversation_id: 'demo-conv' } },
16
+ { when: 'approve', wait: 200, ev: { type: 'approval.resolved', turn_id: turnId, call_id: 'c2', inbox_item_id: 'demo-item', decision: 'approved' } },
17
+ { when: 'approve', wait: 900, ev: { type: 'tool.finished', turn_id: turnId, call_id: 'c2', tool: 'host_restart', status: 'ok', result_summary: 'restart web on edge-02', duration_ms: 2300, envelope_id: 'env2', receipt: { ward_seq: 4069513, verified: true } } },
18
+ { when: 'approve', wait: 100, ev: { type: 'trace', turn_id: turnId, protocol: 'aocl', label: 'ingress ok identity ok policy ok' } },
19
+ { when: 'approve', wait: 100, ev: { type: 'trace', turn_id: turnId, protocol: 'ward', label: 'witnessed', ref: '4069513' } },
20
+ { when: 'approve', wait: 300, ev: { type: 'text.delta', turn_id: turnId, content: 'Done. The note is saved and web on edge-02 restarted (demo data, nothing real ran).' } },
21
+ { when: 'approve', wait: 200, ev: { type: 'turn.finished', turn_id: turnId, status: 'completed', conversation_id: 'demo-conv' } }
22
+ ];
23
+
24
+ export const DEMO_LABEL = 'DEMO: simulated journey, no server, nothing real runs';