quoxcli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * quox notes seal init|status|new|open|edit|print: ENQRYPT sealed notes (ENQRYPT stream E4, enqrypt ADR-0001/0002/0003).
3
+ *
4
+ * Sealing and opening happen HERE, on the client, with the owner's ENQRYPT identity (passphrase-protected key
5
+ * file, default ~/.quox/enqrypt). NotesQ only ever receives the sealed envelope: the server, the collector and
6
+ * agents cannot read a sealed note. Titles, collections and tags are NOT sealed (they stay searchable).
7
+ * ENQRYPT is an adapter dependency, loaded on demand; core Quox does not need it.
8
+ */
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
10
+ import { homedir } from 'os';
11
+ import { join, resolve } from 'path';
12
+ import { randomUUID } from 'crypto';
13
+ import { pathToFileURL } from 'url';
14
+ import { QuoxError } from '../core/errors.js';
15
+ import { resolveEndpoint } from '../api/endpoints.js';
16
+
17
+ export const SEAL_PURPOSE = 'notesq.note';
18
+ export const SEAL_MARK = 'enqrypt-sealed-note';
19
+ const FENCE = 'enqrypt-envelope';
20
+ const FP_RE = /^[0-9a-f]{64}$/;
21
+ const B64U_RE = /^[A-Za-z0-9_-]+$/;
22
+
23
+ export const keystoreDir = (opt) => resolve(opt || join(homedir(), '.quox', 'enqrypt'));
24
+
25
+ /** Markdown NotesQ stores for a sealed note: a heading, a plain explanation, and the envelope. Nothing secret. */
26
+ export function sealedNoteMarkdown({ title, envelope, fingerprint }) {
27
+ const b64 = Buffer.from(envelope).toString('base64url');
28
+ const wrapped = b64.match(/.{1,76}/g).join('\n');
29
+ return `# ${title}\n\n🔐 Sealed with ENQRYPT (HPKE, RFC 9180). The server stores only this envelope and cannot read it.\n`
30
+ + `Open it on a device holding the key with fingerprint \`${fingerprint.slice(0, 16)}…\`: \`quox notes seal open <id>\`\n\n`
31
+ + '```' + FENCE + '\n' + wrapped + '\n```\n';
32
+ }
33
+
34
+ export function extractEnvelope(markdown) {
35
+ const blocks = [...String(markdown || '').matchAll(new RegExp('```' + FENCE + '\\n([\\s\\S]*?)\\n```', 'g'))];
36
+ if (blocks.length !== 1) throw new QuoxError(blocks.length ? 'This note has more than one ENQRYPT envelope; refusing to guess.' : 'This note holds no ENQRYPT envelope.', 1);
37
+ const b64 = blocks[0][1].replace(/\n/g, '');
38
+ if (!B64U_RE.test(b64)) throw new QuoxError('The ENQRYPT envelope in this note is damaged.', 1);
39
+ return new Uint8Array(Buffer.from(b64, 'base64url'));
40
+ }
41
+
42
+ export function sealProperties({ fingerprint, objectId, version, collection, tags }) {
43
+ const p = { enqrypt: SEAL_MARK, enqrypt_fp: fingerprint, enqrypt_object: objectId, enqrypt_version: version };
44
+ if (collection) p.collection = collection;
45
+ if (tags) p.tags = tags;
46
+ return p;
47
+ }
48
+
49
+ export function readSealProperties(props) {
50
+ const p = props || {};
51
+ const version = Number(p.enqrypt_version);
52
+ if (p.enqrypt !== SEAL_MARK) throw new QuoxError('This note is not an ENQRYPT sealed note.', 1);
53
+ if (!FP_RE.test(String(p.enqrypt_fp)) || !/^[0-9a-f-]{36}$/.test(String(p.enqrypt_object)) || !Number.isSafeInteger(version) || version < 1) {
54
+ throw new QuoxError('This sealed note has damaged ENQRYPT properties.', 1);
55
+ }
56
+ return { fingerprint: p.enqrypt_fp, objectId: p.enqrypt_object, version };
57
+ }
58
+
59
+ // Rollback floor: the highest version this client has opened per sealed object. Older envelopes are refused.
60
+ const floorFile = (dir) => join(dir, 'notes-floor.json');
61
+ export function readFloor(dir, objectId) {
62
+ try { return Number(JSON.parse(readFileSync(floorFile(dir), 'utf8'))[objectId]) || 0; } catch { return 0; }
63
+ }
64
+ export function bumpFloor(dir, objectId, version) {
65
+ let all = {};
66
+ try { all = JSON.parse(readFileSync(floorFile(dir), 'utf8')); } catch { /* first use */ }
67
+ if (!(Number(all[objectId]) >= version)) all[objectId] = version;
68
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
69
+ writeFileSync(floorFile(dir), JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
70
+ }
71
+
72
+ /** A passphrase file must be private to its owner; anything looser is refused rather than trusted. */
73
+ export function readPassphraseFile(path) {
74
+ const st = statSync(path);
75
+ if ((st.mode & 0o077) !== 0) throw new QuoxError(`Refusing ${path}: passphrase files must be chmod 600.`, 2);
76
+ return readFileSync(path, 'utf8').replace(/\r?\n$/, '');
77
+ }
78
+
79
+ async function defaultLoadEnqrypt() {
80
+ const base = process.env.QUOX_ENQRYPT_PATH;
81
+ const at = (sub) => (base ? pathToFileURL(join(base, 'src', `${sub}.mjs`)).href : `@quoxai/enqrypt-script/${sub}`);
82
+ try {
83
+ const [seal, keystore, armor] = await Promise.all([import(at('seal')), import(at('keystore')), import(at('armor'))]);
84
+ return { ...seal, ...keystore, ...armor };
85
+ } catch {
86
+ throw new QuoxError('ENQRYPT is not available on this machine. It is not published yet: set QUOX_ENQRYPT_PATH to a checkout of quoxai/enqrypt (never `npm install enqrypt`, that name is an unrelated malicious package).', 1);
87
+ }
88
+ }
89
+
90
+ async function askPassphrase(message, confirm = false) {
91
+ if (!process.stdin.isTTY) throw new QuoxError('No terminal to ask for the passphrase: pass --passphrase-file <chmod-600 file>.', 2);
92
+ const { default: inquirer } = await import('inquirer');
93
+ const { p } = await inquirer.prompt([{ type: 'password', name: 'p', message, mask: '*' }]);
94
+ if (confirm) {
95
+ const { q } = await inquirer.prompt([{ type: 'password', name: 'q', message: 'Repeat the passphrase', mask: '*' }]);
96
+ if (p !== q) throw new QuoxError('The passphrases did not match. Nothing was created.', 2);
97
+ }
98
+ return p;
99
+ }
100
+
101
+ export function registerNotesSeal(notes, ctx) {
102
+ const { program, client, auth, call, getNote, putNote, readContent, isJson, printJson, announce } = ctx;
103
+ const loadEnqrypt = ctx.loadEnqrypt || defaultLoadEnqrypt;
104
+ const passphrase = (options, msg, confirm) => (options.passphraseFile ? readPassphraseFile(options.passphraseFile) : askPassphrase(msg, confirm));
105
+ const mapErr = (e) => {
106
+ if (e && (e.name === 'EnqryptError' || e.name === 'KeystoreError')) return new QuoxError(`ENQRYPT refused: ${e.code} (${e.message}). Nothing was shown.`, 1);
107
+ return e;
108
+ };
109
+ const unlock = async (E, options) => {
110
+ try { return await E.unlockKeystore({ dir: keystoreDir(options.keystore), passphrase: await passphrase(options, 'ENQRYPT passphrase') }); }
111
+ catch (e) { throw mapErr(e); }
112
+ };
113
+
114
+ const seal = notes.command('seal').description('ENQRYPT sealed notes: encrypted on this device, the server stores only the envelope (prototype, security review pending)');
115
+ const common = (cmd) => cmd
116
+ .option('--keystore <dir>', 'ENQRYPT keystore directory (default ~/.quox/enqrypt)')
117
+ .option('--passphrase-file <path>', 'read the passphrase from a chmod-600 file instead of asking');
118
+
119
+ common(seal.command('init').description('Create your ENQRYPT key (passphrase-protected) and show the one-time recovery key'))
120
+ .action(async (options) => {
121
+ const E = await loadEnqrypt();
122
+ const dir = keystoreDir(options.keystore);
123
+ if (existsSync(join(dir, 'identity.age'))) throw new QuoxError(`An ENQRYPT key already exists in ${dir}. Nothing was changed.`, 1);
124
+ console.log('If you lose BOTH this passphrase and the recovery key shown next, your sealed notes cannot be recovered by anyone.');
125
+ let out;
126
+ try { out = await E.createKeystore({ dir, passphrase: await passphrase(options, 'New ENQRYPT passphrase (10+ characters)', true) }); }
127
+ catch (e) { throw mapErr(e); }
128
+ if (isJson(program)) return printJson({ dir, fingerprint: out.fingerprint, recovery_key: out.recoveryKey });
129
+ console.log(`\nKey created in ${dir}\nFingerprint: ${out.fingerprint}\n`);
130
+ console.log('RECOVERY KEY (shown once; store it offline, it opens every sealed note):');
131
+ console.log(` ${out.recoveryKey}\n`);
132
+ announce('seal init', `key ${out.fingerprint.slice(0, 16)}…`);
133
+ });
134
+
135
+ common(seal.command('status').description('Show whether this device has an ENQRYPT key, and its fingerprint'))
136
+ .action(async (options) => {
137
+ const dir = keystoreDir(options.keystore);
138
+ let E;
139
+ try { E = await loadEnqrypt(); } catch (e) {
140
+ // status must report, not fail, when the seal backend is absent (the other verbs still refuse).
141
+ const reason = e && e.message ? e.message : 'ENQRYPT could not be loaded.';
142
+ if (isJson(program)) return printJson({ dir, backend: 'unavailable', fingerprint: null, reason });
143
+ return console.log(`ENQRYPT backend: not available on this machine, so no key can be read from ${dir}.\n${reason}`);
144
+ }
145
+ try {
146
+ const pub = await E.readPublicIdentity({ dir });
147
+ if (isJson(program)) return printJson({ dir, fingerprint: pub.fingerprint });
148
+ console.log(`ENQRYPT key in ${dir}\nFingerprint: ${pub.fingerprint}`);
149
+ } catch (e) {
150
+ if (e && e.code === 'MISSING') return console.log(`No ENQRYPT key in ${dir}. Create one: quox notes seal init`);
151
+ throw mapErr(e);
152
+ }
153
+ });
154
+
155
+ common(seal.command('new').description('Create a sealed note from --file or --stdin. The title is NOT sealed.'))
156
+ .option('--scope <scope>', 'user | org (default user)', 'user')
157
+ .option('--title <t>', 'visible title (not sealed)', 'Sealed note')
158
+ .option('--collection <c>', 'collection (not sealed)')
159
+ .option('--tag <t>', 'tag (repeatable, not sealed)', (v, prev) => (prev || []).concat(v))
160
+ .option('--file <path>', 'read the secret text from a file')
161
+ .option('--stdin', 'read the secret text from stdin')
162
+ .action(async (options) => {
163
+ auth.requireAuth();
164
+ if (!['user', 'org'].includes(options.scope)) throw new QuoxError('--scope must be user or org.', 2);
165
+ const text = await readContent(options);
166
+ if (text === null) throw new QuoxError('seal new needs --file <path> or --stdin (the secret is never taken from a flag).', 2);
167
+ const E = await loadEnqrypt();
168
+ const me = await unlock(E, options);
169
+ const fingerprint = await E.fingerprint(me.publicKey);
170
+ const objectId = randomUUID();
171
+ let envelope;
172
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, seq: 1, plaintext: new TextEncoder().encode(text) }); }
173
+ catch (e) { throw mapErr(e); }
174
+ const ep = resolveEndpoint('notesq.notes.create');
175
+ const body = { scope: options.scope, markdown: sealedNoteMarkdown({ title: options.title, envelope, fingerprint }), properties: sealProperties({ fingerprint, objectId, version: 1, collection: options.collection, tags: options.tag }) };
176
+ const data = await call(() => client.post(ep.path, { service: ep.service, body }));
177
+ bumpFloor(keystoreDir(options.keystore), objectId, 1);
178
+ if (isJson(program)) return printJson({ id: data.note?.id, object: objectId, version: 1, fingerprint });
179
+ console.log(`Sealed note ${data.note?.id} created (${text.length} characters sealed; the title "${options.title}" is visible).`);
180
+ announce('seal new', `sealed note ${data.note?.id}`);
181
+ });
182
+
183
+ common(seal.command('print').description('Print a sealed note as a plain ASCII armour block (ciphertext only, safe to print or file; no passphrase needed)'))
184
+ .argument('<id>', 'note id')
185
+ .action(async (id) => {
186
+ auth.requireAuth();
187
+ const n = await getNote(id);
188
+ const props = readSealProperties(n.properties);
189
+ const envelope = extractEnvelope(n.markdown ?? n.body);
190
+ const E = await loadEnqrypt();
191
+ const armor = await E.toAsciiArmor(envelope, { Version: '1', Key: props.fingerprint.slice(0, 16), Object: props.objectId, Note: String(id).slice(0, 64) });
192
+ if (isJson(program)) return printJson({ id, armor });
193
+ process.stdout.write(armor);
194
+ });
195
+
196
+ common(seal.command('open').description('Open a sealed note on this device and print it (by note id, or offline from a printed armour block)'))
197
+ .argument('[id]', 'note id (omit with --armor-file)')
198
+ .option('--armor-file <path>', 'open a printed/saved ASCII armour block instead of fetching the note ("-" = stdin)')
199
+ .action(async (id, options) => {
200
+ let envelope, objectId, expectFp = null, label = id;
201
+ const E = await loadEnqrypt();
202
+ if (options.armorFile) {
203
+ if (id) throw new QuoxError('Give a note id or --armor-file, not both.', 2);
204
+ const text = options.armorFile === '-' ? await readContent({ stdin: true }) : readFileSync(options.armorFile, 'utf8');
205
+ let parsed;
206
+ try { parsed = await E.fromAsciiArmor(text); } catch (e) { throw new QuoxError(`That armour block was refused: ${e.message}. Nothing was opened.`, 1); }
207
+ envelope = parsed.envelope;
208
+ objectId = parsed.headers.Object;
209
+ if (!/^[0-9a-f-]{36}$/.test(String(objectId))) throw new QuoxError('That armour block has no valid Object header; it cannot be bound to a note.', 1);
210
+ label = parsed.headers.Note || 'armour';
211
+ } else {
212
+ if (!id) throw new QuoxError('seal open needs a note id or --armor-file <path>.', 2);
213
+ auth.requireAuth();
214
+ const n = await getNote(id);
215
+ const props = readSealProperties(n.properties);
216
+ envelope = extractEnvelope(n.markdown ?? n.body);
217
+ objectId = props.objectId;
218
+ expectFp = props.fingerprint;
219
+ }
220
+ const me = await unlock(E, options);
221
+ const fingerprint = await E.fingerprint(me.publicKey);
222
+ if (expectFp && fingerprint !== expectFp) throw new QuoxError(`This note was sealed to key ${expectFp.slice(0, 16)}…, not this device's key ${fingerprint.slice(0, 16)}….`, 1);
223
+ const dir = keystoreDir(options.keystore);
224
+ let plain;
225
+ try { plain = await E.open({ recipient: me, senderPublicKey: me.publicKey, senderFingerprint: fingerprint, envelope, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId, minVersion: readFloor(dir, objectId) }); }
226
+ catch (e) { throw mapErr(e); }
227
+ const v = E.parseEnvelope(envelope).seq;
228
+ bumpFloor(dir, objectId, v);
229
+ const text = new TextDecoder().decode(plain);
230
+ if (isJson(program)) return printJson({ id: label, version: v, text });
231
+ process.stdout.write(text.endsWith('\n') ? text : text + '\n');
232
+ });
233
+
234
+ common(seal.command('edit').description('Replace a sealed note\'s secret text (--file or --stdin); writes a new sealed version'))
235
+ .argument('<id>', 'note id')
236
+ .option('--file <path>', 'read the new secret text from a file')
237
+ .option('--stdin', 'read the new secret text from stdin')
238
+ .action(async (id, options) => {
239
+ auth.requireAuth();
240
+ const text = await readContent(options);
241
+ if (text === null) throw new QuoxError('seal edit needs --file <path> or --stdin.', 2);
242
+ const cur = await getNote(id);
243
+ const props = readSealProperties(cur.properties);
244
+ const E = await loadEnqrypt();
245
+ const me = await unlock(E, options);
246
+ const fingerprint = await E.fingerprint(me.publicKey);
247
+ if (fingerprint !== props.fingerprint) throw new QuoxError('This note was sealed to a different key; refusing to re-seal it.', 1);
248
+ const version = props.version + 1;
249
+ let envelope;
250
+ try { envelope = await E.seal({ sender: me, recipientPublicKey: me.publicKey, recipientFingerprint: fingerprint, op: E.OP.SEALED_OBJECT, purpose: SEAL_PURPOSE, objectId: props.objectId, seq: version, plaintext: new TextEncoder().encode(text) }); }
251
+ catch (e) { throw mapErr(e); }
252
+ const title = String(cur.title || 'Sealed note');
253
+ const data = await putNote(id, { markdown: sealedNoteMarkdown({ title, envelope, fingerprint }), properties: { ...cur.properties, enqrypt_version: version }, base_hash: cur.content_hash });
254
+ bumpFloor(keystoreDir(options.keystore), props.objectId, version);
255
+ if (isJson(program)) return printJson({ id, version, revision: data.revision?.rev });
256
+ console.log(`Sealed note ${id} updated to sealed version ${version}. Earlier revisions hold earlier envelopes, never plaintext.`);
257
+ announce('seal edit', `sealed note ${id} v${version}`);
258
+ });
259
+ }
@@ -12,9 +12,11 @@
12
12
  * live loop gate: budget, risk, interval).
13
13
  */
14
14
 
15
- import { formatOutput, formatDetail, printSuccess } from '../core/output.js';
15
+ import { withStepUp } from '../core/stepUpPrompt.js';
16
+ import { formatOutput, formatDetail, printSuccess, printWarning } from '../core/output.js';
16
17
  import { parseJSON } from './helpers.js';
17
18
  import { resolveEndpoint } from '../api/endpoints.js';
19
+ import { QuoxUserError } from '../core/errors.js';
18
20
 
19
21
  /**
20
22
  * Format a timestamp as a short relative-time string ("5m ago").
@@ -75,6 +77,13 @@ export function registerObjectiveCommands(program, ctx) {
75
77
  .option('--loop-backend <backend>', 'claude|codex, loop mode only (default claude)')
76
78
  .option('--capability-scope <tools>', 'comma-separated tool names the loop may call (repeatable); empty scope escalates every tool', collectScope)
77
79
  .option('--system-prompt <text>', 'per-objective system prompt, carried verbatim to the run')
80
+ .option('--observe-every <n>', 'loop mode: emit a status line every N turns while it runs (watch with `quox loop watch <id>`)')
81
+ .option('--observe-milestones', 'loop mode: lift MILESTONE: lines from the loop output into headline updates')
82
+ .option('--until <text>', 'loop mode: the done criterion in plain words. The loop runs turn after turn until the model ends a turn with "DONE: <evidence>" (or --max-iterations / --max-duration-ms stops it)')
83
+ .option('--every-seconds <n>', 'loop mode: the runner waits N seconds (30-3600) between turns; the model is never asked to sleep')
84
+ .option('--workspace <name>', 'loop mode: name the durable workspace dir, so a follow-up loop can work in the same files (default: one per loop)')
85
+ .option('--done-file <path>', 'loop mode: DONE only counts once this file exists in the loop workspace; the runner checks it itself (repeatable)', (v, prev = []) => prev.concat(v))
86
+ .option('--done-command <cmd>', 'loop mode: DONE only counts once this command exits 0 in the loop workspace; must be one the loop scope lets it run (repeatable)', (v, prev = []) => prev.concat(v))
78
87
  .action(async (goal, options) => {
79
88
  auth.requireAuth();
80
89
  const body = { title: goal };
@@ -98,6 +107,28 @@ export function registerObjectiveCommands(program, ctx) {
98
107
  if (scope.length > 0) body.capability_scope = scope;
99
108
  }
100
109
  if (options.systemPrompt) body.system_prompt = options.systemPrompt;
110
+ if (options.until) body.until = options.until;
111
+ if (options.everySeconds !== undefined) {
112
+ const n = Number(options.everySeconds);
113
+ if (!Number.isInteger(n) || n < 30 || n > 3600) throw new QuoxUserError('--every-seconds needs a whole number of seconds between 30 and 3600.');
114
+ body.every_seconds = n;
115
+ }
116
+ if (options.workspace) body.workspace = options.workspace;
117
+ const doneChecks = [
118
+ ...(options.doneFile || []).map((path) => ({ kind: 'file_exists', path })),
119
+ ...(options.doneCommand || []).map((command) => ({ kind: 'command_ok', command })),
120
+ ];
121
+ if (doneChecks.length) body.done_check = doneChecks;
122
+ if (options.observeEvery !== undefined || options.observeMilestones) {
123
+ const observe = {};
124
+ if (options.observeEvery !== undefined) {
125
+ const n = Number(options.observeEvery);
126
+ if (!Number.isInteger(n) || n < 1) throw new QuoxUserError('--observe-every needs a whole number of turns (1 or more).');
127
+ observe.every_turns = n;
128
+ }
129
+ if (options.observeMilestones) observe.milestones = true;
130
+ body.observe = observe;
131
+ }
101
132
 
102
133
  const data = await client.post(resolveEndpoint('objectives.create').path, { service: resolveEndpoint('objectives.create').service, body });
103
134
  printSuccess(`Objective created: ${data.id}`);
@@ -151,10 +182,22 @@ export function registerObjectiveCommands(program, ctx) {
151
182
  .argument('<id>', 'objective ID')
152
183
  .action(async (id) => {
153
184
  auth.requireAuth();
154
- const data = await client.post(resolveEndpoint('objectives.approve-plan', { id }).path, { service: resolveEndpoint('objectives.approve-plan', { id }).service });
185
+ const planEp = resolveEndpoint('objectives.approve-plan', { id });
186
+ const data = await withStepUp((headers) => client.post(planEp.path, { service: planEp.service, headers }), { client, orgId: auth.getOrgId?.(program.opts().org) || null, io: ctx.stepUpIO, what: 'Approving this plan' });
155
187
  printSuccess(`Objective ${id}: ${data.status}${data.message ? ` — ${data.message}` : ''}`);
156
188
  });
157
189
 
190
+ objective
191
+ .command('retry')
192
+ .description('Retry a failed or partially completed objective: failed and skipped steps are reset and the plan comes back for approval')
193
+ .argument('<id>', 'objective ID')
194
+ .action(async (id) => {
195
+ auth.requireAuth();
196
+ const ep = resolveEndpoint('objectives.retry', { id });
197
+ const data = await client.post(ep.path, { service: ep.service });
198
+ printSuccess(`Objective ${id}: ${data.steps_reset ?? 0} step(s) reset, plan is back for approval. Run \`quox objective approve ${id}\` to run them again.`);
199
+ });
200
+
158
201
  objective
159
202
  .command('checkpoints')
160
203
  .description('List pending HITL checkpoints across the org (approval queue)')
@@ -190,7 +233,14 @@ export function registerObjectiveCommands(program, ctx) {
190
233
  const body = {};
191
234
  if (options.notes) body.notes = options.notes;
192
235
  const params = { id: objectiveId, checkpointId };
193
- const data = await client.post(resolveEndpoint('objectives.checkpoint-approve', params).path, { service: resolveEndpoint('objectives.checkpoint-approve', params).service, body });
236
+ const cpEp = resolveEndpoint('objectives.checkpoint-approve', params);
237
+ const data = await withStepUp((headers) => client.post(cpEp.path, { service: cpEp.service, headers, body }), { client, orgId: auth.getOrgId?.(program.opts().org) || null, io: ctx.stepUpIO, what: 'Approving this checkpoint' });
238
+ // LOOPQ-HALT: "approved" is not "running". When the server says the loop did not start, say so in words.
239
+ if (data.started === false) {
240
+ printWarning(`Checkpoint ${checkpointId}: ${data.status}, but the objective did NOT start (objective ${data.objective_status || 'pending'}).`);
241
+ if (data.hint) printWarning(data.hint);
242
+ return;
243
+ }
194
244
  printSuccess(`Checkpoint ${checkpointId}: ${data.status}${data.objective_status ? ` (objective ${data.objective_status})` : ''}`);
195
245
  });
196
246
 
@@ -9,10 +9,9 @@
9
9
  * (same as `quox vm pool lease`) with a dedicated key at ~/.config/quox/offload_ed25519.
10
10
  */
11
11
 
12
- import path from 'node:path';
13
12
  import { printInfo } from '../core/output.js';
14
13
  import { makePoolRest } from '../core/vm.js';
15
- import { OffloadError, realDeps, runOffload, releaseWorker, workerStatus, gitToplevel, WORKER_NAME_RE } from '../core/offload.js';
14
+ import { OffloadError, realDeps, runOffload, releaseWorker, workerStatus, resolveSyncDir, WORKER_NAME_RE } from '../core/offload.js';
16
15
 
17
16
  export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
18
17
  const { client, auth } = ctx;
@@ -45,8 +44,8 @@ export function registerOffloadCommands(program, ctx, { deps: injected } = {}) {
45
44
  .action(async (command, options) => {
46
45
  auth.requireAuth();
47
46
  const cwd = process.cwd();
48
- const dir = path.resolve(options.dir || gitToplevel(cwd));
49
47
  try {
48
+ const dir = resolveSyncDir(cwd, options.dir);
50
49
  const code = await runOffload(mk(options), {
51
50
  dir, cwd, command, fresh: !!options.fresh, docker: !!options.docker,
52
51
  cores: options.cores, memoryGb: options.memoryGb, diskGb: options.diskGb,
@@ -7,6 +7,7 @@ import { formatOutput, formatDetail, printSuccess, printWarning } from '../core/
7
7
  import { otherProfileUsers, listProcs } from '../core/profileUsers.js';
8
8
  import { resolveOrgId } from './helpers.js';
9
9
  import { resolveEndpoint } from '../api/endpoints.js';
10
+ import { numberedOrgLines, pickOrgByInput, sharedSwitchHint } from '../quoxon/orgSwitch.js';
10
11
 
11
12
  export function registerOrgCommands(program, ctx) {
12
13
  const { client, auth, config } = ctx;
@@ -90,9 +91,26 @@ export function registerOrgCommands(program, ctx) {
90
91
  org
91
92
  .command('switch')
92
93
  .description('Switch active organization context')
93
- .argument('<orgId>', 'organization ID or slug')
94
- .action(async (orgId) => {
94
+ .argument('[orgId]', 'organization ID, slug, or the number from the list (omit to pick from a numbered list)')
95
+ .action(async (orgIdArg) => {
95
96
  auth.requireAuth();
97
+ let orgId = orgIdArg;
98
+ if (!orgId || /^\d+$/.test(orgId)) {
99
+ const l = await client.get(resolveEndpoint('orgs.list').path, { service: resolveEndpoint('orgs.list').service });
100
+ const orgs = Array.isArray(l) ? l : (l?.organizations || l?.orgs || l?.data || []);
101
+ if (!orgs.length) throw new QuoxError('No organisations returned for this login.', 1);
102
+ if (!orgId) {
103
+ const curId = (() => { try { return auth.getOrgId(); } catch { return null; } })();
104
+ for (const line of numberedOrgLines(orgs, curId)) console.log(line);
105
+ if (!process.stdin.isTTY) throw new QuoxError('Pass a number, slug or ID: quox org switch <number>.', 1);
106
+ const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout });
107
+ try { orgId = await rl.question('Switch to which? (number, slug, or Enter to cancel) '); } finally { rl.close(); }
108
+ if (!orgId.trim()) { console.log('Not switched.'); return; }
109
+ }
110
+ const pick = pickOrgByInput(orgId, orgs);
111
+ if (pick.org) orgId = pick.org.id;
112
+ else if (/^\d+$/.test(orgId)) throw new QuoxError(`No org numbered ${orgId}. Run \`quox org switch\` to see the list.`, 1);
113
+ }
96
114
  // The server matches memberships by org ID only. Accept a slug (or exact name) by
97
115
  // resolving it against the caller's org list first; a 26-char ULID passes through.
98
116
  let targetId = orgId;
@@ -126,8 +144,7 @@ export function registerOrgCommands(program, ctx) {
126
144
  const name = config.getActiveProfileName?.();
127
145
  const others = name ? otherProfileUsers({ profile: name, defaultProfile: config._data?.activeProfile, procs: listProcs(), selfPids: [process.pid, process.ppid] }) : [];
128
146
  if (others.length) {
129
- printWarning(`${others.length} other quox session${others.length === 1 ? '' : 's'} use profile "${name}" (pid ${others.map(p => p.pid).join(', ')}). `
130
- + 'They follow this org on their next token refresh or restart. To move one window only, use /org switch inside it.');
147
+ printWarning(sharedSwitchHint({ name, pids: others.map(p => p.pid) }));
131
148
  }
132
149
  } catch { /* best effort: never fail the switch over the warning */ }
133
150
  });
@@ -207,6 +207,22 @@ function renderStreamList(payload, { lens = 'tier' } = {}) {
207
207
  return lines;
208
208
  }
209
209
 
210
+ /** Honest count of what was printed, plus a one-line glyph legend (the list itself prints glyphs only). */
211
+ function renderListFooter(payload, lens) {
212
+ const streams = (payload && payload.streams) || [];
213
+ if (streams.length === 0) return [];
214
+ const tiers = lens === 'tier' ? streams : null;
215
+ const top = tiers ? tiers.flatMap((g) => g.streams || []) : streams;
216
+ const subs = top.reduce((n, s) => n + (s.substreams || []).length, 0);
217
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
218
+ const count = `${plural(top.length, 'stream')}${subs ? ` + ${plural(subs, 'substream')}` : ''}${tiers ? ` in ${plural(tiers.length, 'tier')}` : ''}`;
219
+ return [
220
+ '',
221
+ chalk.dim(` ${count}`),
222
+ chalk.dim(' ◇ not started ⬗ in progress ◈ decision needed ◆ blocked (red) or complete (dim) └ substream'),
223
+ ];
224
+ }
225
+
210
226
  // ── render: stream detail ───────────────────────────────────────────────
211
227
 
212
228
  function renderStreamDetail(detail) {
@@ -601,6 +617,7 @@ export function registerPlanCommands(program, ctx) {
601
617
  if (isJsonOutput(program)) return printJson(data);
602
618
  console.log(`\n ${roadmapId} (lens: ${options.lens})`);
603
619
  console.log(renderStreamList(data, { lens: options.lens }).join('\n'));
620
+ console.log(renderListFooter(data, options.lens).join('\n'));
604
621
  });
605
622
 
606
623
  // ── focus ────────────────────────────────────────────────────────────
@@ -17,6 +17,9 @@ import { createApi, isInteractiveHuman } from '../quoxon/api.js';
17
17
  import { PROVIDERS, providerFor, looksLikeKey, connectionLines, connectFlow, connectOllamaFlow, connectCompatFlow } from '../quoxon/connect.js';
18
18
  import { needsConsent, consentCovers, connectConsentNote, providerLabel } from '../quoxon/consent.js';
19
19
  import { readStdin } from '../quoxon/print.js';
20
+ import { INSTALL_TOOLS, toolFor, planLine, offerQuestion, runInstall, isAgentCaller } from '../quoxon/installCli.js';
21
+ import { YN_OPTIONS, waitForDecision } from '../core/askDecision.js';
22
+ import { detectCallerSource } from '../core/callerSource.js';
20
23
 
21
24
  const KINDS = Object.keys(PROVIDERS);
22
25
  const SAFE_ROW = ['id', 'integration_type', 'label', 'is_default', 'test_status', 'last_tested_at', 'created_at'];
@@ -25,6 +28,8 @@ const CONTAINER_NOTE = 'Quox runs in containers: 127.0.0.1 or localhost means th
25
28
  function defaultIO() {
26
29
  return {
27
30
  isTTY: () => isInteractiveHuman(),
31
+ isAgent: () => isAgentCaller(process.env),
32
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
28
33
  readStdin: () => readStdin(),
29
34
  promptSecret: async (message) => {
30
35
  const { default: inquirer } = await import('inquirer');
@@ -53,17 +58,19 @@ export function registerProviderCommands(program, ctx) {
53
58
  // ── provider list ──────────────────────────────────────────────────────────
54
59
  provider
55
60
  .command('list')
56
- .description('Show which AI providers this org can use, and how each is connected')
57
- .action(async () => {
61
+ .description('Show which AI providers this org can use, and how each is connected (--all adds the other integrations in the vault)')
62
+ .option('--all', 'also list the other integrations in the vault (Slack, GitHub, bastion...), not just AI providers')
63
+ .action(async (opts = {}) => {
58
64
  auth.requireAuth();
59
65
  const a = api();
60
66
  const [vault, caps] = await Promise.all([a.connections(), a.capabilities ? a.capabilities() : null]);
61
67
  if (program.opts().output === 'json') {
62
- const rows = vault?.ok ? vault.rows.map((r) => Object.fromEntries(SAFE_ROW.filter((k) => r[k] !== undefined).map((k) => [k, r[k]]))) : null;
68
+ const known = new Set(Object.values(PROVIDERS).map((p) => p.type));
69
+ const rows = vault?.ok ? vault.rows.filter((r) => opts.all || known.has(r.integration_type)).map((r) => Object.fromEntries(SAFE_ROW.filter((k) => r[k] !== undefined).map((k) => [k, r[k]]))) : null;
63
70
  formatDetail({ connections: rows, ...(vault?.ok ? {} : { error: vault?.error || 'vault not readable' }), capabilities: caps?.ok ? caps.capabilities : null }, { format: 'json' });
64
71
  return;
65
72
  }
66
- say(connectionLines(vault, caps));
73
+ say(connectionLines(vault, caps, { includeOthers: Boolean(opts.all) }));
67
74
  });
68
75
 
69
76
  // ── provider connect ───────────────────────────────────────────────────────
@@ -129,6 +136,59 @@ export function registerProviderCommands(program, ctx) {
129
136
  await consentAfter(p.type);
130
137
  });
131
138
 
139
+ // ── provider install (LAZY-AI-CLI) ────────────────────────────────────────
140
+ // The user's own server downloads the vendor's npm package (never a binary Quox ships). A person decides: y/N on
141
+ // a terminal, a high-risk approval card when an agent runs it (an agent's --yes is not a person's yes).
142
+ provider
143
+ .command('install')
144
+ .description('Install Claude Code or Codex on this Quox server from npm (the vendor\'s own package), so Claude sign-in and Codex work. Instance admin only')
145
+ .argument('<tool>', `what to install: ${Object.keys(INSTALL_TOOLS).join(', ')}`)
146
+ .option('--yes', 'install without the y/N question (a script; an agent still gets an approval card)')
147
+ .action(async (toolArg, opts) => {
148
+ auth.requireAuth();
149
+ const tool = toolFor(toolArg);
150
+ if (!tool) throw new Error(`No installable CLI called "${toolArg}". Use one of: ${Object.keys(INSTALL_TOOLS).join(', ')}`);
151
+ const t = INSTALL_TOOLS[tool];
152
+ const a = api();
153
+ const st = await a.providerCliStatus();
154
+ const cur = st?.ok ? st.tools.find((x) => x.id === tool) : null;
155
+ if (cur?.installed) { printInfo(`✔ ${t.name} is already installed on this server (${cur.version || 'version unknown'}${cur.location ? `, ${cur.location}` : ''}). Nothing to do.`); return; }
156
+ const plan = await a.providerCliPlan(tool);
157
+ if (!plan?.ok) {
158
+ if (plan?.status === 404 || plan?.status === 405) throw new Error(`This Quox server is too old to install ${t.name} for you. Update it first: quox server update`);
159
+ throw new Error(plan?.status === 403 ? (plan.error || 'Only an instance admin can install software on this server.') : `Could not plan the install: ${plan?.error || 'no answer'}`);
160
+ }
161
+ printInfo(planLine(plan));
162
+ let approvalId = null;
163
+ if (io.isAgent()) {
164
+ const orgId = resolveOrgId(auth, program);
165
+ const src = (() => { try { return detectCallerSource(process.env); } catch { return {}; } })();
166
+ const session = process.env.QUOX_SESSION || src?.label || 'agent';
167
+ const ep = resolveEndpoint('inbox.decision.create', { orgId });
168
+ const filed = await client.post(ep.path, { service: ep.service, body: {
169
+ session, title: `Install ${t.name} on this server?`, why: planLine(plan), options: YN_OPTIONS, recommend: 'y', high_risk: true,
170
+ justification: `${session} asked to install ${t.vendor}'s ${plan.package} ${plan.version} from npm on this Quox server, so ${t.name} works. It runs as the Quox service user, never root, and every install is audited.`,
171
+ agent: process.env.QUOX_AGENT_ID || undefined,
172
+ } });
173
+ const id = filed?.decision?.id;
174
+ if (!id) throw new Error('Could not file the approval card. Nothing was installed.');
175
+ printInfo(`An agent is running this, so a person decides: approval card ${id} is in /approvals (needs the step-up PIN).`);
176
+ const r = await waitForDecision({
177
+ poll: (w) => { const g = resolveEndpoint('inbox.decision.get', { orgId, id }); return client.get(g.path, { service: g.service, query: { wait: w }, timeout: (w + 15) * 1000, retries: 0 }); },
178
+ log: (m) => process.stderr.write(`${m}\n`),
179
+ });
180
+ if (r.decision?.state !== 'answered' || r.decision?.answer?.option !== 'y') { printInfo(`Not installed: the card was not approved. Nothing was downloaded.`); process.exitCode = 2; return; }
181
+ approvalId = id;
182
+ } else if (!opts.yes) {
183
+ if (!io.isTTY()) throw new Error(`Run again with --yes to install ${t.name}, or run it in a terminal to be asked. Nothing was installed.`);
184
+ if (!(await io.confirm(offerQuestion(tool, plan)))) { printInfo('Not installed. Nothing was downloaded.'); return; }
185
+ }
186
+ printInfo(`◐ installing ${t.name} ${plan.version} on the server (this takes a minute or two)`);
187
+ const r = await runInstall(a, tool, { approvalId, onProgress: printInfo, sleep: io.sleep || undefined });
188
+ if (!r.ok) throw new Error(r.lines.join(' '));
189
+ for (const l of r.lines) printInfo(l);
190
+ });
191
+
132
192
  // ── provider test ──────────────────────────────────────────────────────────
133
193
  provider
134
194
  .command('test')
@@ -144,16 +204,35 @@ export function registerProviderCommands(program, ctx) {
144
204
  const rows = vault.rows.filter((r) => types.has(r.integration_type) && (r.is_default === 1 || r.is_default === true));
145
205
  if (!rows.length) throw new Error(p ? `No ${p.id} connection is stored for this org. Connect one: quox provider connect ${p.id}` : 'No provider connection is stored for this org. Connect one: quox provider connect <kind>');
146
206
  const orgId = resolveOrgId(auth, program);
207
+ const asJson = program.opts().output === 'json';
208
+ const results = [];
147
209
  let failed = 0;
148
210
  for (const row of rows) {
149
- const name = (Object.values(PROVIDERS).find((x) => x.type === row.integration_type) || {}).name || row.integration_type;
211
+ const meta = Object.values(PROVIDERS).find((x) => x.type === row.integration_type) || {};
212
+ const name = meta.name || row.integration_type;
150
213
  const ep = resolveEndpoint('vault.test', { orgId, id: row.id });
151
- let res;
214
+ let res; let reason = null;
152
215
  try { res = await client.post(ep.path, { service: ep.service, retries: 0 }); }
153
- catch (e) { res = { success: false, message: e?.message || 'failed' }; }
154
- if (res?.success === false) { failed++; printInfo(`✖ ${name} ${row.label || row.id} failed: ${res?.message || 'no reason given'}`); }
216
+ catch (e) {
217
+ res = { success: false, message: e?.message || 'failed' };
218
+ reason = e?.body?.reason || (String(e?.message || '').match(/\(reason: ([^)]+)\)/) || [])[1] || null;
219
+ }
220
+ const ok = res?.success !== false;
221
+ if (!ok) failed++;
222
+ results.push({
223
+ id: row.id, kind: meta.id || row.integration_type, label: row.label || row.id, ok,
224
+ ...(ok ? (res?.message ? { message: res.message } : {}) : { error: res?.message || 'no reason given', reason }),
225
+ });
226
+ if (asJson) continue;
227
+ if (!ok) printInfo(`✖ ${name} ${row.label || row.id} failed: ${res?.message || 'no reason given'}`);
155
228
  else printInfo(`✔ ${name} ${row.label || row.id} ok${res?.message ? `: ${res.message}` : ''}`);
156
229
  }
230
+ if (asJson) formatDetail({
231
+ results,
232
+ summary: { total: results.length, ok: results.length - failed, failed },
233
+ // a top-level error string is what the collector's quox_cli wrapper quotes to the model on a non-zero exit
234
+ ...(failed ? { error: results.filter((r) => !r.ok).map((r) => `${r.kind}: ${r.error}`).join('; ') } : {}),
235
+ }, { format: 'json' });
157
236
  if (failed) process.exitCode = 1;
158
237
  });
159
238
  }