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
package/lib/quoxon/api.js CHANGED
@@ -3,13 +3,29 @@
3
3
  * driven by a fake (demo + tests). All routes per docs/QUOXON_EVENTS_v1.md.
4
4
  */
5
5
  import { resolveEndpoint } from '../api/endpoints.js';
6
+ import { rememberEmail } from '../core/auth.js';
7
+ import { isSessionDead } from './sessionDead.js'; // not signin.js: that loads React + Ink (QUOXON-LATENCY)
6
8
  import { buildStreamQuery } from '../commands/chat.js';
7
9
  import { loadBundledAssistantsRoster } from '../core/agentAlias.js';
10
+ import { bundledIdentities } from './objects/fetch.js';
8
11
  import { recordHeldSlot } from '../commands/slot.js';
9
12
  import { askGripe, settleGripe } from '../commands/gripe.js';
13
+ import { sendFeedback } from './feedback.js';
10
14
  import { fromObjective, fromExecution, fromApproval, sortRows } from './roster.js';
11
15
  import { normChoice } from './model.js';
16
+ import { normPrefs as normModelPrefs } from './modelPrefs.js';
17
+ import { createRowsStore } from './rowsStore.js';
12
18
  import { createObjectsApi } from './objects/fetch.js';
19
+ import { createKillerApi } from './slash/killerApi.js';
20
+ import { createUndoOps } from './slash/undoApi.js';
21
+ import { resolveDashboardBase, resolveCollectorBase, detectLanIp } from './hostUrls.js';
22
+ import { grantBody } from './consent.js';
23
+ import { loadJourney } from './onboarding/journey.js';
24
+ import { joinServiceUrl } from '../core/config.js';
25
+ import { runVmCommand } from '../core/vmTui.js';
26
+ import { fetchActivity, heldLines } from '../core/hyperion.js';
27
+ import { createPeekFetchers, peekId } from '../core/peek.js';
28
+ import { askAside } from '../core/aside.js';
13
29
 
14
30
  const missing = (e) => e?.status === 404 || e?.status === 405 || e?.status === 501;
15
31
  /** Server steer refusal label -> client reason. Unknown labels are 'ambiguous', never "finished". */
@@ -48,12 +64,37 @@ async function* readTurn(open, cur, signal) {
48
64
  const sleepFor = (ms, signal) => new Promise((r) => { if (signal?.aborted) return r(); const t = setTimeout(done, ms); function done() { clearTimeout(t); signal?.removeEventListener?.('abort', done); r(); } signal?.addEventListener?.('abort', done, { once: true }); });
49
65
  const listOf = (d, ...keys) => { for (const k of keys) if (Array.isArray(d?.[k])) return d[k]; return Array.isArray(d) ? d : null; };
50
66
 
51
- export function createApi({ client, config, auth, reattachBackoff = REATTACH_BACKOFF_MS }) {
67
+ export const SLOTS_UNLICENSED = 'Slots need a licence. This Quox does not have one, so /slot is off. See: quox setup license status';
68
+ // Z2H run-6: every slot write (checkout, /say, /broadcast) names the licence reason the same way.
69
+ /** #1288: a refused vault write, keeping the server's "confirm this private address" hint. */
70
+ const vaultFailure = (e) => ({ ok: false, status: e?.status, message: e?.message || 'failed', ...(e?.body?.confirmable === true ? { confirmable: true, origin: typeof e.body.origin === 'string' ? e.body.origin : null } : {}) });
71
+ const slotsUnlicensed = (e) => e?.status === 403 && /not licen[cs]ed/i.test(`${e?.code || ''} ${e?.body?.error || ''} ${e?.message || ''}`);
72
+
73
+ /** PRESENCE: only a person at a real terminal is "back". --print, pipes, proof runs and agents are not. */
74
+ export function isInteractiveHuman({ stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
75
+ return Boolean(stdin?.isTTY && stdout?.isTTY) && !env.CI;
76
+ }
77
+
78
+ export function createApi({ client, config, auth, windowOrg = null, reattachBackoff = REATTACH_BACKOFF_MS }) {
79
+ let holderSessionFn = null;
52
80
  const orgId = () => { try { return auth.getOrgId(); } catch { return null; } };
81
+ const rowsStore = createRowsStore({ client, config, orgId });
82
+ let credsSeen = null; // credentials-file mtime last seen (credentialsChanged)
53
83
  const userId = () => { try { return config.getAuth?.()?.user_id || null; } catch { return null; } };
84
+ /** Z2H-R3: the server's public app URL (instance_config app_base_url), or null (not admin, old server, unset). Never throws. */
85
+ const serverAppUrl = async () => {
86
+ try { const ep = resolveEndpoint('admin.instance.basics.get'); const r = await client.get(ep.path, { service: ep.service, retries: 0 }); return r?.effective?.appBaseUrl || null; } catch { return null; }
87
+ };
88
+ const journeyUrl = () => { try { return loadJourney()?.dashboardUrl || null; } catch { return null; } };
54
89
  const listApprovals = async () => {
55
- const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
56
- const data = await client.get(ep.path, { service: ep.service, query: { status: 'open', kind: 'approval', limit: 20 } });
90
+ // quox#827: the set comes from the server's one open-approvals function (the same one behind the dashboard badge and LUCID).
91
+ const ep = resolveEndpoint('inbox.approvals.open', { orgId: orgId() });
92
+ try {
93
+ const data = await client.get(ep.path, { service: ep.service });
94
+ if (Array.isArray(data?.approvals)) return data.approvals.map((a) => a.item).filter(Boolean);
95
+ } catch (e) { if (!missing(e)) throw e; } // an older server without the route: fall back to the inbox list below
96
+ const old = resolveEndpoint('inbox.list', { orgId: orgId() });
97
+ const data = await client.get(old.path, { service: old.service, query: { kind: 'approval', include_snoozed: 'true', limit: 200 } }); // status omitted = every ACTIVE status
57
98
  const items = data.items || data.data || data || [];
58
99
  return Array.isArray(items) ? items : [];
59
100
  };
@@ -93,11 +134,75 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
93
134
  err.code = 'reattach_failed';
94
135
  throw err;
95
136
  }
137
+ /** PRESENCE auto-clear: the human's own first real action. -> { ok, cleared, welcome } (welcome once: { away_text, via }). Never throws. */
138
+ async function noteActivity(action, { quiet = false, interactive = isInteractiveHuman() } = {}) {
139
+ if (!interactive) return { ok: false, cleared: false, welcome: null, skipped: 'not interactive' };
140
+ try {
141
+ const ep = resolveEndpoint('presence.activity');
142
+ const d = await client.post(ep.path, { service: ep.service, retries: 0, timeout: 6000, body: { action, org_id: orgId(), ...(quiet ? { quiet: true } : {}) } });
143
+ return { ok: true, cleared: d?.cleared === true, welcome: d?.welcome || null };
144
+ } catch (e) { return { ok: false, cleared: false, welcome: null, status: e?.status || null }; }
145
+ }
96
146
  return {
147
+ ...createKillerApi({ client }),
97
148
  orgId,
149
+ undoOps: createUndoOps({ client, orgId }), // /undo
98
150
  userId,
151
+ /** /peek <id>: the same lookup as `quox peek`, in this window's org. -> peekId result */
152
+ peek: (id) => peekId(id, createPeekFetchers({ client, orgId })),
153
+ /** /aside: a no-tools, no-conversation question (core/aside.js). turns = recent exchanges, read-only context. */
154
+ aside: (question, turns = []) => askAside(client, { question, turns }),
155
+ /** @mention hosts: the Bastion inventory (same read as `quox fleet hosts`). -> [{ id, group, health }] */
156
+ async mentionHosts() {
157
+ const ep = resolveEndpoint('v1.fleet.hosts.list');
158
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 8000 });
159
+ return (listOf(d, 'hosts') || []).map(h => ({ id: h.id, group: h.group || '', health: h.status?.health || '' }));
160
+ },
161
+ /** @mention tasks: the newest objectives (one read, cached by the composer for the session). -> [{ id, title, status }] */
162
+ async mentionTasks() {
163
+ const ep = resolveEndpoint('objectives.list');
164
+ const d = await client.get(ep.path, { service: ep.service, query: { limit: 30 }, retries: 0, timeout: 8000 });
165
+ return (listOf(d, 'objectives', 'data') || []).map(o => ({ id: o.id, title: o.title || '', status: o.status || '' }));
166
+ },
167
+ /** Z2H: the same login path as `quox login` (createAuth().login). Returns only what the card needs; tokens are stored by auth, never surfaced. */
168
+ async signIn(email, password) {
169
+ const r = await auth.login(config.getServiceUrl('auth'), email, password);
170
+ return { mfa_required: Boolean(r.mfa_required), access_token: r.mfa_required ? r.access_token : undefined, user: r.user ? { email: r.user.email } : null };
171
+ },
172
+ verifyMfa: (token, code) => auth.verifyMfa(config.getServiceUrl('auth'), token, code),
173
+ /** The last email this profile signed in with (prefill for /login), or ''. */
174
+ lastEmail() { try { return config.getAuth?.()?.email || config.getActiveProfile?.()?.last_email || ''; } catch { return ''; } },
175
+ /**
176
+ * Startup/ongoing check: is the stored session dead? Only a locally expired token is probed, via the refresh seam.
177
+ * -> { alive: true } | { alive: false, reason: 'idle_timeout'|'max_session'|null }. A transient failure reads as alive (we learned nothing).
178
+ */
179
+ async probeSession() {
180
+ try {
181
+ if (!auth.isExpired?.()) return { alive: true };
182
+ try { rememberEmail(config, config.getAuth?.()?.email); } catch { /* best-effort */ }
183
+ await auth.refresh(config.getServiceUrl('auth'));
184
+ return { alive: true };
185
+ } catch (e) {
186
+ return isSessionDead(e) ? { alive: false, reason: e.reason || null } : { alive: true };
187
+ }
188
+ },
189
+ /**
190
+ * Stale sign-in: true once each time the credentials file changed on disk since the last call (another window
191
+ * or `quox login` wrote it). A cheap stat; the first call only records the baseline.
192
+ */
193
+ credentialsChanged() {
194
+ try {
195
+ const m = config.credentialsMtime?.() || 0;
196
+ const was = credsSeen; credsSeen = m;
197
+ return was !== null && m !== was;
198
+ } catch { return false; }
199
+ },
200
+ /** Re-read this profile's credentials from disk. -> true when a signed-in profile is there to retry with. */
201
+ reloadCredentials() {
202
+ try { config.reloadAuth?.(); return Boolean(config.getAuth?.()?.access_token); } catch { return false; }
203
+ },
99
204
  /** Inline object views (objects/): skill, workflow, crystal, agent, memory, rules, proof, reconcile. */
100
- objects: createObjectsApi({ client, orgId }),
205
+ objects: createObjectsApi({ client, orgId, profile: config?._effectiveProfile || null }),
101
206
  /** /model choice persisted on the active profile ({ provider, model? } only, never a key); null = auto. */
102
207
  modelChoice() { try { return normChoice(config.getActiveProfile?.()?.chat_model); } catch { return null; } },
103
208
  setModelChoice(choice) {
@@ -119,6 +224,46 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
119
224
  await client.patch(ep.path, { service: ep.service, body: { preferences: { soundProfile: value } } });
120
225
  return true;
121
226
  },
227
+ /**
228
+ * QUOXON-FW6: model favourites + default. The server is the source of truth: the user's
229
+ * `quoxonModels` preference (PATCH /api/auth/me merges top-level namespaces, the same store as
230
+ * soundProfile). The active CLI profile keeps a copy (model_prefs) for offline starts.
231
+ * -> { prefs, source: 'server' | 'local', error? }.
232
+ */
233
+ async modelPrefs() {
234
+ const local = () => normModelPrefs(config.getActiveProfile?.()?.model_prefs);
235
+ try {
236
+ const ep = resolveEndpoint('auth.me.list');
237
+ const d = await client.get(ep.path, { service: ep.service, retries: 0 });
238
+ const raw = (d?.user?.preferences ?? d?.preferences)?.quoxonModels;
239
+ const prefs = normModelPrefs(raw);
240
+ const prof = config.getActiveProfile?.();
241
+ if (prof && raw !== undefined) { prof.model_prefs = prefs; config.save?.(); }
242
+ const useLocal = raw === undefined && local().favourites.length;
243
+ return { prefs: useLocal ? local() : prefs, source: 'server' };
244
+ } catch (e) { return { prefs: local(), source: 'local', error: e?.message || 'failed' }; }
245
+ },
246
+ /** -> { ok, synced, error? }: the local copy is always written; synced=false: refused. */
247
+ async setModelPrefs(value) {
248
+ const prefs = normModelPrefs(value);
249
+ const prof = config.getActiveProfile?.();
250
+ if (prof) { prof.model_prefs = prefs; config.save?.(); }
251
+ try {
252
+ const ep = resolveEndpoint('auth.me.update');
253
+ const body = { preferences: { quoxonModels: prefs } };
254
+ await client.patch(ep.path, { service: ep.service, body, retries: 0 });
255
+ return { ok: true, synced: true };
256
+ } catch (e) { return { ok: Boolean(prof), synced: false, error: e?.message || 'failed' }; }
257
+ },
258
+ localModelPrefs() {
259
+ try { return normModelPrefs(config.getActiveProfile?.()?.model_prefs); }
260
+ catch { return normModelPrefs(null); }
261
+ },
262
+ /**
263
+ * QUOXON-FW17 / ROWS: custom rows. url, quox and plugin rows sync per user (preference namespace `quoxonRows`);
264
+ * cmd rows stay in a local file. One implementation, shared with `quox rows` (rowsStore.js).
265
+ */
266
+ ...rowsStore,
122
267
  /** Local copy on the active CLI profile: offline start + fallback store. */
123
268
  localSoundProfile() { try { return config.getActiveProfile?.()?.sound_profile || null; } catch { return null; } },
124
269
  setLocalSoundProfile(p) { const prof = config.getActiveProfile?.(); if (!prof) return false; prof.sound_profile = p; config.save?.(); return true; },
@@ -129,17 +274,21 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
129
274
  * by seq. While reconnecting it yields { type:'reconnecting', attempt } and, once back, { type:'resumed', after }.
130
275
  * A server without v1.1 (no seq on its events) gets the v1 behaviour: no reattach.
131
276
  */
132
- async *stream({ turnId, message, agent, conversationId, context, provider, model, promptProfile, durable = true }, signal) {
277
+ async *stream({ turnId, message, agent, conversationId, context, provider, model, promptProfile, heldSlot = null, durable = true, overrideId = undefined, turnBudgetMs = undefined, mode = undefined, handoffFrom = undefined }, signal) {
133
278
  const body = buildStreamQuery({ queryId: turnId, message, agent: agent || 'quox', sessionId: conversationId, context });
134
279
  body.turn_id = turnId;
135
280
  if (durable) body.durable = true; // the server keeps the turn running when we disconnect (v1.1)
281
+ if (Number(turnBudgetMs) > 0) body.turn_budget_ms = Math.floor(Number(turnBudgetMs)); // quox#1090: a delegation hop may use the turn's budget
136
282
  if (provider) body.provider = provider; // a request: the server policy decides, turn.started says what ran
137
283
  if (provider && model) body.model = model;
138
284
  if (promptProfile === 'compact' || promptProfile === 'full') body.prompt_profile = promptProfile; // D3: server default when absent
139
285
  if (conversationId) body.conversation_id = conversationId;
286
+ if (mode === 'plan') body.mode = 'plan'; // PLAN MODE: the server makes no state-changing tool eligible and refuses writes
287
+ if (heldSlot?.name && heldSlot?.session) body.held_slot = heldSlot; // QUOXON-MESH: slot_say signs as this window's slot
288
+ if (handoffFrom) body.handoff_from = String(handoffFrom).slice(0, 80); // a handed-off turn: this agent stays attached, the server labels the exchange for it
140
289
  const ep = resolveEndpoint('chat.stream');
141
290
  const cur = { lastSeq: 0, finished: false, conversationId: conversationId || null };
142
- const drop = yield* readTurn((sig) => client.stream(ep.path, { service: ep.service, body, signal: sig, untilClose: true }), cur, signal);
291
+ const drop = yield* readTurn((sig) => client.stream(ep.path, { service: ep.service, body, signal: sig, untilClose: true, ...(overrideId ? { headers: { 'x-override-id': overrideId } } : {}) }), cur, signal);
143
292
  if (cur.finished || !drop || !durable || signal?.aborted) return;
144
293
  if (!cur.lastSeq || !cur.conversationId) { if (drop.error) throw drop.error; return; } // v1 server: nothing to reattach to
145
294
  yield* reattachLoop({ turnId, cur, signal, first: drop.error, backoff: reattachBackoff });
@@ -208,20 +357,24 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
208
357
  const roster = loadBundledAssistantsRoster();
209
358
  const nameOf = new Map(roster.map(r => [r.id, r.name || r.shortName || r.id]));
210
359
  const out = new Map(); let ok = 0; let lastErr = null;
360
+ let ids = null; const roleOf = (id) => { ids ??= bundledIdentities(); const i = ids.get(id); return i?.tagline || i?.description || null; };
211
361
  try {
212
362
  const ep = resolveEndpoint('v1.tools.agents.list');
213
- const d = await client.get(ep.path, { service: ep.service, noAuth: true, retries: 0 });
214
- for (const id of Object.keys(d?.agentTools || {})) out.set(id, { agent_id: id, name: nameOf.get(id) || id });
363
+ const d = await client.get(ep.path, { service: ep.service, retries: 0 }); // authenticated: the collector's default-deny gate 401s a noAuth read
364
+ for (const id of Object.keys(d?.agentTools || {})) out.set(id, { agent_id: id, name: nameOf.get(id) || id, role: roleOf(id), status: 'registered' });
215
365
  ok++;
216
- } catch (e) { lastErr = e; for (const r of roster) out.set(r.id, { agent_id: r.id, name: nameOf.get(r.id) }); if (roster.length) ok++; }
366
+ } catch (e) { lastErr = e; for (const r of roster) out.set(r.id, { agent_id: r.id, name: nameOf.get(r.id), role: roleOf(r.id) }); if (roster.length) ok++; }
217
367
  try {
218
368
  const ep = resolveEndpoint('agents.list', { orgId: orgId() });
219
369
  const rows = listOf(await client.get(ep.path, { service: ep.service, query: { includeTools: 'false' }, retries: 0 }), 'agents', 'data', 'items');
220
- for (const r of rows || []) if (r.agent_id && !out.has(r.agent_id)) out.set(r.agent_id, { agent_id: r.agent_id, name: r.name || r.agent_id });
370
+ for (const r of rows || []) if (r.agent_id && !out.has(r.agent_id)) out.set(r.agent_id, { agent_id: r.agent_id, name: r.name || r.agent_id, role: r.description || null, status: r.status || null });
221
371
  if (rows) ok++;
222
372
  } catch (e) { lastErr = lastErr || e; }
223
373
  if (!ok) throw lastErr || new Error('no agent list available');
224
- return [...out.values()];
374
+ const list = [...out.values()];
375
+ // The live registry failed and the bundled roster stood in: say so, so a miss is never read as "no such agent".
376
+ if (lastErr) Object.defineProperty(list, 'listError', { value: lastErr });
377
+ return list;
225
378
  },
226
379
  /** -> saved turns of one of YOUR conversations, oldest first [{ id, role, content }]; [] when none or not yours. */
227
380
  async conversationTurns(conversationId) {
@@ -236,6 +389,24 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
236
389
  if (!d || !Array.isArray(d.groups)) throw new Error('unexpected response');
237
390
  return d;
238
391
  },
392
+ /** GET /api/consent: this user's AI data sharing record ({ ai_data_sharing_consent, provider_consent }). */
393
+ async consentStatus() {
394
+ const ep = resolveEndpoint('consent.list');
395
+ return client.get(ep.path, { service: ep.service, retries: 0 });
396
+ },
397
+ /** Grant AI data sharing consent for one provider (the user's own act; keeps the other providers' settings). */
398
+ async grantConsent(provider) {
399
+ const lst = resolveEndpoint('consent.list');
400
+ let cur = null; try { cur = await client.get(lst.path, { service: lst.service, retries: 0 }); } catch { cur = null; }
401
+ const ep = resolveEndpoint('consent.create');
402
+ await client.post(ep.path, { service: ep.service, body: grantBody(cur, provider), retries: 0 });
403
+ return true;
404
+ },
405
+ /** The dashboard address to print (never localhost when a better one is known). */
406
+ async dashboardBase() {
407
+ const profileUrl = config.getActiveProfile?.()?.url || config.getBaseUrl();
408
+ return resolveDashboardBase({ serverApp: await serverAppUrl(), profileUrl, journeyUrl: journeyUrl(), lanIp: detectLanIp() }).url;
409
+ },
239
410
  /** POST /api/v1/conversations/:id/compact (server-side summary; a model call, so a long timeout and never retried). */
240
411
  async compactConversation(conversationId) {
241
412
  return client.post(`/api/v1/conversations/${encodeURIComponent(conversationId)}/compact`, { service: 'collector', body: {}, retries: 0, timeout: 180000 });
@@ -254,6 +425,36 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
254
425
  },
255
426
  /** The `quox doctor` checks, run in this process. */
256
427
  async doctorChecks() { const { runDoctorChecks } = await import('../commands/doctor.js'); return runDoctorChecks({ config }); },
428
+ /** C4: the `quox server status` panel rows for the local install. -> { ok, rows } | { ok:false, error }. */
429
+ async serverStatus() {
430
+ const { createSys } = await import('../server/sys.js');
431
+ const { resolveInstall, backupsRoot } = await import('../server/install.js');
432
+ const { gatherStatus, statusRows, defaultUrls } = await import('../server/status.js');
433
+ const sys = createSys();
434
+ const install = await resolveInstall({ sys });
435
+ if (install.error) return { ok: false, error: install.error };
436
+ const st = await gatherStatus({ install, sys, urls: defaultUrls(config), backups: backupsRoot() });
437
+ return { ok: true, rows: statusRows(st) };
438
+ },
439
+ /** /dashboard: mint a one-time sign-in link for this profile (see slash/dashboard.js). Never throws; kind 'link' | 'plain' | 'error'. */
440
+ async dashboardLink(page) {
441
+ const { runDashboard } = await import('./slash/dashboard.js');
442
+ const profileUrl = config.getActiveProfile?.()?.url || config.getBaseUrl();
443
+ const baseUrl = resolveDashboardBase({ serverApp: await serverAppUrl(), profileUrl, journeyUrl: journeyUrl(), lanIp: detectLanIp() }).url;
444
+ return runDashboard({ page, baseUrl, mint: () => { const ep = resolveEndpoint('auth.login-link'); return client.post(ep.path, { service: ep.service, body: {}, retries: 0 }); } });
445
+ },
446
+ /** Z2H-R3: the address to print for the host installer (never localhost when a better one is known). */
447
+ async hostsBaseUrl() {
448
+ let collectorUrl = null; try { collectorUrl = new URL(config.getServiceUrl('collector')).origin; } catch { /* none */ }
449
+ return resolveCollectorBase({ collectorUrl, serverApp: await serverAppUrl(), journeyUrl: journeyUrl(), lanIp: detectLanIp() }).url;
450
+ },
451
+ /** Z2H-3c: can `${base}/install` be served? -> { ok, status } or null when unreachable (never throws, 4 s cap). */
452
+ async hostInstallCheck(base) {
453
+ if (!base || /^<|example/.test(String(base))) return null;
454
+ const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 4000);
455
+ try { const r = await fetch(`${String(base).replace(/\/+$/, '')}/install`, { method: 'GET', signal: ctl.signal }); try { await r.body?.cancel?.(); } catch { /* drained */ } return { ok: r.ok, status: r.status }; }
456
+ catch { return null; } finally { clearTimeout(t); }
457
+ },
257
458
  serverUrl() { try { return new URL(config.getServiceUrl('collector')).origin; } catch { return null; } },
258
459
  /** /config settings on the active profile. */
259
460
  settings() { try { const q = config.getActiveProfile?.()?.quoxon; return q && typeof q === 'object' ? { ...q } : {}; } catch { return {}; } },
@@ -287,17 +488,198 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
287
488
  return { ok: false, message: e?.status ? `HTTP ${e.status}: ${e.message}` : (e?.message || 'failed') };
288
489
  }
289
490
  },
290
- async checkoutSlot(name, session) {
491
+ /**
492
+ * /feedback: POST the previewed payload to the public quox.ai feedback API (not the org's server, not GitHub).
493
+ * Anonymous by design: no token, cookie, org or profile goes with it. -> { ok:true, id, url } | { ok:false, message }.
494
+ */
495
+ async feedback(payload, { version = null } = {}) { return sendFeedback(payload, { version }); },
496
+ /** Installed, enabled plugin ids for the org (GET plugins.list, as `quox plugin list`). Throws on failure so it never reads as "none". */
497
+ async installedPlugins() {
498
+ const id = orgId();
499
+ if (!id) throw new Error('no org');
500
+ const ep = resolveEndpoint('plugins.list', { orgId: id });
501
+ const data = await client.get(ep.path, { service: ep.service, retries: 0 });
502
+ const rows = listOf(data, 'plugins', 'data', 'items');
503
+ if (!rows) throw new Error('unexpected response');
504
+ return rows.filter(r => r.is_enabled !== false && r.is_enabled !== 0).map(r => r.plugin_id).filter(Boolean);
505
+ },
506
+ /** Fleet hosts already connected (tips skip the QuoxAgent tip when > 0). GET agents list; throws on failure so a failure never reads as "no hosts". */
507
+ async hostCount() {
508
+ const ep = resolveEndpoint('v1.agents.list');
509
+ const rows = listOf(await client.get(ep.path, { service: ep.service, retries: 0 }), 'agents', 'data', 'items');
510
+ if (!rows) throw new Error('unexpected response');
511
+ return rows.length;
512
+ },
513
+ /** -> [{ name, host_name, assigned_to, ... }] ; GET /api/v1/slots (throws on failure, never reads as empty). */
514
+ async slots() {
515
+ const ep = resolveEndpoint('v1.slots.list');
516
+ const rows = listOf(await client.get(ep.path, { service: ep.service, retries: 0 }), 'slots');
517
+ if (!rows) throw new Error('unexpected response');
518
+ return rows;
519
+ },
520
+ /**
521
+ * Post a message into a slot room (same call as `quox slot say`). QUOXON-MESH: `from` signs it as this window
522
+ * ("quox1 (Adam)"); `fast` answers once the line is in the slot, without waiting on the chat-app bridges.
523
+ * -> { ok:true, message } | { ok:false, message, status }.
524
+ */
525
+ /** SLOTS-REPLY2: the window registers how to name its checkout session; slotSay then sends it as holder_session. */
526
+ bindHolderSession(fn) { holderSessionFn = typeof fn === 'function' ? fn : null; },
527
+ async slotSay(name, text, { from = null, fast = false, roomSession = null, fromSlot = null, holderSession = holderSessionFn ? holderSessionFn() : null } = {}) {
528
+ const ep = resolveEndpoint('v1.slots.say', { name });
529
+ try {
530
+ // SLOTS-REPLY: fromSlot is this window's held slot, its reply address (the server verifies the hold).
531
+ const d = await client.post(ep.path, { service: ep.service, retries: 0, body: { text, org_id: orgId(), ...(roomSession ? { room_session: roomSession } : from ? { from } : {}), ...(!roomSession && fromSlot ? { from_slot: fromSlot } : {}), ...(!roomSession && holderSession ? { holder_session: holderSession } : {}), ...(fast ? { bridge_report: false } : {}) } });
532
+ return { ok: true, message: d?.message || null, roomPending: d?.room_pending === true, presence: d?.presence || null, humanPresence: d?.human_presence || null };
533
+ } catch (e) {
534
+ if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
535
+ return { ok: false, message: e?.status === 404 ? `no slot named ${name}` : (e?.message || 'failed'), status: e?.status || null };
536
+ }
537
+ },
538
+ /** QUOXON-BROADCAST: fan a message out to the org's held slots. -> { ok:true, data } | { ok:false, message, status }. */
539
+ async slotBroadcast({ text, slots = null, priority = 'normal', ack = false, from = null } = {}) {
540
+ const ep = resolveEndpoint('v1.slots.broadcast');
541
+ try {
542
+ const data = await client.post(ep.path, { service: ep.service, retries: 0, body: { text, priority, ack, org_id: orgId(), ...(slots ? { slots } : {}), ...(from ? { from } : {}) } });
543
+ return { ok: true, data };
544
+ } catch (e) {
545
+ if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
546
+ return { ok: false, message: e?.message || 'failed', status: e?.status || null };
547
+ }
548
+ },
549
+ async slotBroadcastStatus(id) {
550
+ const ep = resolveEndpoint('v1.slots.broadcast.get', { id });
551
+ try { return { ok: true, data: await client.get(ep.path, { service: ep.service, retries: 0, query: { org_id: orgId() } }) }; } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
552
+ },
553
+ // POKE-WAKE: slot control (server allows a human org owner/admin only; agents are refused).
554
+ async slotControl(slot, command) {
555
+ const ep = resolveEndpoint('v1.slots.control', { name: slot });
556
+ try { return { ok: true, data: await client.post(ep.path, { service: ep.service, retries: 0, body: { command, org_id: orgId() } }) }; } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
557
+ },
558
+ async slotControlGet(slot, id) {
559
+ const ep = resolveEndpoint('v1.slots.control.get', { name: slot, id });
560
+ try { return { ok: true, data: await client.get(ep.path, { service: ep.service, retries: 0, query: { org_id: orgId() } }) }; } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
561
+ },
562
+ async slotAck(id, slot) {
563
+ const ep = resolveEndpoint('v1.slots.broadcast.ack', { id });
564
+ try { return { ok: true, data: await client.post(ep.path, { service: ep.service, retries: 0, body: { slot, org_id: orgId() } }) }; } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
565
+ },
566
+ /** QUOXON-MESH: the slot's live messages (SSE). Yields { type: 'connected' | 'slot.message' | 'reconnect' }. */
567
+ async *slotStream(name, { since = null, excludeFrom = null, session = null } = {}, signal) {
568
+ const ep = resolveEndpoint('v1.slots.stream', { name });
569
+ yield* client.stream(ep.path, { service: ep.service, query: { org_id: orgId(), since, exclude_from: excludeFrom, ...(session ? { session } : {}) }, signal, untilClose: true });
570
+ },
571
+ /** QUOXON-ROOMS: join / leave a slot as a room (no hold). -> { ok, already? | removed? } | { ok:false, message, status }. */
572
+ async slotJoin(name, session, person = null) {
573
+ const ep = resolveEndpoint('v1.slots.join', { name });
574
+ try { const d = await client.post(ep.path, { service: ep.service, retries: 0, body: { session, ...(person ? { person } : {}), org_id: orgId() } }); return { ok: true, already: d?.already === true }; }
575
+ catch (e) { return { ok: false, message: e?.status === 404 ? `no slot named ${name}` : (e?.message || 'failed'), status: e?.status || null }; }
576
+ },
577
+ async slotLeave(name, session) {
578
+ const ep = resolveEndpoint('v1.slots.leave', { name });
579
+ try { const d = await client.post(ep.path, { service: ep.service, retries: 0, body: { session, org_id: orgId() } }); return { ok: true, removed: d?.removed === true }; }
580
+ catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
581
+ },
582
+ /** The rooms this session has joined (server truth, survives a restart). -> [{ slot_name, joined_at }]; throws on failure. */
583
+ async slotRooms(session) {
584
+ const ep = resolveEndpoint('v1.slots.rooms');
585
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, query: { session, org_id: orgId() } });
586
+ return Array.isArray(d?.rooms) ? d.rooms : [];
587
+ },
588
+ /** Long-poll fallback for a server without the stream route (same call as `quox slot wait`). */
589
+ async slotWait(name, { since = null, excludeFrom = null } = {}) {
590
+ const ep = resolveEndpoint('v1.slots.wait', { name });
591
+ return client.get(ep.path, { service: ep.service, retries: 0, timeout: 40000, query: { org_id: orgId(), since, exclude_from: excludeFrom, timeout_ms: 25000 } });
592
+ },
593
+ /** Last `limit` messages of a slot, oldest first (same call as `quox slot read`). Throws on failure. */
594
+ async slotMessages(name, limit = 20) {
595
+ const ep = resolveEndpoint('v1.slots.messages', { name });
596
+ const rows = listOf(await client.get(ep.path, { service: ep.service, retries: 0, query: { org_id: orgId(), limit } }), 'messages');
597
+ if (!rows) throw new Error('unexpected response');
598
+ return rows;
599
+ },
600
+ /** The signed-in human's display name for slot lines (display_name, else the email's local part). */
601
+ displayName() {
602
+ try { const a = config.getAuth?.() || {}; return a.display_name || (a.email ? String(a.email).split('@')[0] : null); } catch { return null; }
603
+ },
604
+ /** `take` = the same signed-in user's other/renamed/restarted session takes a LIVE hold (stale holds of yours are reclaimed regardless). */
605
+ async checkoutSlot(name, session, { take = false } = {}) {
291
606
  const ep = resolveEndpoint('v1.slots.checkout', { name });
292
607
  try {
293
- const data = await client.post(ep.path, { service: ep.service, body: { holder_session: session, holder_kind: 'interactive', host_id: null, org_id: orgId(), force: false, create: true } });
608
+ const data = await client.post(ep.path, { service: ep.service, body: { holder_session: session, holder_kind: 'interactive', host_id: null, org_id: orgId(), force: false, create: true, ...(take ? { take: true } : {}) } });
294
609
  recordHeldSlot(name, session);
295
- return { ok: true, slot: data?.slot || data };
610
+ return { ok: true, slot: data?.slot || data, tookOver: data?.took_over || null };
296
611
  } catch (e) {
297
- if (e?.status === 409) return { ok: false, message: e.detail?.assigned_to ? `slot ${name} is assigned to ${e.detail.assigned_to}` : `slot ${name} is held by ${e.detail?.holder_session || 'another session'}` };
298
- return { ok: false, message: e?.message || 'failed' };
612
+ if (e?.status === 409) {
613
+ const d = e.detail || {};
614
+ if (d.assigned_to) return { ok: false, message: `slot ${name} is assigned to ${d.assigned_to}` };
615
+ return { ok: false, message: `slot ${name} is held by ${d.holder_session || 'another session'}`, held: { holder_session: d.holder_session || null, same_user: d.same_user === true, state: d.state || null, since: d.checked_out_at || null, last_seen: d.last_heartbeat_at || null } };
616
+ }
617
+ // Z2H-R5 C: a free install answers 403 "QuoxSlots is not licensed": say that, not a bare 403.
618
+ if (slotsUnlicensed(e)) {
619
+ return { ok: false, unlicensed: true, transient: false, status: 403, message: SLOTS_UNLICENSED };
620
+ }
621
+ // No answer, or the server failing (timeout, network, 5xx, e.g. a collector mid-redeploy): worth retrying.
622
+ const st = e?.status || null;
623
+ return { ok: false, message: e?.message || 'failed', status: st, transient: !st || st >= 500 || st === 429 || st === 408 };
299
624
  }
300
625
  },
626
+ /** Keep a TUI-held slot alive (the hold lapses to stale after 15m without one). -> { ok } */
627
+ async slotHeartbeat(name, session) {
628
+ const ep = resolveEndpoint('v1.slots.heartbeat', { name });
629
+ try { await client.post(ep.path, { service: ep.service, body: { holder_session: session, org_id: orgId() } }); return { ok: true }; }
630
+ catch (e) { return { ok: false, message: e?.message || 'failed' }; }
631
+ },
632
+ /** quox#1183: the org's hats -> [{ id, key, name, definition }]. Same endpoint as `quox hat list`. */
633
+ async hats() {
634
+ const ep = resolveEndpoint('hat.list', { orgId: orgId() });
635
+ const d = await client.get(ep.path, { service: ep.service });
636
+ return Array.isArray(d?.hats) ? d.hats.filter(h => h.status !== 'archived') : [];
637
+ },
638
+ /** CUSTOM-HATS: create an org hat of your own -> { hat, warnings }. Throws the server's refusal unchanged. */
639
+ async createHat({ key, name, definition, visibility = 'org' }) {
640
+ const ep = resolveEndpoint('hat.create', { orgId: orgId() });
641
+ return client.post(ep.path, { service: ep.service, retries: 0, body: { key, name, definition, visibility } });
642
+ },
643
+ /** CUSTOM-HATS: a new revision of a hat -> { hat, warnings }. */
644
+ async updateHat(hatId, { definition, name, change_reason }) {
645
+ const ep = resolveEndpoint('hat.update', { orgId: orgId(), hatId });
646
+ return client.put(ep.path, { service: ep.service, retries: 0, body: { definition, ...(name ? { name } : {}), change_reason } });
647
+ },
648
+ /** CUSTOM-HATS: delete a custom hat -> { deleted: { key, revision, bindings_cleared, user_prefs_cleared } }. */
649
+ async deleteHat(hatId) {
650
+ const ep = resolveEndpoint('hat.delete', { orgId: orgId(), hatId });
651
+ return client.delete(ep.path, { service: ep.service, retries: 0 });
652
+ },
653
+ /** quox#1183: the hat this conversation is on (or would start on) -> { key, name } | null. */
654
+ async activeHat(conversationId) {
655
+ const ep = resolveEndpoint('hat.resolve', { orgId: orgId() });
656
+ const d = await client.get(ep.path, { service: ep.service, ...(conversationId ? { query: { conversation_id: conversationId } } : {}) });
657
+ return d?.hat ? { key: d.hat.key, name: d.hat.name, source: d.source || null } : null;
658
+ },
659
+ /** quox#1183: the person's own hat switch for this conversation (audited on the server). Throws the refusal unchanged. */
660
+ async bindHat(conversationId, hatId) {
661
+ const ep = resolveEndpoint('hat.bind', { orgId: orgId(), conversationId });
662
+ const d = await client.put(ep.path, { service: ep.service, retries: 0, body: { hat_id: hatId } });
663
+ return d?.binding || d;
664
+ },
665
+ /** QUOXON-FW3: the orgs the signed-in user belongs to -> [{ id, name, slug, role }]. Same endpoint as `quox org list`. */
666
+ async orgs() {
667
+ const ep = resolveEndpoint('orgs.list');
668
+ const d = await client.get(ep.path, { service: ep.service });
669
+ const rows = Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || []);
670
+ return Array.isArray(rows) ? rows : [];
671
+ },
672
+ /**
673
+ * QUOXON-FW3, #1034: re-scope THIS WINDOW to another org. The org-scoped token is held in this window's memory
674
+ * (windowOrg); the shared profile, its file and every other window keep their org. `quox org switch` in a shell is
675
+ * the explicit way to change a profile's default. -> { id, name }. Throws the server's refusal unchanged.
676
+ */
677
+ async switchOrg(targetId) {
678
+ if (!windowOrg) { const e = new Error('Org switching is not available in this mode.'); e.code = 'no_window_org'; throw e; }
679
+ return windowOrg.switchTo(targetId);
680
+ },
681
+ /** The window's own org id when it differs from the profile's (carried across /restart), else null. */
682
+ windowOrgId: () => windowOrg?.current()?.id || null,
301
683
  /** -> [{ id, name, description, edition, version, decayState }] ; the org's QSKL procedural skills (read-only list; Crystals are a separate store). */
302
684
  async skills() {
303
685
  const ep = resolveEndpoint('quoxflow.skills.list');
@@ -361,6 +743,29 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
361
743
  }
362
744
  return { ok: false, unavailable: true };
363
745
  },
746
+ /** /sitrep: the LUCID situation (cursor not moved) and the approvals inbox, each part on its own. */
747
+ async sitrep() {
748
+ const part = async (fn) => { try { return { ok: true, v: await fn() }; } catch (e) { return { ok: false, error: e?.status ? `HTTP ${e.status}` : /timed out/i.test(e?.message || '') ? 'timed out' : (e?.message || 'failed') }; } };
749
+ const [situation, approvals] = await Promise.all([
750
+ part(async () => { const ep = resolveEndpoint('lucid.situation'); return client.get(ep.path, { service: ep.service, query: { since: 'last', surface: 'cli' }, retries: 0, timeout: 12000 }); }),
751
+ part(() => listApprovals())
752
+ ]);
753
+ return { situation, approvals };
754
+ },
755
+ /** PRESENCE: PUT /api/v1/presence (/afk, /sleep, /back, a typed turn). -> { ok:true, presence, notice } | { ok:false, message, status }. */
756
+ async setPresence(state, { note, slots } = {}) {
757
+ const ep = resolveEndpoint('presence.set');
758
+ try {
759
+ const d = await client.put(ep.path, { service: ep.service, retries: 0, timeout: 8000, body: { state, org_id: orgId(), ...(note ? { note } : {}), ...(Array.isArray(slots) && slots.length ? { slots } : {}) } });
760
+ return { ok: true, presence: d?.presence || null, notice: d?.notice || null };
761
+ } catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
762
+ },
763
+ noteActivity,
764
+ /** E5 /away: GET /api/v1/away?since=<iso> -> { since, until, items, activity, blind }. Throws on non-2xx. */
765
+ async away(since) {
766
+ const ep = resolveEndpoint('away.digest');
767
+ return client.get(ep.path, { service: ep.service, query: { since }, retries: 0, timeout: 15000 });
768
+ },
364
769
  /** Facts for /observers, each part on its own: { lucid, learning:[{ id, label, state, text }] }. */
365
770
  async observers() {
366
771
  const part = async (fn) => { try { return { ok: true, v: await fn() }; } catch (e) { return { ok: false, unavailable: missing(e), error: e?.message || 'failed' }; } };
@@ -377,8 +782,27 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
377
782
  ]);
378
783
  return { situation: sit, lessons, learning: [reflect, learn, insight] };
379
784
  },
785
+ /** /mind [id]: QuoxMind implants from the collector. -> { implants, health } | { implant }. Throws on non-2xx. */
786
+ async mind(id) {
787
+ if (id) { const ep = resolveEndpoint('mind.implant', { id }); return { implant: await client.get(ep.path, { service: ep.service, retries: 0, timeout: 15000 }) }; }
788
+ const [l, h] = await Promise.all([resolveEndpoint('mind.implants'), resolveEndpoint('mind.health')].map((ep) => client.get(ep.path, { service: ep.service, retries: 0, timeout: 15000 })));
789
+ return { implants: l.implants || [], health: h };
790
+ },
791
+ /** /mirror: the Mirror as CommanderQ sees it (compact System Status block + age). Throws on non-2xx. */
792
+ async mirror() {
793
+ const ep = resolveEndpoint('mind.mirror');
794
+ return client.get(ep.path, { service: ep.service, retries: 0, timeout: 10000 });
795
+ },
796
+ /** #904 part 3: providers refusing turns right now (weekly limit, no credits). -> [{ provider, state, line }] | null on error. */
797
+ async providerHealth() {
798
+ try {
799
+ const ep = resolveEndpoint('chat.status.list');
800
+ const d = await client.get(ep.path, { service: ep.service, retries: 0, timeout: 8000 });
801
+ return Array.isArray(d?.provider_health) ? d.provider_health : [];
802
+ } catch { return null; }
803
+ },
380
804
  async health(timeoutMs = 12000) {
381
- const url = new URL('/health', config.getServiceUrl('collector'));
805
+ const url = joinServiceUrl(config.getServiceUrl('collector'), '/health');
382
806
  const ctrl = new AbortController();
383
807
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
384
808
  try { const r = await fetch(url, { signal: ctrl.signal }); return { ok: r.ok, status: r.status, url: url.origin }; }
@@ -419,20 +843,32 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
419
843
  } catch (e) { return { ok: false, status: e?.status, message: e?.message || 'failed' }; }
420
844
  },
421
845
  /** The governed vault write (same route as `quox vault create`): audited, AEE envelope carries type + label only. */
422
- async saveProviderKey({ type, label, credentials, isDefault }) {
846
+ async saveProviderKey({ type, label, credentials, isDefault, confirmPrivateOrigin }) {
423
847
  const ep = resolveEndpoint('vault.create', { orgId: orgId() });
424
848
  try {
425
- const r = await client.post(ep.path, { service: ep.service, body: { integration_type: type, label, credentials, is_default: Boolean(isDefault) }, retries: 0, sensitive: true });
849
+ const r = await client.post(ep.path, { service: ep.service, body: { integration_type: type, label, credentials, is_default: Boolean(isDefault), ...(confirmPrivateOrigin ? { confirm_private_origin: confirmPrivateOrigin } : {}) }, retries: 0, sensitive: true });
426
850
  return { ok: Boolean(r?.id), id: r?.id || null, message: r?.id ? null : 'unexpected response' };
427
- } catch (e) { return { ok: false, status: e?.status, message: e?.message || 'failed' }; }
851
+ } catch (e) { return vaultFailure(e); }
428
852
  },
429
853
  /** B3: refresh an existing vault credential (same governed route as `quox vault update`). */
430
- async updateProviderCredential({ id, credentials, isDefault }) {
854
+ async updateProviderCredential({ id, credentials, isDefault, confirmPrivateOrigin }) {
431
855
  const ep = resolveEndpoint('vault.update', { orgId: orgId(), id });
432
856
  try {
433
- const r = await client.put(ep.path, { service: ep.service, body: { credentials, is_default: Boolean(isDefault) }, retries: 0, sensitive: true });
857
+ const r = await client.put(ep.path, { service: ep.service, body: { credentials, is_default: Boolean(isDefault), ...(confirmPrivateOrigin ? { confirm_private_origin: confirmPrivateOrigin } : {}) }, retries: 0, sensitive: true });
434
858
  return { ok: Boolean(r && (r.id || r.success || r.credential)), id, message: null };
435
- } catch (e) { return { ok: false, status: e?.status, message: e?.message || 'failed' }; }
859
+ } catch (e) { return vaultFailure(e); }
860
+ },
861
+ /** Z2H: read the instance LAN-target gate -> { ok, enabled } | { ok:false, status, message }. */
862
+ async lanTargets() {
863
+ const ep = resolveEndpoint('admin.instance.lan_targets.get');
864
+ try { const r = await client.get(ep.path, { service: ep.service, retries: 0 }); return { ok: true, enabled: Boolean(r?.enabled) }; }
865
+ catch (e) { return { ok: false, status: e?.status, message: e?.message || 'failed' }; }
866
+ },
867
+ /** Z2H: set the instance LAN-target gate (instance admin only; audited server side). */
868
+ async setLanTargets(on) {
869
+ const ep = resolveEndpoint('admin.instance.lan_targets.set');
870
+ try { const r = await client.put(ep.path, { service: ep.service, body: { enabled: Boolean(on) }, retries: 0 }); return { ok: true, enabled: Boolean(r?.enabled) }; }
871
+ catch (e) { return { ok: false, status: e?.status, message: e?.message || 'failed' }; }
436
872
  },
437
873
  /** B3: server-side probe of an Ollama URL (GET <url>/api/tags). Nothing stored. */
438
874
  async ollamaProbe(baseUrl) {
@@ -446,6 +882,12 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
446
882
  try { return await client.get(ep.path, { service: ep.service, retries: 0 }); }
447
883
  catch (e) { return { ok: false, status: e?.status, error: e?.code || null, message: e?.message || 'failed' }; }
448
884
  },
885
+ /** QUOXON-MODEL: live model list for openai | anthropic -> { ok, models:[{id,family,created}], default_model } | { ok:false, error, message }. */
886
+ async providerModels(provider, { refresh = false } = {}) {
887
+ const ep = resolveEndpoint('chat.providers.models', { provider });
888
+ try { return await client.get(ep.path, { service: ep.service, query: refresh ? { refresh: 1 } : undefined, retries: 0 }); }
889
+ catch (e) { return { ok: false, status: e?.status, error: e?.code || null, message: e?.message || 'failed' }; }
890
+ },
449
891
  /** B5: server-side probe of a self-hosted / custom OpenAI-compatible base URL (lmstudio|vllm|custom). Nothing stored. */
450
892
  async compatProbe(preset, baseUrl) {
451
893
  const ep = resolveEndpoint('chat.providers.compat.probe', { preset });
@@ -458,16 +900,53 @@ export function createApi({ client, config, auth, reattachBackoff = REATTACH_BAC
458
900
  try { return await client.get(ep.path, { service: ep.service, retries: 0 }); }
459
901
  catch (e) { return { ok: false, status: e?.status, error: e?.code || null, message: e?.message || 'failed' }; }
460
902
  },
903
+ /** /vm <verb> ...: the HYPERION Proxmox tools through POST /api/v1/tools/execute. Returns { lines, tone }. */
904
+ async vm(argv) { return runVmCommand({ client, orgId: orgId(), argv }); },
905
+ /** /hyperion: held VMs and who holds them, one line each (read only, GET /api/v1/proxmox/activity). */
906
+ async hyperion() { const data = await fetchActivity(client); return { lines: heldLines(data), tone: data.nodes_error || data.pool_error ? 'warn' : undefined }; },
461
907
  approvals: listApprovals,
908
+ /** Org-wide approvals awaiting a human (the dashboard header's server-side count, inbox stats awaiting_human). */
909
+ async approvalStats() {
910
+ const ep = resolveEndpoint('inbox.stats.list', { orgId: orgId() });
911
+ const d = await client.get(ep.path, { service: ep.service });
912
+ return { pending: Number(d?.pending_approvals ?? 0), awaiting: Number(d?.awaiting_human ?? 0) };
913
+ },
462
914
  /** GET /api/v1/approvals/:id/result -> { status, result, executed_at } ; null when unknown (404) */
463
915
  async approvalResult(approvalId) {
464
916
  const ep = resolveEndpoint('v1.approvals.result', { id: approvalId });
465
917
  try { return await client.get(ep.path, { service: ep.service, query: { org_id: orgId() || undefined } }); }
466
918
  catch (e) { if (e?.status === 404) return null; throw e; }
467
919
  },
468
- async respond(itemId, action) {
920
+ /** quox ask: answer a decision with one of its option keys (and an optional note). */
921
+ async answerDecision(itemId, option, { note = null, stepUp = null, bulk = false } = {}) {
922
+ const ep = resolveEndpoint('inbox.respond', { orgId: orgId(), itemId });
923
+ const r = await client.post(ep.path, { service: ep.service, headers: stepUp || {}, body: { action: 'answer', option, ...(bulk ? { bulk: true } : {}), ...(note ? { comment: String(note).slice(0, 4000) } : {}) } });
924
+ if (stepUp && Object.keys(stepUp).length) noteActivity('answer', { quiet: true }); // PRESENCE: answering is the owner being back
925
+ return r;
926
+ },
927
+ async respond(itemId, action, { via = null, stepUp = null, reason = null, bulk = false } = {}) {
469
928
  const ep = resolveEndpoint('inbox.respond', { orgId: orgId(), itemId });
470
- return client.post(ep.path, { service: ep.service, body: { action, comment: 'via quoxon' } });
929
+ const tag = via ? `via quoxon ${via}` : 'via quoxon';
930
+ const why = reason ? String(reason).trim().slice(0, 4000) : '';
931
+ const r = await client.post(ep.path, { service: ep.service, headers: stepUp || {}, body: { action, ...(bulk ? { bulk: true } : {}), comment: why ? `${why} (${tag})` : tag, ...(why ? { reason: why } : {}) } });
932
+ if (stepUp && Object.keys(stepUp).length) noteActivity('approval', { quiet: true }); // PRESENCE
933
+ return r;
934
+ },
935
+ /** QUOXON-STEPUP: PIN -> short-lived step-up token. The PIN goes straight into the request body; never logged, never stored. */
936
+ async stepUpVerify(pin, { orgId: org = null, scopes = ['approve'] } = {}) {
937
+ const ep = resolveEndpoint('stepup.verify');
938
+ const r = await client.post(ep.path, { service: ep.service, body: { pin, org_id: org || orgId() || undefined, scopes } });
939
+ noteActivity('pin-step-up', { quiet: true }); // PRESENCE: a PIN step-up is a live human
940
+ return r;
941
+ },
942
+ async stepUpStatus() {
943
+ const ep = resolveEndpoint('stepup.status');
944
+ return client.get(ep.path, { service: ep.service });
945
+ },
946
+ /** QUOXON-STEPUP part B: override ONE blocked action. Needs a live override-scoped step-up token. */
947
+ async override({ blockId, blockType, reason, agentId = null, action = null, stepUp = {} }) {
948
+ const ep = resolveEndpoint('safety.override');
949
+ return client.post(ep.path, { service: ep.service, headers: stepUp, body: { block_id: blockId, block_type: blockType, reason, agent_id: agentId || undefined, action: action || undefined } });
471
950
  }
472
951
  };
473
952
  }