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
@@ -0,0 +1,60 @@
1
+ /**
2
+ * quox ask helpers: parse the option list and durations, and wait (bounded long-poll) for a human's answer.
3
+ * Pure of the CLI framework so the exit codes are unit-testable. Exit codes: 0 answered, 2 expired or denied.
4
+ */
5
+
6
+ export const EXIT_ANSWERED = 0;
7
+ export const EXIT_NO_ANSWER = 2;
8
+
9
+ /** "a: Ship now|b: Wait|c: Drop" -> [{key,label}]. A bare "a|b" gives key=label. Throws with a usable message. */
10
+ export function parseOptions(text) {
11
+ const parts = String(text || '').split('|').map((s) => s.trim()).filter(Boolean);
12
+ if (parts.length < 2) throw new Error('--options needs at least two, e.g. "a: Ship now|b: Wait for review|c: Drop it" (or use --yn)');
13
+ return parts.map((p) => {
14
+ const m = /^([A-Za-z0-9]{1,8})\s*[:=]\s*(.*)$/.exec(p);
15
+ if (m) return { key: m[1].toLowerCase(), label: m[2].trim() || m[1] };
16
+ if (/^[A-Za-z0-9]{1,8}$/.test(p)) return { key: p.toLowerCase(), label: p };
17
+ throw new Error(`could not read option "${p}": write it as key: label, e.g. "a: Ship now"`);
18
+ });
19
+ }
20
+
21
+ export const YN_OPTIONS = [{ key: 'y', label: 'Yes' }, { key: 'n', label: 'No' }];
22
+
23
+ /** "90s" | "10m" | "2h" | "1d" | bare seconds -> seconds. */
24
+ export function parseDuration(text) {
25
+ const m = /^(\d+(?:\.\d+)?)\s*([smhd]?)$/i.exec(String(text || '').trim());
26
+ if (!m) throw new Error(`could not read duration "${text}": use 90s, 10m, 2h or 1d`);
27
+ const mult = { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[m[2].toLowerCase()];
28
+ return Math.round(Number(m[1]) * mult);
29
+ }
30
+
31
+ const fmtLeft = (ms) => { const m = Math.max(0, Math.round(ms / 60000)); return m >= 120 ? `${Math.round(m / 60)}h` : m >= 1 ? `${m}m` : '<1m'; };
32
+
33
+ /**
34
+ * Long-poll a decision until it is no longer pending.
35
+ * @param {{poll:(waitSeconds:number)=>Promise<object>, log:(s:string)=>void, sleep?:Function, now?:()=>number, maxWaitMs?:number|null, pollSeconds?:number}} o
36
+ * @returns {Promise<{code:number, stdout:string, decision:object|null}>} stdout = chosen option key (+ note line) only when answered.
37
+ */
38
+ export async function waitForDecision({ poll, log, now = Date.now, maxWaitMs = null, pollSeconds = 25 }) {
39
+ const start = now();
40
+ let errors = 0;
41
+ for (;;) {
42
+ let d;
43
+ try { d = (await poll(pollSeconds)).decision; errors = 0; }
44
+ catch (e) {
45
+ if (e?.status === 404 || e?.status === 403 || e?.status === 401) throw e; // not a blip: a decision we cannot see
46
+ if (++errors >= 5) throw e;
47
+ log(`could not reach the server (${e?.message || 'failed'}), retrying (${errors}/5)`);
48
+ await new Promise((r) => setTimeout(r, 2000));
49
+ continue;
50
+ }
51
+ if (!d) throw new Error('the server returned no decision');
52
+ if (d.state === 'answered') return { code: EXIT_ANSWERED, stdout: `${d.answer?.option ?? ''}${d.answer?.note ? `\n${d.answer.note}` : ''}`, decision: d };
53
+ if (d.state === 'denied') { log(`denied${d.answer?.note ? `: ${d.answer.note}` : ''}`); return { code: EXIT_NO_ANSWER, stdout: '', decision: d }; }
54
+ if (d.state === 'expired' || d.state === 'cancelled') { log(`${d.state}, no answer${d.default ? ` (default was ${d.default}; not applied)` : ''}`); return { code: EXIT_NO_ANSWER, stdout: '', decision: d }; }
55
+ const elapsed = now() - start;
56
+ const left = d.expires_at ? Date.parse(d.expires_at) - now() : null;
57
+ log(`still waiting for a human (${d.session || 'session'}, ${fmtLeft(elapsed)} elapsed${left != null ? `, ${fmtLeft(left)} left` : ''})`);
58
+ if (maxWaitMs != null && elapsed >= maxWaitMs) { log('gave up waiting (--timeout); the question is still open: quox ask wait ' + d.id); return { code: EXIT_NO_ANSWER, stdout: '', decision: d }; }
59
+ }
60
+ }
package/lib/core/auth.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Tokens stored in config profile's auth field.
6
6
  */
7
7
 
8
+ import { readFileSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
8
9
  import { QuoxAuthError, QuoxApiError, QuoxTransientAuthError } from './errors.js';
9
10
  import { loadResidentProfile, residentLogin, resolveResidentBaseUrl } from './residentAuth.js';
10
11
 
@@ -94,11 +95,79 @@ function residentProfileHint(config, deadOrgId = null) {
94
95
  }
95
96
  }
96
97
 
98
+ // QUOX_TOKEN_FILE: the W4 builder principal (SDK-TEAM design v2, 2.1.3) runs
99
+ // on ONE agent token that an owner-side helper writes to a file and rewrites
100
+ // near expiry. The builder holds no refresh token, no user session and no
101
+ // service key, so this mode is exclusive and fails CLOSED: the file's token or
102
+ // nothing, never the profile, never an ambient QUOX_SERVICE_KEY. Its "refresh"
103
+ // is re-reading the file; it never calls the auth service and never writes or
104
+ // clears the profile.
105
+ function tokenFilePath() {
106
+ return process.env.QUOX_TOKEN_FILE || null;
107
+ }
108
+
109
+ function readTokenFile(file) {
110
+ let st;
111
+ try {
112
+ st = statSync(file);
113
+ } catch {
114
+ throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is not readable. The owner-side builder helper writes it; is the lease live?`);
115
+ }
116
+ if (st.mode & 0o004) {
117
+ throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); refusing to use it. Expected 0640 or tighter.`);
118
+ }
119
+ const token = readFileSync(file, 'utf8').trim();
120
+ if (!token) throw new QuoxAuthError(`QUOX_TOKEN_FILE ${file} is empty.`);
121
+ return { access_token: token, expires_at: jwtExpiryMs(token), token_file: file };
122
+ }
123
+
124
+ // Expiry only, for the client's "looks expired, try the refresh seam" signal.
125
+ // Not verification: the server verifies. Undecodable means no local expiry.
126
+ function jwtExpiryMs(token) {
127
+ try {
128
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
129
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /** Keep the last login email on the profile (not a secret) so a signed-out session can prefill /login. */
136
+ export function rememberEmail(config, email) {
137
+ try {
138
+ const prof = config.getActiveProfile?.();
139
+ if (prof && email && prof.last_email !== email) { prof.last_email = email; config.save?.(); }
140
+ } catch { /* best-effort */ }
141
+ }
142
+
143
+ // Cross-process refresh lock (wx file next to the credentials). Returns a release function. Never throws:
144
+ // after the wait it proceeds unlocked, because a stuck lock must not sign anyone out.
145
+ async function acquireRefreshLock(file, { staleMs = 20000, waitMs = 20000 } = {}) {
146
+ const deadline = Date.now() + waitMs;
147
+ for (;;) {
148
+ try {
149
+ closeSync(openSync(file, 'wx', 0o600));
150
+ return () => { try { unlinkSync(file); } catch { /* already gone */ } };
151
+ } catch (e) {
152
+ if (e.code !== 'EEXIST') return () => {};
153
+ try {
154
+ if (Date.now() - statSync(file).mtimeMs > staleMs) { try { unlinkSync(file); } catch { /* raced */ } continue; }
155
+ } catch { continue; }
156
+ if (Date.now() > deadline) return () => {};
157
+ await new Promise(r => setTimeout(r, 25));
158
+ }
159
+ }
160
+ }
161
+
97
162
  export function createAuth(config) {
98
163
  return {
99
164
  async login(baseUrl, email, password, orgId) {
165
+ if (tokenFilePath()) {
166
+ throw new QuoxAuthError('QUOX_TOKEN_FILE is set: this principal runs on its issued token only and cannot log in as a user.');
167
+ }
100
168
  const url = `${baseUrl}/api/auth/login`;
101
- const body = { email, password };
169
+ // client: 'cli' marks this a CLI/TUI sign-in so the server gives it the longer CLI session window.
170
+ const body = { email, password, client: 'cli' };
102
171
  if (orgId) body.org_id = orgId;
103
172
 
104
173
  const res = await fetch(url, {
@@ -129,6 +198,7 @@ export function createAuth(config) {
129
198
  };
130
199
 
131
200
  config.setAuth(authData);
201
+ rememberEmail(config, email);
132
202
  return { ...data, authData };
133
203
  },
134
204
 
@@ -175,7 +245,52 @@ export function createAuth(config) {
175
245
  config.clearAuth();
176
246
  },
177
247
 
248
+ /**
249
+ * Refresh the session. Stale-sign-in safe: serialised across processes by a lock file next to the
250
+ * credentials, and it adopts credentials another window or `quox login` wrote since this process
251
+ * loaded. A fresh token on disk is reused with no server call (so two windows never spend one
252
+ * rotating refresh token twice); a rejected refresh re-reads disk and retries ONCE if it changed.
253
+ */
178
254
  async refresh(baseUrl) {
255
+ if (tokenFilePath() || typeof config.reloadAuth !== 'function' || typeof config.refreshLockFile !== 'function') {
256
+ return this._refreshOnce(baseUrl);
257
+ }
258
+ const lock = await acquireRefreshLock(config.refreshLockFile());
259
+ try {
260
+ if (config.reloadAuth() && config.getAuth()?.access_token && !this.isExpired()) {
261
+ return { access_token: config.getAuth().access_token, reused: true };
262
+ }
263
+ const sent = config.getAuth()?.refresh_token;
264
+ try {
265
+ return await this._refreshOnce(baseUrl);
266
+ } catch (e) {
267
+ if (e?.code === 'session_expired') {
268
+ config.reloadAuth();
269
+ const now = config.getAuth();
270
+ if (now?.refresh_token && now.refresh_token !== sent) {
271
+ if (!this.isExpired()) return { access_token: now.access_token, reused: true };
272
+ return await this._refreshOnce(baseUrl);
273
+ }
274
+ }
275
+ throw e;
276
+ }
277
+ } finally {
278
+ lock();
279
+ }
280
+ },
281
+
282
+ async _refreshOnce(baseUrl) {
283
+ const tokenFile = tokenFilePath();
284
+ if (tokenFile) {
285
+ const before = this._tokenFileLast;
286
+ const now = readTokenFile(tokenFile);
287
+ const stillExpired = now.expires_at && Date.now() > now.expires_at;
288
+ if (stillExpired || (before && now.access_token === before)) {
289
+ throw new QuoxAuthError(`QUOX_TOKEN_FILE ${tokenFile} has not been renewed; the owner-side helper re-mints it. Not falling back to any other credential.`);
290
+ }
291
+ this._tokenFileLast = now.access_token;
292
+ return { access_token: now.access_token };
293
+ }
179
294
  const auth = config.getAuth();
180
295
 
181
296
  // Resident agents authenticate via a signed challenge, not a refresh
@@ -199,14 +314,24 @@ export function createAuth(config) {
199
314
  // 2026-08-24: header -> no_refresh_token, body -> invalid_refresh_token.
200
315
  // The header is kept because it costs nothing and other deployments may
201
316
  // read it.
202
- const res = await fetch(`${baseUrl}/api/auth/refresh`, {
203
- method: 'POST',
204
- headers: {
205
- 'Content-Type': 'application/json',
206
- 'Authorization': `Bearer ${auth.refresh_token}`,
207
- },
208
- body: JSON.stringify({ refresh_token: auth.refresh_token }),
209
- });
317
+ // QUOXON-FW12: a timeout or network error says nothing about the token.
318
+ // It is TRANSIENT (tokens kept, caller backs off and retries), never
319
+ // "signed out". 2026-10-14 02:50: a slow auth service signed the owner out
320
+ // of a session the server still held active.
321
+ let res;
322
+ try {
323
+ res = await fetch(`${baseUrl}/api/auth/refresh`, {
324
+ method: 'POST',
325
+ headers: {
326
+ 'Content-Type': 'application/json',
327
+ 'Authorization': `Bearer ${auth.refresh_token}`,
328
+ },
329
+ body: JSON.stringify({ refresh_token: auth.refresh_token }),
330
+ signal: AbortSignal.timeout(15000),
331
+ });
332
+ } catch (e) {
333
+ throw new QuoxTransientAuthError(`Quox server is slow or unreachable (${e?.name === 'TimeoutError' ? 'refresh timed out' : (e?.cause?.code || e?.message || 'network error')}). Stored session KEPT; retrying.`);
334
+ }
210
335
 
211
336
  let data = null;
212
337
  try { data = await res.json(); } catch { /* non-JSON error body */ }
@@ -221,7 +346,7 @@ export function createAuth(config) {
221
346
  // fault into a lost session, which is exactly how the header/body bug
222
347
  // above cost three re-logins in one night before it was found.
223
348
  if ((data && data.error) === 'no_refresh_token') {
224
- throw new QuoxAuthError(
349
+ throw new QuoxTransientAuthError(
225
350
  'The auth service received no refresh token, so it could not tell whether yours is valid. '
226
351
  + 'Stored session KEPT. This is a client/transport fault, not an expiry — retry, and report it if it persists.'
227
352
  );
@@ -254,18 +379,25 @@ export function createAuth(config) {
254
379
  healthy = false;
255
380
  }
256
381
  if (!healthy) {
257
- throw new QuoxAuthError(
382
+ throw new QuoxTransientAuthError(
258
383
  `Auth service is not healthy right now (refresh returned ${res.status} but /health did not answer OK). ` +
259
384
  `Stored session KEPT — this is very likely a restart or deploy in progress. Retry in a moment.`
260
385
  );
261
386
  }
262
387
  config.clearAuth({ refresh: true });
263
- throw new QuoxAuthError(
388
+ const dead = new QuoxAuthError(
264
389
  `Session expired (refresh rejected: ${(data && data.error) || res.status}). Please login again.`
265
390
  + residentProfileHint(config, auth?.org_id),
266
391
  );
392
+ // Machine-readable, so the TUI can say WHY (idle_timeout | max_session, from the server's session policy).
393
+ dead.code = 'session_expired';
394
+ // A revoke carries no `reason`; keep the error so the card can still say why (SIGNOUT-WIPE).
395
+ dead.reason = (data && data.reason) || ((data && data.error) === 'session_revoked' ? 'session_revoked' : null);
396
+ throw dead;
267
397
  }
268
- throw new QuoxAuthError(`Token refresh failed (${res.status}); stored session kept. Retry, or login again if this persists.`);
398
+ // 5xx / 429 / anything not a definitive 401/403: the token was never judged.
399
+ const ra = Number(res.headers?.get?.('retry-after'));
400
+ throw new QuoxTransientAuthError(`Token refresh failed (${res.status}); stored session kept. Retry, or login again if this persists.`, Number.isFinite(ra) && ra > 0 ? ra * 1000 : null);
269
401
  }
270
402
 
271
403
  auth.access_token = data.access_token;
@@ -279,10 +411,20 @@ export function createAuth(config) {
279
411
  },
280
412
 
281
413
  getTokens() {
414
+ const tokenFile = tokenFilePath();
415
+ if (tokenFile) {
416
+ const t = readTokenFile(tokenFile);
417
+ this._tokenFileLast = t.access_token;
418
+ return t;
419
+ }
282
420
  return config.getAuth();
283
421
  },
284
422
 
285
423
  isLoggedIn() {
424
+ const tokenFile = tokenFilePath();
425
+ if (tokenFile) {
426
+ try { readTokenFile(tokenFile); return true; } catch { return false; }
427
+ }
286
428
  // Precedence, matching client.js: a stored USER token is what we
287
429
  // actually authenticate with, so it decides this answer. A configured
288
430
  // QUOX_SERVICE_KEY still counts as authenticated on its own, but only
@@ -300,6 +442,15 @@ export function createAuth(config) {
300
442
  },
301
443
 
302
444
  isExpired() {
445
+ const tokenFile = tokenFilePath();
446
+ if (tokenFile) {
447
+ try {
448
+ const { expires_at } = readTokenFile(tokenFile);
449
+ return expires_at ? Date.now() > expires_at : false;
450
+ } catch {
451
+ return true;
452
+ }
453
+ }
303
454
  // A forced service key is not a locally-tracked JWT: never "expired".
304
455
  if (process.env.QUOX_FORCE_SERVICE_KEY && process.env.QUOX_SERVICE_KEY) return false;
305
456
  // A stored user token is the credential in play (see isLoggedIn), so
@@ -22,7 +22,7 @@ import { join, basename } from 'node:path';
22
22
  export const APPROVAL_GATED_TOOLS = new Set([
23
23
  'loop_halt', 'slot_assign', 'bob_dispatch_build', 'email_send', 'send_notification',
24
24
  'remote_ssh', 'ssh_exec', 'db_query', 'alerts_manage', 'backup_run',
25
- 'keeper_backup_create', 'deploy_template', 'gemini_generate', 'perplexity_research',
25
+ 'keeper_backup_create', 'deploy_template',
26
26
  'system_admin', 'proxmox_vm_action', 'calendar_event_create',
27
27
  ]);
28
28
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * KEEPER-REEXEC: which quox build is installed, read from files only (cheap enough to check on
3
+ * every keeper control poll, no git or child process). A git checkout (the dev box runs quox from
4
+ * a detached worktree) reports its HEAD sha; an npm install reports `v<version>`. null = unknown,
5
+ * and an unknown build never triggers a re-exec or a takeover.
6
+ */
7
+ import { readFileSync, existsSync, statSync } from 'node:fs';
8
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const SHA_RE = /^[0-9a-f]{40,64}$/;
12
+ const read = (p) => { try { return readFileSync(p, 'utf-8').trim(); } catch { return null; } };
13
+
14
+ function gitDirFor(root) {
15
+ const dotGit = join(root, '.git');
16
+ if (!existsSync(dotGit)) return null;
17
+ try {
18
+ if (statSync(dotGit).isDirectory()) return dotGit;
19
+ } catch { return null; }
20
+ const m = /^gitdir:\s*(.+)$/m.exec(read(dotGit) || '');
21
+ if (!m) return null;
22
+ return isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
23
+ }
24
+
25
+ function resolveRef(gitDir, ref) {
26
+ const loose = read(join(gitDir, ref));
27
+ if (loose && SHA_RE.test(loose)) return loose;
28
+ const commonRel = read(join(gitDir, 'commondir'));
29
+ const common = commonRel ? (isAbsolute(commonRel) ? commonRel : resolve(gitDir, commonRel)) : null;
30
+ if (common) {
31
+ const c = read(join(common, ref));
32
+ if (c && SHA_RE.test(c)) return c;
33
+ }
34
+ for (const d of [gitDir, common].filter(Boolean)) {
35
+ const packed = read(join(d, 'packed-refs')) || '';
36
+ for (const line of packed.split('\n')) {
37
+ const [sha, name] = line.trim().split(/\s+/);
38
+ if (name === ref && SHA_RE.test(sha)) return sha;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** cliDir = the CLI package dir (holds package.json); its parent may be the git root. */
45
+ export function readInstalledBuild(cliDir) {
46
+ try {
47
+ for (const root of [cliDir, dirname(cliDir)]) {
48
+ const gd = gitDirFor(root);
49
+ if (!gd) continue;
50
+ const head = read(join(gd, 'HEAD'));
51
+ if (!head) continue;
52
+ if (SHA_RE.test(head)) return head;
53
+ const m = /^ref:\s*(\S+)$/.exec(head);
54
+ if (m) { const sha = resolveRef(gd, m[1]); if (sha) return sha; }
55
+ }
56
+ const pkg = JSON.parse(readFileSync(join(cliDir, 'package.json'), 'utf-8'));
57
+ return pkg && pkg.version ? `v${pkg.version}` : null;
58
+ } catch { return null; }
59
+ }
60
+
61
+ /** The CLI package this module was loaded from (cli/lib/core -> cli). */
62
+ export const CLI_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');