quoxcli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -13,6 +13,12 @@
13
13
  * the composer buffer or history, never sent to the chat stream, never written to a transcript.
14
14
  */
15
15
 
16
+ import { askText } from './ask.js';
17
+ import { consentCovers, connectConsentNote, needsConsent, providerLabel } from './consent.js';
18
+ import { providerState, SUBSCRIPTION_NAME } from './providers.js';
19
+ import { isLoopbackUrl, detectLanIp } from './hostUrls.js';
20
+ import { cmd } from './palette.js';
21
+
16
22
  export const PROVIDERS = {
17
23
  openai: {
18
24
  id: 'openai', type: 'openai', name: 'OpenAI', field: 'api_key', card: true, minLen: 20,
@@ -41,14 +47,14 @@ export const PROVIDERS = {
41
47
  id: 'custom', type: 'openai_compatible', name: 'Custom', field: 'api_key', card: false, url: true, custom: true, minLen: 8,
42
48
  why: 'This endpoint wants an API key. It is stored in your org vault with the base URL.',
43
49
  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.'
50
+ 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 own AI data sharing consent applies (/consent).'
45
51
  }
46
52
  };
47
53
  function hosted(id, name, field, keyUrl, who) {
48
54
  return {
49
55
  id, type: id, name, field, card: true, hosted: true, minLen: 20, keyUrl,
50
56
  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.`
57
+ disclosure: `Hosted: your prompts and tool results leave this server for ${who}. Your own AI data sharing consent applies (/consent).`
52
58
  };
53
59
  }
54
60
  function selfHosted(id, name, port) {
@@ -115,10 +121,11 @@ export const pickRow = (rows, type) => {
115
121
  };
116
122
 
117
123
  /**
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".
124
+ * -> [[text, tone?]] lines for /connect. `res` is { ok:true, rows } from the vault list, or { ok:false, error };
125
+ * `caps` is the server's capabilities answer, the source of truth for whether each provider is usable
126
+ * (providers.js, shared with /model and /doctor). Anything neither can answer is "? unknown".
120
127
  */
121
- export function connectionLines(res) {
128
+ export function connectionLines(res, caps = null) {
122
129
  const STATUS_PROVIDERS = [PROVIDERS.openai, PROVIDERS.anthropic, PROVIDERS.ollama, PROVIDERS.openrouter, PROVIDERS.huggingface, PROVIDERS.groq, PROVIDERS.together, PROVIDERS.lmstudio, PROVIDERS.vllm, PROVIDERS.custom];
123
130
  const out = [];
124
131
  const ok = res && res.ok && Array.isArray(res.rows);
@@ -126,13 +133,19 @@ export function connectionLines(res) {
126
133
  out.push(['Connections for this org:']);
127
134
  for (const p of STATUS_PROVIDERS) {
128
135
  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']);
136
+ const st = providerState({ cap: p.id, vault: p.type }, caps, res);
137
+ const def = st.row;
138
+ if (st.state === 'unknown') out.push([`? ${p.name} unknown (vault not readable)`, 'warn']);
139
+ else if (st.state === 'connected' && def) out.push([`● ${p.name} connected · ${def.label || def.id} · ${testTag(def)}`, def.test_status === 'failed' ? 'warn' : 'ok']);
140
+ else if (st.state === 'connected') out.push([`● ${p.name} ready on this server (an instance-wide key, not in your org vault)`, 'ok']);
141
+ else if (st.state === 'stored') out.push([`◐ ${p.name} stored but not the default · quox vault set-default ${st.row.id}`, 'warn']);
142
+ else if (st.state === 'unavailable') out.push([`○ ${p.name} not available here${st.reason ? `: ${st.reason}` : ''}`]);
133
143
  else out.push([`○ ${p.name} not connected · ${hint}`]);
134
144
  }
135
- out.push(['? Claude (subscription) unknown · this server has no status route for it']);
145
+ const sub = providerState({ cap: 'claude_subscription' }, caps, res);
146
+ if (sub.state === 'connected') out.push([`● ${SUBSCRIPTION_NAME} ready on this server · /model claude-cli`, 'ok']);
147
+ else if (sub.state === 'unknown') out.push([`? ${SUBSCRIPTION_NAME} unknown (the server did not say)`, 'warn']);
148
+ else out.push([`○ ${SUBSCRIPTION_NAME} ${sub.reason || 'not signed in on this server'} · sign in from the dashboard Terminal`]);
136
149
  if (!ok) { out.push([` could not read the vault: ${res?.error || 'no answer'}`, 'warn']); return out; }
137
150
  const known = new Set(Object.values(PROVIDERS).map(p => p.type));
138
151
  const others = new Map();
@@ -210,6 +223,9 @@ export async function connectFlow(api, providerId, secret, extra) {
210
223
  return { ok: true, provider: p.id, last4, text: `✔ ${p.name} connected · key …${last4} · tested`, ...(p.disclosure ? { disclosure: p.disclosure } : {}) };
211
224
  }
212
225
 
226
+ /** QUOXON-Z2H lane R: the one consent question asked after a cloud connect. Default No: cloud processing stays opt-in. */
227
+ export const consentAskLine = (type) => askText(`Allow Quox to send your conversations to ${providerLabel(type)}?`, { keys: 'y/N', who: 'Quox', action: `send your messages and any tool results to ${providerLabel(type)}, a third party, when it answers`, reason: 'Nothing is sent until you say yes; withdraw it any time in Settings > Privacy.' }) // no consent yet: nothing to chat with;
228
+
213
229
  // ---- card controller ---------------------------------------------------------------------------
214
230
  /**
215
231
  * Owns the open card and its secret box. The app routes every input event here while the card is
@@ -233,11 +249,64 @@ export function createConnectController({ api, note, emit, refresh }) {
233
249
  note(r.text, 'ok');
234
250
  if (r.disclosure) note(r.disclosure, 'warn');
235
251
  note('Your next turns can use it. Servers cache keys briefly, so allow up to a minute.');
252
+ await askConsent(p.type); // never meet the consent error cold: ask now, once
236
253
  return;
237
254
  }
238
255
  card.phase = 'input'; card.error = r.text; refresh();
239
256
  }
257
+ /**
258
+ * QUOXON-Z2H lane R: when `type` is a cloud provider and this user's OK is not in place, open the card on
259
+ * the consent question. y records it (the user's own direct act, POST /api/consent); anything else leaves
260
+ * it off and says how to give it later. -> true when the question opened.
261
+ */
262
+ async function askConsent(type) {
263
+ if (!needsConsent(type) || !api.consentStatus) return false;
264
+ let rec = null; try { rec = await api.consentStatus(); } catch { rec = null; }
265
+ if (consentCovers(rec, type)) return false;
266
+ if (!api.grantConsent) { note(connectConsentNote(type), 'warn'); return false; }
267
+ box.clear();
268
+ card = { provider: providerFor(type) || Object.values(PROVIDERS).find((x) => x.type === type) || { id: type, type, name: providerLabel(type) }, phase: 'consent', consentType: type, error: null, question: consentAskLine(type) };
269
+ refresh();
270
+ return true;
271
+ }
272
+ async function answerConsent(yes) {
273
+ const type = card.consentType;
274
+ if (!yes) { close(); note(`Left off: nothing goes to ${providerLabel(type)} until you say so. ${connectConsentNote(type)}`, 'warn'); return; }
275
+ card.phase = 'saving'; refresh();
276
+ try {
277
+ await api.grantConsent(type);
278
+ close(); note(`✔ Your OK is recorded: ${providerLabel(type)} can now answer. Withdraw it any time: quox consent list, or Settings > Privacy.`, 'ok');
279
+ } catch (e) {
280
+ close(); note(`Quox could not record your OK (${e?.message || e}). Nothing was sent. Try /consent grant ${type}.`, 'warn');
281
+ }
282
+ }
283
+ /**
284
+ * #1288: "Connect to <host>? It's on your private network." y/N. y runs offer.retry() (the save with a
285
+ * confirm for that exact origin) and hands its result to after(); n, Enter or Esc saves nothing.
286
+ */
287
+ function askPrivate(offer, after) {
288
+ box.clear();
289
+ card = {
290
+ provider: PROVIDERS.ollama || { id: 'ollama', type: 'ollama', name: 'Ollama' },
291
+ phase: 'private', error: null, privateOffer: offer, privateAfter: after,
292
+ question: askText(`Connect to ${offer.host}?`, { keys: 'y/N', who: 'Quox', reason: "It's on your private network", action: `save ${offer.origin} as your org's Ollama. Only this address is allowed, after the server checks it answers as Ollama. Needs an org admin.` })
293
+ };
294
+ refresh();
295
+ return true;
296
+ }
297
+ async function answerPrivate(yes) {
298
+ const { privateOffer: offer, privateAfter: after } = card;
299
+ if (!yes) { close(); note(`Left blocked: ${offer.host} was not connected. Nothing was saved.`); return; }
300
+ card.phase = 'saving'; refresh();
301
+ let r;
302
+ try { r = await offer.retry(); } catch (e) { r = { ok: false, lines: [[`✖ ${e?.message || e}. Nothing was saved.`, 'warn']] }; }
303
+ close();
304
+ for (const [t, tone] of r.lines || []) note(t, tone);
305
+ if (after) await after(r);
306
+ }
240
307
  return {
308
+ askConsent,
309
+ askPrivate,
241
310
  isOpen: () => Boolean(card),
242
311
  view: () => (card ? { card: { ...card }, mask: box.mask() } : null),
243
312
  /** -> true when a card opened. */
@@ -263,6 +332,16 @@ export function createConnectController({ api, note, emit, refresh }) {
263
332
  onEvent(e) {
264
333
  if (!card) return false;
265
334
  if (card.phase === 'saving') return true;
335
+ if (card.phase === 'private') {
336
+ if (e.type === 'text' && /^y(es)?$/i.test(String(e.text).trim())) { answerPrivate(true); return true; }
337
+ if ((e.type === 'text' && /^n(o)?$/i.test(String(e.text).trim())) || (e.type === 'key' && ['enter', 'escape', 'ctrl-c'].includes(e.name))) { answerPrivate(false); return true; }
338
+ return true; // the question takes only y or n
339
+ }
340
+ if (card.phase === 'consent') {
341
+ if (e.type === 'text' && /^y(es)?$/i.test(String(e.text).trim())) { answerConsent(true); return true; }
342
+ if ((e.type === 'text' && /^n(o)?$/i.test(String(e.text).trim())) || (e.type === 'key' && ['enter', 'escape', 'ctrl-c'].includes(e.name))) { answerConsent(false); return true; }
343
+ return true; // the question takes only y or n
344
+ }
266
345
  if (e.type === 'paste') { box.paste(e.text); card.error = null; refresh(); return true; }
267
346
  if (e.type === 'text') { box.type(e.text); card.error = null; refresh(); return true; }
268
347
  if (e.type !== 'key') return true;
@@ -275,6 +354,61 @@ export function createConnectController({ api, note, emit, refresh }) {
275
354
  };
276
355
  }
277
356
 
357
+
358
+ // ---- private-network refusal: explain it, offer the instance gate, retry once --------------------
359
+ /** The vault refused the base URL because the address is private (HTTP 400, SSRF guard). */
360
+ export const isPrivateNetworkError = (s) => s?.status === 400 && /private|internal/i.test(s?.message || '');
361
+
362
+ const WHO_CAN = [
363
+ 'Only an instance admin can allow this. Ask one to run:',
364
+ ' quox server config lan-targets on'
365
+ ];
366
+
367
+ /**
368
+ * The save failed because the address is on a private network. -> { ok:false, lines, lanOffer? }.
369
+ * An admin gets a card and { lanOffer } (the app keeps it for "/connect yes"); anyone else is told
370
+ * who can. `retry` re-runs the same connect once the gate is on.
371
+ */
372
+ /** The true scope of a saved private address: the instance gate is server-wide, not per connection. */
373
+ function privateScopeNote(origin) {
374
+ return [
375
+ [` ${origin} is a private address. This Quox server allows private-network`],
376
+ [' addresses for AI and monitoring connections. Turn off: quox server config lan-targets off']
377
+ ];
378
+ }
379
+
380
+ export async function lanGateCard(api, name, retry, { retried } = {}) {
381
+ const lines = [[`✖ ${name} is on a private network, so Quox did not save it.`, 'warn']];
382
+ const st = api.lanTargets ? await api.lanTargets() : null;
383
+ if (retried || st?.enabled) {
384
+ lines.push([' Private-network targets are already on, so Quox still refuses this address.']);
385
+ lines.push([' Loopback and link-local addresses are always blocked. Use the machine\'s LAN address.']);
386
+ return { ok: false, lines };
387
+ }
388
+ lines.push([' Quox blocks private addresses by default, so an AI cannot be pointed at internal services.']);
389
+ if (!api.setLanTargets || st?.status === 403) {
390
+ for (const l of WHO_CAN) lines.push([l, l.startsWith(' ') ? undefined : 'warn']);
391
+ return { ok: false, lines };
392
+ }
393
+ lines.push([' Allowing it applies to every AI and monitoring connection on this Quox server,']);
394
+ lines.push([' not just this one. Loopback and link-local stay blocked. Turn off later:']);
395
+ lines.push([' quox server config lan-targets off']);
396
+ lines.push([' Allow it and connect now? /connect yes (leave it blocked: /connect no)', 'warn']);
397
+ return { ok: false, lines, lanOffer: { name, retry } };
398
+ }
399
+
400
+ /** "/connect yes": set the gate, then run the connect once more. Never offers a second time. */
401
+ export async function confirmLanAllow(api, offer) {
402
+ if (!offer?.retry) return { ok: false, lines: [['Nothing to confirm. Run /connect again.', 'warn']] };
403
+ const set = await api.setLanTargets(true);
404
+ if (!set?.ok) {
405
+ if (set?.status === 403) return { ok: false, lines: [['✖ Only an instance admin can allow that. Nothing was changed.', 'warn'], ...WHO_CAN.map(l => [l])] };
406
+ return { ok: false, lines: [[`✖ Could not turn it on (${set?.status ? `HTTP ${set.status}: ` : ''}${set?.message || 'failed'}). Nothing was saved.`, 'warn']] };
407
+ }
408
+ const r = await offer.retry();
409
+ return { ...r, lines: [['✔ Private-network targets are on for this instance. Retrying…', 'ok'], ...r.lines] };
410
+ }
411
+
278
412
  // ---- Ollama (B3): a server URL, no key -----------------------------------------------------------
279
413
  /** "/connect ollama <url> [model]" rest -> { url, model } ; a bare host gets http:// and :11434. */
280
414
  export function parseOllamaArg(rest) {
@@ -314,25 +448,64 @@ export function ollamaModelRows(models, current) {
314
448
  return out;
315
449
  }
316
450
 
451
+ /**
452
+ * Z2H-R4: how to make Ollama listen on the network. The official Linux installer runs it as a systemd
453
+ * service on 127.0.0.1, so `OLLAMA_HOST=0.0.0.0 ollama serve` fails there ("address already in use");
454
+ * the service override comes first, the plain serve only for a non-service run.
455
+ */
456
+ export const OLLAMA_LISTEN_LINES = [
457
+ 'Linux (installed as a service): ' + cmd('sudo systemctl edit ollama'),
458
+ ' add under [Service]: ' + cmd('Environment="OLLAMA_HOST=0.0.0.0"'),
459
+ ' then: ' + cmd('sudo systemctl restart ollama'),
460
+ 'Not a service: ' + cmd('OLLAMA_HOST=0.0.0.0 ollama serve')
461
+ ];
462
+
317
463
  export const OLLAMA_REACH_HINT = [
318
- 'On the Ollama machine: start it listening on the network, OLLAMA_HOST=0.0.0.0 ollama serve',
464
+ 'On the Ollama machine, make it listen on the network:',
465
+ ...OLLAMA_LISTEN_LINES.map(l => ` ${l}`),
319
466
  'and allow port 11434 through its firewall. "localhost" is not reachable from the server; use the machine\'s LAN address.'
320
467
  ];
321
468
 
469
+ /** Z2H-R3: the hint for a loopback Ollama URL, with this machine's LAN address filled in. */
470
+ export function ollamaLoopbackHint(lan) {
471
+ return [
472
+ 'The Quox server runs in Docker, so "localhost" there is the container, not this machine.',
473
+ 'Make Ollama listen on the network:',
474
+ ...OLLAMA_LISTEN_LINES.map(l => ` ${l}`),
475
+ `Then connect with this machine's LAN address: ${cmd(`/connect ollama http://${lan}:11434`)}`
476
+ ];
477
+ }
478
+
479
+ /** Z2H-R4: bare /connect ollama, the whole setup from nothing, with this machine's LAN address filled in. */
480
+ export function ollamaSetupCard(lan) {
481
+ return [
482
+ ['Ollama runs AI models on your own machine, no account or key. To set it up:'],
483
+ [' 1. Install it: ' + cmd('curl -fsSL https://ollama.com/install.sh | sh')],
484
+ [' 2. Get a model that can use tools: ' + cmd('ollama pull qwen2.5')],
485
+ [' 3. Let the Quox server reach it:'],
486
+ ...OLLAMA_LISTEN_LINES.map(l => [` ${l}`]),
487
+ [` 4. Connect: ${cmd(`/connect ollama http://${lan}:11434`)}`],
488
+ [' (Ollama on another machine? Use that machine\'s address.)']
489
+ ];
490
+ }
491
+
322
492
  /**
323
493
  * Server-side probe (GET <url>/api/tags; human org admin; nothing stored), then the governed vault
324
494
  * write as the org default: { base_url, default_model, allow_private_origin? }. A private address is
325
495
  * allow-listed only for the exact origin probed. -> { ok, lines: [[text, tone?]] }
326
496
  */
327
- export async function connectOllamaFlow(api, rest) {
497
+ export async function connectOllamaFlow(api, rest, { retried = false, confirmOrigin = null } = {}) {
328
498
  const { url, model } = parseOllamaArg(rest);
329
- if (!url) return { ok: false, lines: [['Usage: /connect ollama http://<host>:11434 [model]', 'warn']] };
499
+ if (!url) return { ok: false, lines: ollamaSetupCard((api.lanIp ? api.lanIp() : detectLanIp()) || '<lan-ip>') };
330
500
  if (!api.ollamaProbe || !api.saveProviderKey) return { ok: false, lines: [['Connecting is not available in this mode.', 'warn']] };
331
501
  const p = await api.ollamaProbe(url);
332
502
  if (!p?.ok) {
333
503
  if (p?.status === 403) return { ok: false, lines: [['✖ Connecting Ollama needs an org admin. Nothing was saved.', 'warn']] };
334
504
  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}`]);
505
+ if (isLoopbackUrl(url)) { // Z2H-R3: the server is in Docker, so loopback is the container; give the exact command
506
+ const lan = (api.lanIp ? api.lanIp() : detectLanIp()) || '<lan-ip>';
507
+ for (const h of ollamaLoopbackHint(lan)) lines.push([` ${h}`]);
508
+ } else if (p?.error === 'ollama_unreachable' || p?.status === 502) for (const h of OLLAMA_REACH_HINT) lines.push([` ${h}`]);
336
509
  return { ok: false, lines };
337
510
  }
338
511
  const models = Array.isArray(p.models) ? p.models : [];
@@ -342,20 +515,32 @@ export async function connectOllamaFlow(api, rest) {
342
515
  }
343
516
  const credentials = { base_url: p.base_url, ...(def ? { default_model: def } : {}), ...(p.private ? { allow_private_origin: p.origin } : {}) };
344
517
  const label = `Ollama · ${p.host}`;
345
- let s = await api.saveProviderKey({ type: 'ollama', label, credentials, isDefault: true });
518
+ const confirm = confirmOrigin ? { confirmPrivateOrigin: confirmOrigin } : {};
519
+ let s = await api.saveProviderKey({ type: 'ollama', label, credentials, isDefault: true, ...confirm });
346
520
  if (!s?.ok && s?.status === 409 && api.connections && api.updateProviderCredential) {
347
521
  // Same host connected before: refresh that credential (URL, default model, allowance) instead.
348
522
  const c = await api.connections();
349
523
  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;
524
+ s = row ? await api.updateProviderCredential({ id: row.id, credentials, isDefault: true, ...confirm }) : s;
525
+ }
526
+ if (!s?.ok && s?.confirmable && !confirmOrigin) {
527
+ // #1288: the server lets an org admin allow THIS one private address for their own Ollama. Ask y/N.
528
+ const origin = s.origin || p.origin;
529
+ return { ok: false, lines: [], privateAsk: { host: p.host, origin, retry: () => connectOllamaFlow(api, rest, { retried: true, confirmOrigin: origin }) } };
530
+ }
531
+ if (!s?.ok && confirmOrigin) {
532
+ return { ok: false, lines: [[`✖ Ollama at ${p.host} was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).`, 'warn']] };
533
+ }
534
+ if (!s?.ok && isPrivateNetworkError(s)) {
535
+ return lanGateCard(api, `Ollama at ${p.host}`, () => connectOllamaFlow(api, rest, { retried: true }), { retried });
351
536
  }
352
537
  if (!s?.ok) {
353
538
  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']] };
539
+ return { ok: false, lines: [[`✖ Ollama answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}`, 'warn']] };
356
540
  }
357
541
  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.`]);
542
+ if (p.private && confirmOrigin) lines.push([` ${confirmOrigin} is on your private network. Quox allows only this address, for this connection.`]);
543
+ else if (p.private) lines.push(...privateScopeNote(p.origin));
359
544
  if (!models.length) lines.push([' No models installed yet. On that machine run: ollama pull qwen2.5', 'warn']);
360
545
  else {
361
546
  lines.push(...ollamaModelRows(models, def));
@@ -415,7 +600,7 @@ export function compatModelRows(models, current) {
415
600
  * { ok:false, needsKey:true, extra } so the app can open the key card carrying base_url.
416
601
  * -> { ok, lines: [[text, tone?]], needsKey?, extra? }
417
602
  */
418
- export async function connectCompatFlow(api, presetId, rest) {
603
+ export async function connectCompatFlow(api, presetId, rest, { retried = false } = {}) {
419
604
  const p = PROVIDERS[presetId];
420
605
  if (!p?.url || p.id === 'ollama') return { ok: false, lines: [[`${presetId} cannot be connected by URL.`, 'warn']] };
421
606
  const { url, model, error } = parseCompatArg(p.id, rest);
@@ -453,13 +638,15 @@ export async function connectCompatFlow(api, presetId, rest) {
453
638
  const row = c?.ok ? c.rows.find(r => r.integration_type === p.type && r.label === label) : null;
454
639
  s = row ? await api.updateProviderCredential({ id: row.id, credentials, isDefault: true }) : s;
455
640
  }
641
+ if (!s?.ok && isPrivateNetworkError(s)) {
642
+ return lanGateCard(api, `${p.name} at ${pr.host}`, () => connectCompatFlow(api, presetId, rest, { retried: true }), { retried });
643
+ }
456
644
  if (!s?.ok) {
457
645
  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']] };
646
+ return { ok: false, lines: [[`✖ ${p.name} answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}`, 'warn']] };
460
647
  }
461
648
  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.`]);
649
+ if (pr.private && !p.custom) lines.push(...privateScopeNote(pr.origin));
463
650
  lines.push(p.custom ? [` ${p.disclosure}`, 'warn'] : [` ${SELF_HOSTED_NOTE}`]);
464
651
  lines.push(...compatModelRows(models, model));
465
652
  lines.push([` Use it: /model ${p.id}${model ? '' : ' [model]'}`]);
@@ -5,14 +5,31 @@
5
5
  import React from 'react';
6
6
  import { Box, Text } from 'ink';
7
7
  import { c, clip } from './theme.js';
8
- import { Rule } from './render.js';
8
+ import { Rule, AskCard } from './render.js';
9
9
 
10
10
  const h = React.createElement;
11
11
 
12
- /** card: { provider: PROVIDERS entry, phase: 'input'|'saving', error?: string }, mask: string */
12
+ /** card: { provider: PROVIDERS entry, phase: 'input'|'saving'|'consent', error?: string, question?: string }, mask: string */
13
13
  export function ConnectCard({ card, mask, cols }) {
14
14
  const p = card.provider;
15
15
  const w = Math.max(10, cols - 4);
16
+ // QUOXON-Z2H lane R: the one consent question after a cloud connect (y records the user's own OK).
17
+ if (card.phase === 'consent') return h(AskCard, { text: card.question, cols }); // QUOXON-ASK: the same card as an approval
18
+ // #1288: an org admin's own Ollama on a private address: y/N, then the save with a confirm for that origin.
19
+ if (card.phase === 'private') return h(AskCard, { text: card.question, cols });
20
+ if (card.phase === 'saving' && card.privateOffer) {
21
+ return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
22
+ h(Text, { color: c('dim') }, clip(` ◐ checking ${card.privateOffer.host} answers as Ollama, then saving…`, cols)));
23
+ }
24
+ if (card.phase === 'saving' && card.consentType) {
25
+ return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
26
+ h(Rule, { cols, color: 'sky' }),
27
+ h(Text, { color: c('sky'), bold: true }, clip(`Ⓠ ${p.name} is connected · one question`, cols)),
28
+ h(Text, { color: c('text'), wrap: 'wrap' }, ` Your messages and any tool results go to ${p.name}, a third party, when it answers. Nothing is sent until you say yes. You can withdraw this any time (Settings > Privacy).`),
29
+ h(Text, { color: c('warn'), bold: true }, clip(` ${card.question}`, cols)),
30
+ h(Text, { color: c('text') }, card.phase === 'saving' ? clip(' ◐ recording your OK…', cols) : clip(' y yes · n or Enter no · Esc no', cols)),
31
+ h(Rule, { cols, color: 'sky' }));
32
+ }
16
33
  return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
17
34
  h(Rule, { cols, color: 'sky' }),
18
35
  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)),
@@ -0,0 +1,86 @@
1
+ /**
2
+ * QUOXON-CONSENT: AI data sharing consent, in words a stuck user can act on.
3
+ * Consent is PERSONAL (one record per user, POST /api/consent), so the person typing grants it; no admin is
4
+ * involved. Cloud providers that receive the conversation need it; a local model (Ollama) never does.
5
+ * This file only words things and shapes the two API calls. The gate itself is the server's.
6
+ */
7
+ export const PRIVACY_PATH = '/settings#privacy';
8
+ const LABELS = { openai: 'OpenAI', perplexity: 'Perplexity', gemini: 'Google Gemini', google: 'Google', openrouter: 'OpenRouter', huggingface: 'Hugging Face', groq: 'Groq', together: 'Together AI', openai_compatible: 'your custom hosted endpoint' };
9
+ export const providerLabel = (p) => LABELS[String(p || '').toLowerCase()] || (p ? String(p) : 'your cloud AI provider');
10
+
11
+ // Keep in step with services/collector/lib/consentVerification.js THIRD_PARTY_PROVIDERS. Anthropic and local models are not in it.
12
+ export const CONSENT_PROVIDERS = ['openai', 'perplexity', 'gemini', 'google', 'openrouter', 'huggingface', 'groq', 'together', 'openai_compatible'];
13
+ export const needsConsent = (type) => CONSENT_PROVIDERS.includes(String(type || '').toLowerCase());
14
+
15
+ export const isConsentError = (ev) => ev?.type === 'error' && ev?.error === 'ai_consent_required';
16
+
17
+ /** The one-line headline for the error block (the card's first line). */
18
+ export function consentErrorText(ev) {
19
+ if (ev?.retryable) return 'Quox could not check your AI data sharing consent just now, so nothing was sent. Try again in a moment.';
20
+ return `Quox needs your OK before sending this conversation to ${providerLabel(ev?.provider)}.`;
21
+ }
22
+
23
+ /** The lines under the headline: what is shared, how to grant it, the local alternative, the dashboard link. Short lines, no clipping. */
24
+ export function consentHintLines(ev, dashboardBase) {
25
+ if (ev?.retryable) return [];
26
+ const who = providerLabel(ev?.provider);
27
+ const prov = ev?.provider || '<provider>';
28
+ const why = ev?.reason === 'consent_expired' ? 'Your earlier OK is out of date and needs renewing.'
29
+ : ev?.reason === 'provider_blocked' ? `You have blocked ${who} in your privacy settings; granting turns it back on.` : null;
30
+ const lines = [];
31
+ if (why) lines.push(why);
32
+ lines.push(`Shared: your messages and any tool results go to ${who}, a third party. Nothing was sent.`);
33
+ lines.push('Grant it: /consent grant (records your OK and sends this message again), or quox consent grant --provider ' + prov + ' in a shell.');
34
+ lines.push('Consent is personal, so you can give it yourself; no admin is needed.');
35
+ if (dashboardBase) lines.push(`Or in the dashboard: ${String(dashboardBase).replace(/\/$/, '')}${ev?.grant?.dashboard_path || PRIVACY_PATH}`);
36
+ lines.push('No cloud at all: a local model (Ollama) needs no consent. /connect ollama');
37
+ return lines;
38
+ }
39
+
40
+ /** GET /api/consent record -> { granted, providers: { name: bool } }. */
41
+ export function consentState(rec) {
42
+ const providers = rec?.provider_consent && typeof rec.provider_consent === 'object' ? rec.provider_consent : {};
43
+ return { granted: Boolean(rec?.ai_data_sharing_consent), providers };
44
+ }
45
+
46
+ /** Is `provider` allowed to receive data under this record? Mirrors the server: overall consent on, provider not set false. */
47
+ export const consentCovers = (rec, provider) => {
48
+ const s = consentState(rec);
49
+ return s.granted && s.providers[String(provider || '').toLowerCase()] !== false;
50
+ };
51
+
52
+ export function consentStatusLines(rec, { provider = null } = {}) {
53
+ const s = consentState(rec);
54
+ const names = Object.entries(s.providers).map(([k, v]) => `${k} ${v ? 'yes' : 'blocked'}`);
55
+ const lines = [[s.granted ? '✔ AI data sharing: you have given your OK.' : '· AI data sharing: not given yet, so nothing is sent to a cloud AI.', s.granted ? 'ok' : 'warn']];
56
+ if (names.length) lines.push([`Per provider: ${names.join(', ')}.`]);
57
+ if (!s.granted) lines.push([`/consent grant${provider ? ` ${provider}` : ''} gives it. Your messages and tool results then go to the cloud AI you chose. A local model (Ollama) never needs it.`]);
58
+ return lines;
59
+ }
60
+
61
+ /** The body for POST /api/consent: keeps every other provider's setting (the server replaces the map wholesale). */
62
+ export function grantBody(rec, provider) {
63
+ const keep = consentState(rec).providers;
64
+ const p = String(provider || '').toLowerCase();
65
+ return { ai_data_sharing_consent: true, provider_consent: { ...keep, ...(p ? { [p]: true } : {}) } };
66
+ }
67
+
68
+ /** What to say after /connect saves a hosted key and consent is not yet in place. */
69
+ export function connectConsentNote(providerId) {
70
+ return `One more step before it can answer: ${providerLabel(providerId)} receives your messages, so Quox needs your OK. /consent grant ${providerId}`;
71
+ }
72
+
73
+ /**
74
+ * QUOXON-Z2H lane R: the plain-text message `quox --print` gives for ai_consent_required, naming the exact
75
+ * shell command (there is no /consent in a one-shot run). profile = the -p the user named, if any.
76
+ */
77
+ export function consentPrintMessage(ev, profile = null) {
78
+ if (ev?.retryable) return consentErrorText(ev);
79
+ const who = providerLabel(ev?.provider);
80
+ const prov = ev?.provider || '<provider>';
81
+ const p = profile ? `-p ${profile} ` : '';
82
+ return `${consentErrorText(ev)} Nothing was sent.\n`
83
+ + ` Your messages and any tool results would go to ${who}, a third party. Consent is personal; no admin is needed.\n`
84
+ + ` Grant it, then send again: quox ${p}consent grant --provider ${prov}\n`
85
+ + ' Or use a local model (Ollama), which needs no consent.';
86
+ }
@@ -1,7 +1,8 @@
1
1
  /** /copy helpers: pick the text and build the OSC 52 clipboard escape. */
2
+ import { stripMarks } from './palette.js';
2
3
 
3
4
  export function osc52(text, env = process.env) {
4
- const seq = `\x1b]52;c;${Buffer.from(String(text), 'utf8').toString('base64')}\x07`;
5
+ const seq = `\x1b]52;c;${Buffer.from(stripMarks(text), 'utf8').toString('base64')}\x07`;
5
6
  return env.TMUX ? `\x1bPtmux;${seq.replace(/\x1b/g, '\x1b\x1b')}\x1b\\` : seq;
6
7
  }
7
8
 
@@ -23,11 +24,11 @@ export function pickCopy(answers, arg) {
23
24
  const list = (answers || []).filter(a => a.text && a.text.trim());
24
25
  if (!list.length) return { error: 'No answer to copy yet.' };
25
26
  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
+ for (let i = list.length - 1; i >= 0; i--) { const b = codeBlocks(list[i].text); if (b.length) return { text: stripMarks(b[b.length - 1]) }; }
27
28
  return { error: 'No code block in the answers so far.' };
28
29
  }
29
30
  const n = arg ? Number(arg) : 1;
30
31
  if (!Number.isInteger(n) || n < 1) return { error: 'Usage: /copy [N|code]' };
31
32
  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
+ return { text: stripMarks(list[list.length - n].text) };
33
34
  }
@@ -0,0 +1,11 @@
1
+ /** The restart countdown line, fitted to the width: full text when it fits, shorter forms below, never wrapped. */
2
+ export function countdownLine(secs, cols) {
3
+ const room = Math.max(20, (cols || 80) - 4); // paddingLeft 2 plus a margin
4
+ const forms = [
5
+ `↻ New version ready. Restarting in ${secs}s · Esc to stay on this version · Enter to restart now`,
6
+ `↻ New version · restarting in ${secs}s · Esc stay · Enter now`,
7
+ `↻ Restarting in ${secs}s · Esc stay · Enter now`,
8
+ `↻ Restart in ${secs}s · Esc stay`,
9
+ ];
10
+ return forms.find(f => f.length <= room) || forms[forms.length - 1];
11
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Deterministic script for `quox --demo --autoplay`: the same timings on every run, so a screen
3
+ * recording is repeatable. No Math.random, no wall clock in the plan; only fixed numbers and a seeded PRNG.
4
+ */
5
+ export const AUTOPLAY_LABEL = 'DEMO: demo data, nothing real runs.';
6
+ export const AUTOPLAY_REQUEST = 'Show me what you can do (demo)';
7
+ export const AUTOPLAY_PROOF = '/proof';
8
+ export const AUTOPLAY = Object.freeze({ seed: 20261013, startMs: 900, typeMinMs: 38, typeMaxMs: 78, afterTypeMs: 450, approvalHoldMs: 2500, afterKeyMs: 400, afterTurnMs: 900, finalHoldMs: 600 });
9
+
10
+ /** Small seeded PRNG (mulberry32): deterministic across runs and platforms. */
11
+ export function prng(seed) {
12
+ let a = seed >>> 0;
13
+ return () => {
14
+ a = (a + 0x6D2B79F5) >>> 0;
15
+ let t = a;
16
+ t = Math.imul(t ^ (t >>> 15), t | 1);
17
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
18
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
19
+ };
20
+ }
21
+
22
+ /** Per-character delays (ms) for typing `text` at a human pace; identical for the same seed. */
23
+ export function typingPlan(text, seed = AUTOPLAY.seed, { min = AUTOPLAY.typeMinMs, max = AUTOPLAY.typeMaxMs } = {}) {
24
+ const r = prng(seed);
25
+ return [...text].map(() => Math.round(min + r() * (max - min)));
26
+ }
27
+
28
+ /** Is autoplay requested? flag or QUOX_DEMO_AUTOPLAY=1. */
29
+ export const autoplayOn = (flag, env = process.env) => Boolean(flag) || /^(1|true|yes|on)$/i.test(String(env.QUOX_DEMO_AUTOPLAY || ''));
@@ -14,7 +14,7 @@ export const DEMO_STEPS = (turnId) => [
14
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
15
  { when: 'reject', wait: 200, ev: { type: 'turn.finished', turn_id: turnId, status: 'completed', conversation_id: 'demo-conv' } },
16
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 } } },
17
+ { when: 'approve', wait: 900, ev: { type: 'tool.finished', turn_id: turnId, call_id: 'c2', tool: 'host_restart', status: 'ok', result_summary: 'restarted', duration_ms: 2300, envelope_id: 'env2', receipt: { ward_seq: 4069513, verified: true } } },
18
18
  { when: 'approve', wait: 100, ev: { type: 'trace', turn_id: turnId, protocol: 'aocl', label: 'ingress ok identity ok policy ok' } },
19
19
  { when: 'approve', wait: 100, ev: { type: 'trace', turn_id: turnId, protocol: 'ward', label: 'witnessed', ref: '4069513' } },
20
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).' } },
@@ -1,3 +1,4 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
1
2
  import { DEMO_STEPS } from './demo.fixtures.js';
2
3
 
3
4
  const sleep = (ms, signal) => new Promise((res) => {
@@ -7,7 +8,32 @@ const sleep = (ms, signal) => new Promise((res) => {
7
8
  });
8
9
 
9
10
  /** A fake api with the same surface as createApi(), driven by DEMO_STEPS. */
10
- export function createDemoApi({ speed = 1 } = {}) {
11
+ /**
12
+ * WALK-FIXES pty seam (demo only, never a real server): decisionsFile is a JSON state file
13
+ * { items: [decision inbox items], log: [], answerDelayMs?, approvalsDelayMs? }. approvals() lists the open ones,
14
+ * answerDecision() marks one answered and appends { id, key, note, at } to log, so a test in another process reads
15
+ * exactly what was submitted, and when.
16
+ */
17
+ function decisionStore(file) {
18
+ const load = () => JSON.parse(readFileSync(file, 'utf8'));
19
+ const wait = (ms) => new Promise(r => setTimeout(r, ms || 0));
20
+ return {
21
+ // staleMs: an answered item is still listed for this long (a server that lags behind its own answers)
22
+ async open() { const d = load(); await wait(d.approvalsDelayMs); return d.items.filter(x => x.status === 'open' || (d.staleMs && x.answeredAt && Date.now() - x.answeredAt < d.staleMs)); },
23
+ async answer(id, key, note) {
24
+ const d0 = load(); await wait(d0.answerDelayMs);
25
+ const d = load(); const it = d.items.find(x => x.id === id);
26
+ if (!it || it.status !== 'open') { const e = new Error('already handled'); e.status = 404; throw e; }
27
+ it.status = 'answered'; it.answeredAt = Date.now(); it.answer = { key, note: note || null };
28
+ d.log.push({ id, key, note: note || null, at: Date.now() });
29
+ writeFileSync(file, JSON.stringify(d));
30
+ return { ok: true };
31
+ }
32
+ };
33
+ }
34
+
35
+ export function createDemoApi({ speed = 1, objects = false, decisionsFile = null } = {}) {
36
+ const decs = decisionsFile ? decisionStore(decisionsFile) : null;
11
37
  let gate = null;
12
38
  let decision = 'approve';
13
39
  return {
@@ -26,10 +52,25 @@ export function createDemoApi({ speed = 1 } = {}) {
26
52
  async steer() { return { ok: false, reason: 'unsupported' }; },
27
53
  async interrupt() { return { ok: false }; },
28
54
  async health() { return { ok: true, url: 'demo' }; },
29
- async approvals() { return []; },
55
+ // the open approval is in the inbox while the demo holds at the gate, so a poll never prunes its card
56
+ async approvals() { return [...(gate ? [{ id: 'demo-item', title: 'host_restart on web on edge-02', approval_id: 'demo-appr', created_at: new Date().toISOString() }] : []), ...(decs ? await decs.open() : [])]; },
57
+ ...(decs ? { async answerDecision(id, key, { note } = {}) { return decs.answer(id, key, note); }, async approvalStats() { return { awaiting: (await decs.open()).length + (gate ? 1 : 0) }; } } : {}),
30
58
  async skills() { return [{ id: 'sk1', name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }]; },
31
59
  async skill(id) { return { id, name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }; },
32
60
  async conversationExists() { return { exists: true }; },
33
- async respond(_id, action) { decision = action === 'reject' ? 'reject' : 'approve'; const g = gate; gate = null; g?.(); return { ok: true }; }
61
+ // /proof works in the demo journey (main.js asks for it); the other object views stay unavailable offline
62
+ ...(objects ? { objects: {
63
+ async proof(tool) {
64
+ const env = { id: tool.envelopeId, corr: 'demo-corr', payload: { success: true, tool_name: tool.tool } };
65
+ return { data: { tool, envelope: { ok: true, env },
66
+ aocl: { ok: true, list: [{ intent: 'aocl.identity.verified' }, { intent: 'aocl.policy.evaluated', payload: { allPassed: true } }] },
67
+ receipt: { ok: true, result: { status: 'WITNESSED', entry: { seq: 4069513 } } } } };
68
+ },
69
+ ...Object.fromEntries(['skill', 'agent', 'workflowRun', 'crystal', 'memory', 'rules', 'reconcile'].map((k) => [k, async () => ({ error: 'Object views are not available in the demo.' })]))
70
+ } } : {}),
71
+ async respond(_id, action) {
72
+ if (decs && _id !== 'demo-item') return decs.answer(_id, action === 'reject' ? 'n' : 'y', null); // a yes/no decision answered as an approval (the bulk card)
73
+ decision = action === 'reject' ? 'reject' : 'approve'; const g = gate; gate = null; g?.(); return { ok: true };
74
+ }
34
75
  };
35
76
  }