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,82 @@
1
+ /**
2
+ * QUOXON objective offer (owner, live 2026-10-25 19:47). Attached to SEER (advisory, no image/X/site tools) the owner asked
3
+ * for a blog article, an image, a publish and a tweet draft, and got "I couldn't". Offer, don't give up: the server sends
4
+ * turn.objective_suggested (from the turn's tool selection and the shape of the ask, never the answer text) and the TUI
5
+ * asks once. It REPLACES the single-turn handoff card (one ask, not two):
6
+ *
7
+ * ⚠ SEER asks: Make this an objective that runs until done?
8
+ * do create objective "<the user's words>"; you approve the plan, then each publish/post step
9
+ * why SEER can't run x post.
10
+ * ▶ Make this an objective that runs until done? y yes · n no
11
+ *
12
+ * y = `quox objective create <goal>` + start (the plan decomposes and lands as an approval card). n does nothing.
13
+ */
14
+ import { askText } from './ask.js';
15
+
16
+ const clipQ = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
17
+
18
+ /** The suggestion from a turn event, when it applies to THIS window (attached to the agent it names). Else null. */
19
+ export function objectiveFromEvent(ev, attachedAgent) {
20
+ if (!ev || ev.type !== 'turn.objective_suggested') return null;
21
+ const o = ev.objective_suggested;
22
+ const at = String(attachedAgent || '').toLowerCase();
23
+ if (!o || typeof o !== 'object' || !o.from || !o.goal || !at) return null;
24
+ if (String(o.from).toLowerCase() !== at) return null; // a suggestion about some other agent
25
+ const loop = o.mode === 'loop' && o.loop && Array.isArray(o.loop.capability_scope) && o.loop.capability_scope.length ? { goal_condition: String(o.loop.goal_condition || o.goal), capability_scope: o.loop.capability_scope.map(String), ...(o.loop.plain ? { plain: true } : {}), ...(Number.isInteger(o.loop.max_iterations) ? { max_iterations: o.loop.max_iterations } : {}), ...(o.loop.summary ? { summary: String(o.loop.summary) } : {}), ...(o.loop.until ? { until: String(o.loop.until) } : {}), ...(Number.isInteger(o.loop.every_seconds) ? { every_seconds: o.loop.every_seconds } : {}), ...(Number.isInteger(o.loop.max_duration_seconds) ? { max_duration_seconds: o.loop.max_duration_seconds } : {}), ...(Array.isArray(o.loop.done_check) && o.loop.done_check.length ? { done_check: o.loop.done_check } : {}) } : null; // QLOOP: a ready, engine-valid loop spec
26
+ return { from: at, fromName: String(o.from_name || at.toUpperCase()), goal: String(o.goal), reason: String(o.reason || '').trim(), tools: Array.isArray(o.tools) ? o.tools : [], ...(loop ? { loop } : {}) };
27
+ }
28
+
29
+ /** The ask line (drawn as the approval-style card). The goal is clipped for the card only, never in what yes creates. */
30
+ export function objectiveAskText(o, cols = 100) {
31
+ if (o.loop && o.loop.plain) { // the user never said "loop": say what it does, in plain words, whole (it wraps; never clipped)
32
+ const mins = o.loop.max_duration_seconds ? Math.max(1, Math.round(o.loop.max_duration_seconds / 60)) : null;
33
+ const q = o.loop.summary ? `${o.loop.summary}?` : `This needs repeated checks: keep going until ${String(o.loop.goal_condition || o.goal).replace(/\s+/g, ' ').trim()}${mins ? `, up to ${mins} minute${mins === 1 ? '' : 's'}` : ''}?`;
34
+ return askText(q, { keys: 'y yes · n just once', who: o.fromName, reason: '', action: `keep going${o.loop.every_seconds ? `, checking about every ${o.loop.every_seconds} seconds,` : ''} using only ${o.loop.capability_scope.join(', ')}; you approve it before it starts` });
35
+ }
36
+ if (o.loop) return askText('Run this as a loop until it is done?', { keys: 'y/N', who: o.fromName, reason: o.reason, action: `create a loop "${clipQ(o.goal, Math.max(20, cols - 70))}" that may use only ${o.loop.capability_scope.join(', ')}; you approve it before it starts` });
37
+ const action = `create objective "${clipQ(o.goal, Math.max(20, cols - 60))}"; you approve the plan, then each publish/post step`;
38
+ return askText('Make this an objective that runs until done?', { keys: 'y/N', who: o.fromName, reason: o.reason, action });
39
+ }
40
+
41
+ export const objectiveStartedNote = (id) => `▶ Objective ${id} started. The plan will land as an approval card.`;
42
+ export const objectiveWatchLine = (id) => `Watch it: quox objective get ${id}`;
43
+ export const objectiveFailedNote = (goal, err) => `Objective not created: ${String(err?.message || err || 'unknown error')}. Retry: quox objective create ${JSON.stringify(goal)} --checkpoint`;
44
+
45
+ // ---- report back into the conversation (owner 2026-10-26) ----
46
+ const TERMINAL = new Set(['completed', 'completed_with_errors', 'failed', 'cancelled', 'aborted']);
47
+ // A finished step's result is the node output JSON; its text is completion.value.
48
+ const textOf = (r) => { const s = String(r ?? ''); if (!s.trimStart().startsWith('{')) return s; try { const o = JSON.parse(s); return typeof o?.completion?.value === 'string' ? o.completion.value : s; } catch { return s; } };
49
+ const reportOf = (tasks) => {
50
+ const done = (tasks || []).filter(t => t.status === 'completed' && textOf(t.result).trim()).sort((a, b) => (a.sequence_num || 0) - (b.sequence_num || 0));
51
+ const pick = [...done].reverse().find(t => /report/i.test(String(t.title || ''))) || done[done.length - 1];
52
+ const text = pick ? textOf(pick.result).trim() : '';
53
+ return text.length > 1500 ? `${text.slice(0, 1499)}…` : text;
54
+ };
55
+
56
+ /** The lines the chat prints when an objective ends: the glyph line, then its report (or what did not complete). */
57
+ export function objectiveFinishedLines(o) {
58
+ const tasks = Array.isArray(o.tasks) ? o.tasks : [];
59
+ const done = tasks.filter(t => t.status === 'completed').length;
60
+ const title = String(o.title || o.id || '').replace(/\s+/g, ' ').slice(0, 80);
61
+ const lines = [`◎ OBJECTIVE │ ${title} │ ${o.status}, ${done}/${tasks.length} steps`];
62
+ const report = reportOf(tasks);
63
+ if (report) lines.push(...report.split('\n').map(l => l.trimEnd()).filter(Boolean));
64
+ if (o.status !== 'completed' && o.error_message) lines.push(String(o.error_message).slice(0, 400));
65
+ return lines;
66
+ }
67
+
68
+ /**
69
+ * Poll an objective while this window is open and say how it ended. get() -> the objective with its tasks; a failed read is
70
+ * retried, never fatal. Resolves { finished, status }. Gives up quietly at maxMs or when stopped() (the inbox item covers a closed window).
71
+ */
72
+ export async function watchObjective({ get, say, intervalMs = 8000, maxMs = 6 * 3600 * 1000, stopped = () => false } = {}) {
73
+ const t0 = Date.now();
74
+ while (Date.now() - t0 < maxMs && !stopped()) {
75
+ try {
76
+ const o = await get();
77
+ if (o && TERMINAL.has(o.status)) { for (const l of objectiveFinishedLines(o)) say(l); return { finished: true, status: o.status }; }
78
+ } catch { /* a transient read: try again */ }
79
+ await new Promise(r => setTimeout(r, intervalMs));
80
+ }
81
+ return { finished: false };
82
+ }
@@ -4,7 +4,7 @@
4
4
  * server could not read, or that went silent, come back in `blind` and are always named ("Can't see"),
5
5
  * so an empty list never reads as an all clear it cannot vouch for.
6
6
  */
7
- import { seg, fit, hhmm, plural, boxWidth, parseTs } from './model.js';
7
+ import { seg, fit, hhmm, day, plural, boxWidth, parseTs } from './model.js';
8
8
 
9
9
  /** Importance: approvals waiting first (they block work), then alerts, failures, then outcomes. */
10
10
  export const RANK = {
@@ -32,19 +32,52 @@ export function awayFor(sinceTs, now) {
32
32
  return h < 48 ? `${h}h` : `${Math.round(h / 24)}d`;
33
33
  }
34
34
 
35
+ /** Short stamp: the time when it is today, else "Oct 12 09:00" so an old item is never mistaken for a new one. */
36
+ export function stamp(ts, now) {
37
+ const t = parseTs(ts);
38
+ if (t == null) return 'time unknown';
39
+ if (day(t) === day(now)) return hhmm(t);
40
+ const d = new Date(t).toISOString();
41
+ return `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][Number(d.slice(5, 7)) - 1]} ${Number(d.slice(8, 10))} ${d.slice(11, 16)}`;
42
+ }
43
+
44
+ /** Who an item is from: its sender when it has one (mail), else the source that reported it. */
45
+ const whoOf = (it) => {
46
+ const w = it.sender || it.from || it.source || '?';
47
+ return w === 'lucid' ? 'LUCID' : w;
48
+ };
49
+ export function breakdown(items) {
50
+ const n = new Map();
51
+ for (const it of items) n.set(whoOf(it), (n.get(whoOf(it)) || 0) + 1);
52
+ return [...n].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).map(([k, c]) => `${k}: ${c}`).join(' · ');
53
+ }
54
+
55
+ /** Newer than the away start (or the last /back) is "since you left"; anything else, or untimed, is old. */
56
+ export function splitItems(items, since) {
57
+ const s = parseTs(since);
58
+ const fresh = [], old = [];
59
+ for (const it of items || []) {
60
+ const t = parseTs(it.ts);
61
+ (s == null || (t != null && t >= s) ? fresh : old).push(it);
62
+ }
63
+ return { fresh, old };
64
+ }
65
+
35
66
  /** d: { since, items, blind, activity, compact?, max?, now } */
36
67
  export function awayCard(d, cols) {
37
68
  const now = d.now ?? Date.now();
38
69
  const w = boxWidth(cols, 96), inner = w - 4;
39
- const items = rankItems(d.items);
70
+ const { fresh, old } = splitItems(d.items, d.since);
71
+ const items = rankItems(fresh);
40
72
  const max = d.compact ? (d.max ?? 5) : items.length;
41
73
  const shown = items.slice(0, max);
42
74
  const rows = [];
43
- const win = awayFor(d.since, now);
44
- if (!items.length) rows.push([seg(d.standing && d.standing.count > 0 ? 'Nothing new needed you.' : 'Nothing needed you.', 'muted')]);
75
+ const win = awayFor(d.since, parseTs(d.endedAt) ?? now); // endedAt: another window already ended this away, so every window reports the same span
76
+ if (!items.length) rows.push([seg(old.length || (d.standing && d.standing.count > 0) ? 'Nothing new since you left.' : 'Nothing needed you.', 'muted')]);
77
+ else if (old.length) rows.push([seg(fit(`Since you left (${items.length})`, inner), 'value', true)]);
45
78
  for (const it of shown) {
46
79
  const [g, role] = GLYPH[it.kind] || ['· ', 'muted'];
47
- const meta = `${it.source || '?'} · ${hhmm(it.ts) || 'time unknown'}`;
80
+ const meta = `${it.source || '?'} · ${stamp(it.ts, now)}`;
48
81
  if (d.compact) {
49
82
  rows.push([seg(g, role), seg(fit(it.title, Math.max(8, inner - 2 - meta.length - 2)), 'value'), seg(` ${meta}`, 'muted')]);
50
83
  } else {
@@ -53,6 +86,11 @@ export function awayCard(d, cols) {
53
86
  }
54
87
  }
55
88
  if (items.length > shown.length) rows.push([seg(`+${items.length - shown.length} more · /away`, 'muted')]);
89
+ if (items.length) rows.push([seg(fit(breakdown(items), inner), 'muted')]);
90
+ if (old.length) {
91
+ const oldest = old.map(i => parseTs(i.ts)).filter(t => t != null).sort((a, b) => a - b)[0];
92
+ rows.push([seg(fit(`${old.length} still open from before${oldest != null ? ` (oldest ${stamp(oldest, now)})` : ''} · ${breakdown(old)}`, inner), 'muted')]);
93
+ }
56
94
  if (d.standing && d.standing.count > 0) rows.push([seg(fit(`${plural(d.standing.count, 'older item')} still need${d.standing.count === 1 ? 's' : ''} you · quox lucid`, inner), 'muted')]);
57
95
  const act = d.activity;
58
96
  if (act && act.total > 0 && !d.compact) {
@@ -11,6 +11,7 @@ import { agentCard } from './agent.js';
11
11
  import { memoryFacts } from './memory.js';
12
12
  import { rulesLadder } from './ladder.js';
13
13
  import { proofChain, reconcileView } from './proof.js';
14
+ import { wrapHang } from './model.js';
14
15
  import { awayCard } from './away.js';
15
16
  import { sitrepCard } from './sitrep.js';
16
17
  import { fleetView } from './fleet.js';
@@ -91,10 +92,27 @@ export async function runObjectCommand(cmd, arg, ctx) {
91
92
  });
92
93
  }
93
94
  case '/proof': {
94
- const calls = toolCalls(ctx.items).filter(b => b.envelopeId);
95
- const t = a ? calls.find(b => b.envelopeId === a || b.callId === a) : calls[calls.length - 1];
96
- if (!t) return { note: a ? `No tool call "${a}" with an envelope in this session.` : 'No tool call with an envelope id in this session yet.', tone: 'warn' };
97
- return wrap('proof', () => o.proof({ tool: t.tool, status: t.status, envelopeId: t.envelopeId, callId: t.callId }));
95
+ const all = toolCalls(ctx.items);
96
+ const calls = all.filter(b => b.envelopeId);
97
+ const newest = [...calls].reverse(); // 1 = newest
98
+ const num = /^\d{1,3}$/.test(a) ? Number(a) : 0;
99
+ const pick = (b) => wrap('proof', () => o.proof({ tool: b.tool, status: b.status, envelopeId: b.envelopeId, callId: b.callId }));
100
+ if (num >= 1 && newest[num - 1]) return pick(newest[num - 1]);
101
+ const t = a && !num ? calls.find(b => b.envelopeId === a || b.callId === a) : (calls.length === 1 ? calls[0] : null);
102
+ if (t) return pick(t);
103
+ if (a) return { note: `No tool call "${a}" with an envelope in this session. /proof lists them; /evidence exports the whole conversation.`, tone: 'warn' };
104
+ if (!calls.length) {
105
+ const ran = all.length ? ` ${all.length} tool call${all.length === 1 ? '' : 's'} ran, but the server reported no envelope id for ${all.length === 1 ? 'it' : 'them'}.` : '';
106
+ return { note: `No tool call with an envelope id in this session yet.${ran} /evidence exports what the server filed for this conversation.`, tone: 'warn' };
107
+ }
108
+ // Several calls: list the envelope ids, newest first (ten), then how many more.
109
+ const shown = newest.slice(0, 10);
110
+ const W = Math.max(20, (ctx.cols || 1e9) - 4);
111
+ const lines = [...wrapHang(`${calls.length} tool call${calls.length === 1 ? '' : 's'} with an envelope in this conversation (newest first):`, W, 2),
112
+ ...shown.flatMap((b, i) => wrapHang(` ${i + 1}. ${b.envelopeId} ${b.tool || 'tool'} ${b.status || ''}`.trimEnd(), W, 5))];
113
+ if (newest.length > shown.length) lines.push(` ${newest.length - shown.length} more (older)`);
114
+ lines.push(...wrapHang('/proof <number or id> shows one chain. /evidence exports the signed bundle of all of them.', W, 2));
115
+ return { note: lines.join('\n'), lines, tone: 'dim' };
98
116
  }
99
117
  case '/reconcile': {
100
118
  const unk = toolCalls(ctx.items).filter(b => b.status === 'effect_unknown');
@@ -21,6 +21,39 @@ export const fit = (s, n) => {
21
21
  return g.length > n ? g.slice(0, Math.max(0, n - 1)).join('') + '…' : g.join('');
22
22
  };
23
23
 
24
+ /**
25
+ * Wrap a string to `width` display chars, never clipping: breaks at spaces, indents each continuation by `hang`
26
+ * spaces beyond the line's own leading indent, and hard-splits a token longer than the line (an id stays whole
27
+ * across rows, no ellipsis). -> string[]
28
+ */
29
+ export function wrapHang(text, width, hang = 4) {
30
+ const src = String(text ?? '');
31
+ const lead = (src.match(/^ */) || [''])[0];
32
+ const words = src.trim().split(/\s+/).filter(Boolean);
33
+ if (!words.length) return [src];
34
+ const w = Math.max(8, width);
35
+ const lines = [];
36
+ let cur = lead;
37
+ let curHas = false;
38
+ const pad = () => lead + ' '.repeat(hang);
39
+ const push = () => { lines.push(cur); cur = pad(); curHas = false; };
40
+ for (let word of words) {
41
+ while (len(cur) + (curHas ? 1 : 0) + len(word) > w) {
42
+ if (curHas) { push(); continue; }
43
+ const room = Math.max(1, w - len(cur));
44
+ const g = [...word];
45
+ if (g.length <= room) break;
46
+ cur += g.slice(0, room).join('');
47
+ word = g.slice(room).join('');
48
+ push();
49
+ }
50
+ cur += (curHas ? ' ' : '') + word;
51
+ curHas = true;
52
+ }
53
+ if (curHas) lines.push(cur);
54
+ return lines;
55
+ }
56
+
24
57
  /** Clip a row of segments to n chars total; later segments shrink first, the last one gets the ellipsis. */
25
58
  export function fitRow(row, n) {
26
59
  const out = [];
@@ -10,7 +10,7 @@
10
10
  * D3: a tool that ended effect_unknown is reconciled against the server's own record of the call
11
11
  * (the ops.tool envelope's payload.success). No envelope id means it cannot be reconciled from here.
12
12
  */
13
- import { seg, fit, short, plural } from './model.js';
13
+ import { seg, fit, plural, wrapHang } from './model.js';
14
14
 
15
15
  const LINKS = ['AEE', 'AOCL', 'TOOL', 'WARD'];
16
16
 
@@ -19,7 +19,7 @@ export function chainLinks(d) {
19
19
  const t = d.tool || {};
20
20
  const env = d.envelope?.ok ? d.envelope.env : null;
21
21
  const out = [];
22
- out.push(env ? { name: 'AEE', ok: true, sub: `envelope ${short(env.id || t.envelopeId, 14)}` }
22
+ out.push(env ? { name: 'AEE', ok: true, sub: 'envelope read, id above' }
23
23
  : { name: 'AEE', ok: d.envelope?.status === 404 ? false : null, sub: d.envelope?.status === 404 ? 'envelope not found' : `envelope not read${d.envelope?.error ? ` (${fit(d.envelope.error, 24)})` : ''}` });
24
24
  const aocl = d.aocl?.ok ? d.aocl.list || [] : null;
25
25
  if (aocl && aocl.length) {
@@ -52,7 +52,13 @@ const markOf = (ok) => (ok === true ? ['✔', 'ok'] : ok === false ? ['✖', 'er
52
52
  export function proofChain(d, cols) {
53
53
  const links = chainLinks(d);
54
54
  const rows = [];
55
- rows.push([seg('proof ', 'muted'), seg(fit(`${d.tool?.tool || 'tool call'}${d.tool?.envelopeId ? ` · ${short(d.tool.envelopeId, 18)}` : ''}`, cols - 10), 'value', true)]);
55
+ const envId = d.tool?.envelopeId || (d.envelope?.ok ? d.envelope.env?.id : null);
56
+ rows.push([seg('proof ', 'muted'), seg(fit(d.tool?.tool || 'tool call', cols - 10), 'value', true)]);
57
+ // The full id on its own row, never clipped, so it can be selected and copied whole.
58
+ if (envId) {
59
+ wrapHang(envId, cols - 7, 0).forEach((l, i) => rows.push([seg(i ? ' ' : 'id ', 'muted'), seg(l, 'value', true)]));
60
+ for (const t of [`read it back: quox aee get ${envId}`, 'check a bundle: quox evidence verify <file>']) for (const l of wrapHang(t, cols - 4, 2)) rows.push([seg(l, 'muted')]);
61
+ }
56
62
  if (cols >= 90) {
57
63
  const head = [], sub = [], cw = Math.max(23, Math.floor((cols - 4) / 4));
58
64
  links.forEach((l, i) => {
@@ -69,7 +75,9 @@ export function proofChain(d, cols) {
69
75
  links.forEach((l, i) => {
70
76
  const role = `proto:${l.name.toLowerCase() === 'tool' ? 'volt' : l.name.toLowerCase()}`;
71
77
  const [m, mr] = markOf(l.ok);
72
- rows.push([seg(m, mr), seg(' '), seg(l.name.padEnd(5), l.name === 'TOOL' ? 'value' : role, true), seg(fit(l.sub, cols - 12), l.ok === false ? 'error' : 'muted')]);
78
+ const subs = wrapHang(l.sub, cols - 11, 0);
79
+ rows.push([seg(m, mr), seg(' '), seg(l.name.padEnd(5), l.name === 'TOOL' ? 'value' : role, true), seg(subs[0], l.ok === false ? 'error' : 'muted')]);
80
+ for (const more of subs.slice(1)) rows.push([seg(' '.repeat(7)), seg(more, l.ok === false ? 'error' : 'muted')]);
73
81
  if (i < 3) rows.push([seg(l.ok === true && links[i + 1].ok === true ? '┃' : '╎', l.ok === true && links[i + 1].ok === true ? 'indigo' : 'faint')]);
74
82
  });
75
83
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Connect this machine to a QuoxCORE server: the one implementation behind the TUI's `/connect server <url>` and
3
+ * the CLI's `quox connect <url>` (ONBOARD-P1).
4
+ */
5
+ import { spawn as nodeSpawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { probeQuoxCore, probeMessage } from './probe.js';
11
+ import { clearJourney } from './journey.js';
12
+ import { defaultInstallDir } from './installRunner.js';
13
+ import { isTrustFixable, readPeerChain, untrustableReason, certCard, storeProfileCert, removeProfileCert, fetchTrusting } from '../../core/profileTrust.js';
14
+ import * as T from './copy.js';
15
+
16
+ /**
17
+ * H5: run the sign-in child so it can NEVER outlive this process. Resolves { status, signal } like spawnSync.
18
+ * The child is killed on every way out: this process receiving SIGTERM/SIGHUP (cancel from a script or chat),
19
+ * SIGINT (forwarded; in a terminal the child also gets it from the foreground group), an exit of this process,
20
+ * a spawn error, and a final belt-and-braces kill once settled. Listeners are always removed.
21
+ * opts.input: text written to the child's stdin (the password for --password-stdin), then stdin is closed.
22
+ */
23
+ export function runSignInChild(exe, args, { input = null, spawnFn = nodeSpawn, proc = process } = {}) {
24
+ return new Promise((resolve) => {
25
+ let child;
26
+ let settled = false;
27
+ const kill = (sig = 'SIGTERM') => { try { if (child && child.exitCode == null && child.signalCode == null) child.kill(sig); } catch { /* already gone */ } };
28
+ const handlers = {};
29
+ const finish = (r) => {
30
+ if (settled) return;
31
+ settled = true;
32
+ for (const [ev, h] of Object.entries(handlers)) proc.removeListener(ev, h);
33
+ kill('SIGTERM');
34
+ resolve(r);
35
+ };
36
+ // The parent must live through Ctrl-C to restore the profile, so SIGINT is held and forwarded, not fatal.
37
+ // A child sitting at a prompt may ignore a lone SIGINT (only a terminal's group Ctrl-C reaches it reliably):
38
+ // after a short grace, end it and settle as cancelled so connect exits 130 with nothing saved.
39
+ handlers.SIGINT = () => {
40
+ kill('SIGINT');
41
+ const t = setTimeout(() => { if (!settled) { kill('SIGTERM'); finish({ status: null, signal: 'SIGINT' }); } }, 1500);
42
+ t.unref?.();
43
+ };
44
+ handlers.SIGTERM = () => { kill('SIGTERM'); finish({ status: null, signal: 'SIGTERM' }); };
45
+ handlers.SIGHUP = () => { kill('SIGTERM'); finish({ status: null, signal: 'SIGHUP' }); };
46
+ handlers.exit = () => kill('SIGKILL');
47
+ for (const [ev, h] of Object.entries(handlers)) proc.on(ev, h);
48
+ try {
49
+ child = spawnFn(exe, args, { stdio: [input == null ? 'inherit' : 'pipe', 'inherit', 'inherit'] });
50
+ } catch (e) { return finish({ status: null, signal: null, error: e }); }
51
+ child.on('error', (error) => finish({ status: null, signal: null, error }));
52
+ child.on('close', (status, signal) => finish({ status, signal }));
53
+ if (input != null && child.stdin) {
54
+ child.stdin.on('error', () => { /* child closed stdin early: its exit tells the story */ });
55
+ child.stdin.end(`${input}\n`);
56
+ }
57
+ });
58
+ }
59
+
60
+ const QUOX_BIN = fileURLToPath(new URL('../../../bin/quox.js', import.meta.url));
61
+
62
+ /**
63
+ * /connect server <url>: check the URL is a real QuoxCORE (#1101), point a profile at it and run the existing
64
+ * login. Any outcome but a completed sign-in (failure, Ctrl-C at Email or Password) puts the previous profile
65
+ * state and the last-good config.json.bak back, so no half-saved profile is left behind.
66
+ *
67
+ * ONBOARD-P1:
68
+ * profile a NAMED profile to create or update (the sign-in then runs as `quox -p <profile> login`). Without
69
+ * it the active profile is pointed at the URL, as before.
70
+ * trust async (card) => boolean. When the server answers with its own untrusted certificate, the certificate
71
+ * is read and shown; if trust() says yes it is stored ON THAT PROFILE (never the global environment,
72
+ * never with TLS checks off). Without trust() the old message is printed and nothing is saved.
73
+ */
74
+ export async function connectServer(url, {
75
+ demo = false, spawn = (exe, a, o) => runSignInChild(exe, a, o), out = (s) => process.stdout.write(s),
76
+ probe = probeQuoxCore, loadConfigImpl = null, bakFile = join(homedir(), '.config', 'quox', 'config.json.bak'),
77
+ localCertPath = join(defaultInstallDir(), 'quox', 'ssl', 'quox.crt'), // the guided install's own certificate, if this is the server
78
+ profile = null, trust = null, email = null, password = null,
79
+ readChain = readPeerChain, storeCert = storeProfileCert, removeCert = removeProfileCert, trustingFetch = fetchTrusting
80
+ } = {}) {
81
+ if (demo) { out(`${T.connectHandoff(url)}\n(demo: not connecting)\n`); return { action: 'exit' }; }
82
+ const load = loadConfigImpl || (await import('../../core/config.js')).loadConfig;
83
+ const config = load();
84
+ const name = profile || config.getActiveProfileName?.() || null;
85
+ let p = await probe(url);
86
+ let trusted = null; // { file } once this run stored a certificate for the profile
87
+ const hadCert = profile ? Boolean(config.getProfile?.(profile)?.caCert) : false;
88
+ if (!p?.ok && p?.kind === 'tls' && isTrustFixable(p.code) && typeof trust === 'function') {
89
+ const r = await trustServerCert(url, name, { readChain, storeCert, removeCert, trustingFetch, probe, trust, out });
90
+ if (!r.ok) return { action: 'connect-failed' };
91
+ trusted = { file: r.file };
92
+ p = { ok: true };
93
+ }
94
+ if (!p?.ok) { out(`${probeMessage(url, p, { localCert: existsSync(localCertPath) ? localCertPath : null })}\n`); return { action: 'connect-failed' }; }
95
+ const snapshot = profile ? JSON.parse(JSON.stringify(config.getProfile?.(profile) ?? null)) : null;
96
+ const prev = profile ? null : (config.getActiveProfile()?.url || null);
97
+ const prevCert = profile ? null : (config.getActiveProfile()?.caCert || null);
98
+ let bak = null;
99
+ try { bak = readFileSync(bakFile); } catch { /* no backup yet */ }
100
+ if (profile) config.upsertProfile(profile, { url, ...(trusted ? { caCert: trusted.file } : {}) });
101
+ else {
102
+ config.setProfileConnection({ url });
103
+ if (trusted) { const ap = config.getActiveProfile(); ap.caCert = trusted.file; config.save(); }
104
+ }
105
+ out(`${T.connectHandoff(url)}\n`);
106
+ // H1: --email/--password-stdin make the sign-in non-interactive; the password goes to the child's stdin, never argv.
107
+ const loginArgs = [...(profile ? ['-p', profile, 'login'] : ['login']), ...(email ? ['--email', email] : []), ...(email && password ? ['--password-stdin'] : [])];
108
+ // H5: runSignInChild kills the child on every exit path (the default spawn); a spawn override may return sync or async.
109
+ // Ctrl-C reaches the whole foreground group: the login child handles it, the parent must live to restore.
110
+ const hold = () => {};
111
+ process.on('SIGINT', hold);
112
+ let r;
113
+ try { r = await spawn(process.execPath, [QUOX_BIN, ...loginArgs], email && password ? { stdio: 'inherit', input: password } : { stdio: 'inherit' }); } finally { process.removeListener('SIGINT', hold); }
114
+ const cfg = load();
115
+ // A named profile must really hold a sign-in: `quox login` exits 0 when its prompt is closed without an answer.
116
+ const signedIn = !profile || Boolean(cfg.getProfile?.(profile)?.auth?.access_token);
117
+ if (r?.status === 0 && signedIn) { clearJourney(); out(`${T.CONNECT_DONE}\n`); return { action: 'connected', profile: name }; }
118
+ if (profile) {
119
+ cfg.restoreProfile(profile, snapshot);
120
+ if (trusted && !hadCert) removeCert(profile);
121
+ } else {
122
+ if (prev) cfg.setProfileConnection({ url: prev });
123
+ else { const prof = cfg.getActiveProfile(); if (prof) { delete prof.url; cfg.save(); } }
124
+ if (trusted) {
125
+ const prof = cfg.getActiveProfile();
126
+ if (prof) { if (prevCert) prof.caCert = prevCert; else delete prof.caCert; cfg.save(); }
127
+ if (!prevCert) removeCert(name);
128
+ }
129
+ }
130
+ if (bak) { try { writeFileSync(bakFile, bak, { mode: 0o600 }); } catch { /* best effort */ } }
131
+ if (r?.status === 130 || r?.signal === 'SIGINT' || r?.signal === 'SIGTERM' || r?.signal === 'SIGHUP') { out(`\x1b[?25h${T.CONNECT_CANCELLED}\n`); return { action: 'connect-cancelled' }; }
132
+ out(`${T.connectFailed(url)}\n`);
133
+ return { action: 'connect-failed' };
134
+ }
135
+
136
+ /**
137
+ * The untrusted-certificate step: read it, refuse what trusting cannot fix, show the card, ask, store it for the
138
+ * profile, then prove the server is a QuoxCORE when that certificate (and only it) is trusted.
139
+ * -> { ok: true, file } | { ok: false }. Prints why on every refusal; stores nothing unless it verified.
140
+ */
141
+ async function trustServerCert(url, name, { readChain, storeCert, removeCert, trustingFetch, probe, trust, out }) {
142
+ let peer;
143
+ try { peer = await readChain(url); } catch (e) {
144
+ out(`Could not read the certificate from ${url}: ${e.message}. Nothing was saved.\n`);
145
+ return { ok: false };
146
+ }
147
+ const reason = untrustableReason(peer, peer.host);
148
+ if (reason) { out(`${url} cannot be trusted: ${reason} Nothing was saved.\n`); return { ok: false }; }
149
+ const card = certCard(url, peer);
150
+ out(`${card}\n`);
151
+ const yes = await trust(card, peer);
152
+ if (!yes) { out('Not trusted. Nothing was saved and no password was asked for.\n'); return { ok: false }; }
153
+ const file = storeCert(name, peer);
154
+ const check = await probe(url, { fetchImpl: trustingFetch(peer.chain.map((c) => c.pem).join('')) });
155
+ if (!check?.ok) {
156
+ removeCert(name);
157
+ out(`${url} still did not answer as a QuoxCORE with that certificate trusted (${check?.kind || 'unknown'}${check?.code ? ` ${check.code}` : ''}). The certificate was removed. Nothing was saved.\n`);
158
+ return { ok: false };
159
+ }
160
+ out(`Trusted for profile "${name}" only (stored at ${file}).\n`);
161
+ return { ok: true, file };
162
+ }
@@ -35,6 +35,12 @@ export const DEFERRAL = 'I can answer that properly once your Quox is running.';
35
35
  export const OFFER = "To do that I need a QuoxCORE, the engine your agents run in. It runs here in Docker, it's free for your own use, and the first install builds from source, so it takes a while.";
36
36
  export const OFFER_ASK = askText('Set it up now?', { keys: '(Y/n)', who: 'Quox', action: 'connect an AI and install the Quox server, step by step' });
37
37
  export const ALREADY = 'Already have one? /connect server <url>';
38
+ // ONBOARD-P1: the owner's one plain first-run choice (plan section 13). a is the default and runs `quox server install`.
39
+ export const CHOICES = 'a install Quox here (recommended) · b use a Quox already on this machine · c connect to another server';
40
+ export const LOCAL_CHECKING = 'Looking for a Quox on this machine (localhost only)...';
41
+ export const localFound = (url) => `Found a Quox at ${url}.`;
42
+ export const LOCAL_NONE = 'No Quox answered on this machine (looked at https://localhost:3000 and http://localhost:3000). Type a to install one here, or c to connect to another server.';
43
+ export const CONNECT_ELSEWHERE = 'To use a Quox on another machine, give me its address: /connect server <url> (the same as: quox connect <url>)';
38
44
  export const DECLINED = 'No problem. Run quox any time and we will pick up here.';
39
45
  export const DECLINED_HINT = 'Type yes to set up now, /connect server <url> to use one you have, or /exit.';
40
46
 
@@ -5,33 +5,28 @@
5
5
  */
6
6
  import React from 'react';
7
7
  import { render } from 'ink';
8
- import { spawnSync } from 'node:child_process';
9
8
  import { fileURLToPath } from 'node:url';
10
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
11
- import { homedir } from 'node:os';
12
- import { join } from 'node:path';
13
- import { probeQuoxCore, probeMessage } from './probe.js';
9
+ import { connectServer } from './connectServer.js';
14
10
  import { GettingStarted } from './view.js';
15
11
  import { inkStdout } from '../term.js';
16
12
  import { createCredentialBroker, createRequestCredential } from './credential.js';
17
13
  import { createDryRunRunner } from './runner.js';
18
14
  import { createInstallRunner, defaultInstallDir, probeAuthHealth } from './installRunner.js';
19
15
  import { createFinisher } from './finish.js';
20
- import { journeyPath as defaultJourneyPath, clearJourney, loadJourney } from './journey.js';
21
- import * as T from './copy.js';
16
+ import { journeyPath as defaultJourneyPath, loadJourney } from './journey.js';
22
17
  import { runDockerOffer } from './dockerOffer.js';
23
18
 
24
19
  const QUOX_BIN = fileURLToPath(new URL('../../../bin/quox.js', import.meta.url));
25
20
 
26
21
  /** The real install: get.quox.ai -> scripts/setup.sh, health = the new instance's auth service on loopback. */
27
- export async function realInstall({ env = process.env } = {}) {
22
+ export async function realInstall({ env = process.env, installDir = null } = {}) {
28
23
  // The CLI's own service resolution (honours the per-service overrides); with no server configured
29
24
  // yet it derives auth on loopback 127.0.0.1:3101, where the new instance's auth service listens.
30
25
  const { loadConfig } = await import('../../core/config.js');
31
26
  const authUrl = loadConfig().getServiceUrl('auth');
32
27
  const saved = loadJourney();
33
28
  return {
34
- runner: createInstallRunner({ env, installDir: saved?.installDir || defaultInstallDir(), probeHealth: () => probeAuthHealth(authUrl) }),
29
+ runner: createInstallRunner({ env, installDir: installDir || saved?.installDir || defaultInstallDir(), probeHealth: () => probeAuthHealth(authUrl) }),
35
30
  finisher: await createFinisher()
36
31
  };
37
32
  }
@@ -58,37 +53,4 @@ export async function runGettingStarted({ version, cwd, welcome = false, earlyIn
58
53
  return result;
59
54
  }
60
55
 
61
- /**
62
- * /connect server <url>: check the URL is a real QuoxCORE (#1101), point the active profile at it and run the
63
- * existing login. Any outcome but a completed sign-in (failure, Ctrl-C at Email or Password) puts the previous
64
- * URL and the last-good config.json.bak back, so no half-saved profile is left behind.
65
- */
66
- export async function connectServer(url, {
67
- demo = false, spawn = spawnSync, out = (s) => process.stdout.write(s),
68
- probe = probeQuoxCore, loadConfigImpl = null, bakFile = join(homedir(), '.config', 'quox', 'config.json.bak'),
69
- localCertPath = join(defaultInstallDir(), 'quox', 'ssl', 'quox.crt') // the guided install's own certificate, if this is the server
70
- } = {}) {
71
- if (demo) { out(`${T.connectHandoff(url)}\n(demo: not connecting)\n`); return { action: 'exit' }; }
72
- const p = await probe(url);
73
- if (!p?.ok) { out(`${probeMessage(url, p, { localCert: existsSync(localCertPath) ? localCertPath : null })}\n`); return { action: 'connect-failed' }; }
74
- const load = loadConfigImpl || (await import('../../core/config.js')).loadConfig;
75
- const config = load();
76
- const prev = config.getActiveProfile()?.url || null;
77
- let bak = null;
78
- try { bak = readFileSync(bakFile); } catch { /* no backup yet */ }
79
- config.setProfileConnection({ url });
80
- out(`${T.connectHandoff(url)}\n`);
81
- // Ctrl-C reaches the whole foreground group: the login child handles it, the parent must live to restore.
82
- const hold = () => {};
83
- process.on('SIGINT', hold);
84
- let r;
85
- try { r = spawn(process.execPath, [QUOX_BIN, 'login'], { stdio: 'inherit' }); } finally { process.removeListener('SIGINT', hold); }
86
- if (r?.status === 0) { clearJourney(); out(`${T.CONNECT_DONE}\n`); return { action: 'connected' }; }
87
- const cfg = load();
88
- if (prev) cfg.setProfileConnection({ url: prev });
89
- else { const prof = cfg.getActiveProfile(); if (prof) { delete prof.url; cfg.save(); } }
90
- if (bak) { try { writeFileSync(bakFile, bak, { mode: 0o600 }); } catch { /* best effort */ } }
91
- if (r?.status === 130 || r?.signal === 'SIGINT') { out(`\x1b[?25h${T.CONNECT_CANCELLED}\n`); return { action: 'connect-cancelled' }; }
92
- out(`${T.connectFailed(url)}\n`);
93
- return { action: 'connect-failed' };
94
- }
56
+ export { connectServer };
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * -> { ok: true } | { ok: false, kind: 'url'|'unreachable'|'tls'|'not-quoxcore', code?, status?, detail? }
7
7
  */
8
+ import { tlsFailureSentence } from '../../core/profileTrust.js';
8
9
  const TLS_CODES = /^(SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT(_LOCALLY)?|CERT_HAS_EXPIRED|CERT_NOT_YET_VALID|ERR_TLS_.*|ERR_SSL_.*|EPROTO)$/;
9
10
  const PLAINTEXT_CODES = new Set(['ERR_SSL_WRONG_VERSION_NUMBER', 'EPROTO']);
10
11
 
@@ -19,7 +20,7 @@ export async function probeQuoxCore(url, { fetchImpl = globalThis.fetch, timeout
19
20
  try {
20
21
  res = await fetchImpl(target, { signal: AbortSignal.timeout(timeout), headers: { accept: 'application/json' } });
21
22
  } catch (e) {
22
- const code = e?.cause?.code || e?.code || null;
23
+ const code = e?.cause?.code || e?.cause?.cause?.code || e?.code || null;
23
24
  if (code && TLS_CODES.test(code)) return { ok: false, kind: 'tls', code };
24
25
  if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return { ok: false, kind: 'unreachable', code: 'TIMEOUT', detail: `no answer within ${Math.round(timeout / 1000)} s` };
25
26
  return { ok: false, kind: 'unreachable', code, detail: e?.message || 'no answer' };
@@ -59,7 +60,25 @@ export function probeMessage(url, r, { localCert = null } = {}) {
59
60
  if (localCert) return `${lead} It is on this machine, so run: NODE_EXTRA_CA_CERTS=${localCert} quox ${tail}`;
60
61
  return `${lead} Copy it from the server (by default ${SERVER_CERT_DEFAULT}) to this machine, then run: NODE_EXTRA_CA_CERTS=~/quox.crt quox ${tail}`;
61
62
  }
63
+ if (tlsFailureSentence(r.code, url)) return `${tlsFailureSentence(r.code, url)} Never turn TLS checks off. Nothing was saved.`;
62
64
  return `${url} answered, but its TLS certificate is not trusted (${r.code}). For a self-signed edge, trust its CA with NODE_EXTRA_CA_CERTS=<ca.pem> and try again; never turn TLS checks off. Nothing was saved.`;
63
65
  default: return `${url} answered, but it is not a QuoxCORE server (${r?.detail || 'unexpected answer'}). Nothing was saved and no password was asked for. ${again}`;
64
66
  }
65
67
  }
68
+
69
+ /** The only places a Quox already on THIS machine is looked for (owner decision 2026-10-28: no LAN or mDNS scan). */
70
+ export const LOCAL_QUOX_URLS = ['https://localhost:3000', 'http://localhost:3000'];
71
+
72
+ /**
73
+ * Read-only: one bounded GET of /health on each known local URL. A self-signed certificate counts as found
74
+ * (it is how a new Quox answers); the connect step then says how to trust it.
75
+ * -> { found: true, url, tls?: true } | { found: false }
76
+ */
77
+ export async function probeLocalQuox({ urls = LOCAL_QUOX_URLS, probe = probeQuoxCore, timeout = 1500 } = {}) {
78
+ for (const url of urls) {
79
+ const r = await probe(url, { timeout });
80
+ if (r?.ok) return { found: true, url };
81
+ if (r?.kind === 'tls' && /SELF_SIGNED|UNABLE_TO_VERIFY/.test(r.code || '')) return { found: true, url, tls: true };
82
+ }
83
+ return { found: false };
84
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * QUOXON-Q: after first-run onboarding succeeds, ONE offer (y/N, default No, asked once) to make `q` run quox.
3
+ * The answer is remembered in the CLI config (shortcutOffer = 'yes' | 'no'). A yes runs the same code as
4
+ * `quox shortcut add`. Nothing is offered when q already resolves to something (ours or not): never shadow.
5
+ */
6
+ import { askText } from '../ask.js';
7
+ import { addShortcut, whichOnPath, DEFAULT_NAME } from '../../core/shortcut.js';
8
+
9
+ export const SHORTCUT_QUESTION = 'Want a shorter command? Type `q` instead of `quox`.';
10
+ export const shortcutOfferLine = () => askText(SHORTCUT_QUESTION, { keys: 'y/N', who: 'Quox', action: `make \`${DEFAULT_NAME}\` a link to quox in a folder you own (no sudo, no shell edits); undo with: quox shortcut remove` });
11
+
12
+ /** -> true when the offer should be shown now. */
13
+ export function shouldOfferShortcut({ config, env = process.env, which = whichOnPath } = {}) {
14
+ if (config?.get?.('shortcutOffer')) return false; // asked once
15
+ return !which(DEFAULT_NAME, env);
16
+ }
17
+
18
+ /** Runs the offer. ask(line) -> string answer. -> { offered, accepted, result? } */
19
+ export async function offerShortcut({ config, ask, out = (s) => process.stdout.write(s), env = process.env, add = addShortcut, which = whichOnPath } = {}) {
20
+ if (!shouldOfferShortcut({ config, env, which })) return { offered: false };
21
+ const answer = String(await ask(shortcutOfferLine()) ?? '').trim();
22
+ const accepted = /^y(es)?$/i.test(answer);
23
+ config.set('shortcutOffer', accepted ? 'yes' : 'no');
24
+ if (!accepted) return { offered: true, accepted: false };
25
+ const result = add({ name: DEFAULT_NAME, env });
26
+ out(`${result.ok ? '✔' : '✖'} ${result.message}\n`);
27
+ return { offered: true, accepted: true, result };
28
+ }