quoxcli 0.4.0 → 0.6.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 (199) hide show
  1. package/CHANGELOG.md +110 -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/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * TUI-MAIL (owner 2026-10-30): read triaged InboxQ mail in the terminal. Pure helpers, read only; app.js owns the calls.
3
+ * Same data as `quox mail list` (GET /api/v1/inboxq/messages) and the message read behind `quox notes from-email`.
4
+ */
5
+ import { ageOf } from './inboxView.js';
6
+ const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t; };
7
+ const pad = (s, n) => { const t = clip(s, n); return t + ' '.repeat(Math.max(0, n - [...t].length)); };
8
+ export const MAIL_ZONES = ['focus', 'fyi', 'auto', 'screened'];
9
+ export const MAIL_USAGE = 'Usage: /mail | /mail focus|fyi|auto|screened (one zone) | /mail N (read one). Read only.';
10
+ export const BODY_MAX_LINES = 40;
11
+
12
+ const whenMs = (m) => { const v = Number(m?.dateTs); if (Number.isFinite(v) && v > 0) return v < 1e12 ? v * 1000 : v; const t = Date.parse(m?.createdAt || ''); return Number.isFinite(t) ? t : 0; };
13
+ const fromOf = (m) => m.fromName || m.fromAddr || 'unknown';
14
+ const newestFirst = (items) => [...(Array.isArray(items) ? items : [])].filter((m) => m && m.id).sort((a, b) => whenMs(b) - whenMs(a));
15
+ export { newestFirst as mailNewestFirst };
16
+
17
+ /** '' | list | N | #N | show N | <zone> -> { kind:'list', zone? } | { kind:'show', n } | { kind:'usage' } */
18
+ export function parseMail(arg) {
19
+ const a = String(arg || '').trim().toLowerCase();
20
+ if (!a || a === 'list' || a === 'latest') return { kind: 'list' };
21
+ if (MAIL_ZONES.includes(a)) return { kind: 'list', zone: a };
22
+ const m = /^(?:show\s+)?#?(\d+)$/.exec(a);
23
+ if (m && Number(m[1]) >= 1) return { kind: 'show', n: Number(m[1]) };
24
+ return { kind: 'usage' };
25
+ }
26
+
27
+ /** No mailbox connected: one line, the exact command, no secret asked for here. */
28
+ export const noMailboxLines = () => [{ text: 'No mailbox connected. Connect one with: quox mail connect --provider gmail --email you@example.com --password-env MAIL_PASSWORD (the password is read from that env var, not typed here).', tone: 'warn' }];
29
+
30
+ /** The newest messages as a table. -> [{ text, tone }] */
31
+ export function mailTable(items, now = Date.now(), cols = 100, max = 10) {
32
+ const rows = newestFirst(items).slice(0, max);
33
+ if (!rows.length) return [{ text: 'No triaged mail yet.', tone: 'dim' }];
34
+ const tw = Math.max(16, Math.min(60, cols - 44));
35
+ const out = [{ text: ` ${pad('#', 3)}${pad('when', 6)}${pad('from', 18)}${pad('subject', tw)} zone`, tone: 'dim' }];
36
+ rows.forEach((m, i) => out.push({ text: `${m.seen === false ? '●' : ' '} ${pad(i + 1, 3)}${pad(ageOf(whenMs(m) ? new Date(whenMs(m)).toISOString() : '', now), 6)}${pad(fromOf(m), 18)}${pad(m.subject || '(no subject)', tw)} ${m.zone || '?'}`, tone: m.seen === false ? 'accent' : 'dim' }));
37
+ out.push({ text: '/mail N read one · /mail focus|fyi|auto|screened filter · full list: quox mail list', tone: 'dim' });
38
+ return out;
39
+ }
40
+
41
+ const htmlToText = (h) => String(h)
42
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
43
+ .replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\n{3,}/g, '\n\n');
44
+
45
+ /** One message: from, to, date, subject, a plain-text body excerpt, the triage reason. -> [{ text, tone }] */
46
+ export function mailDetail(m, now = Date.now(), n = 0) {
47
+ const from = m.fromName && m.fromAddr ? `${m.fromName} <${m.fromAddr}>` : fromOf(m);
48
+ const t = whenMs(m);
49
+ const to = m.toAddr || m.toAddrs || m.to;
50
+ const out = [{ text: m.subject || '(no subject)', tone: 'accent' },
51
+ { text: `from ${from}`, tone: 'dim' }];
52
+ if (to) out.push({ text: `to ${Array.isArray(to) ? to.join(', ') : to}`, tone: 'dim' });
53
+ out.push({ text: `${t ? new Date(t).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : 'date unknown'} (${t ? ageOf(new Date(t).toISOString(), now) + ' ago' : '?'})`, tone: 'dim' });
54
+ const bits = [`triage: ${m.zone || 'untriaged'}`];
55
+ if (m.classifiedBy) bits.push(`by ${m.classifiedBy}`);
56
+ if (typeof m.confidence === 'number') bits.push(`${Math.round(m.confidence * 100)}% sure`);
57
+ if (Array.isArray(m.flags) && m.flags.length) bits.push(`flags: ${m.flags.join(', ')}`);
58
+ if (m.phishingRisk > 0) bits.push('phishing risk flagged');
59
+ out.push({ text: bits.join(' · '), tone: 'dim' });
60
+ let body = typeof m.bodyText === 'string' && m.bodyText.trim() ? m.bodyText : (typeof m.bodyHtml === 'string' && m.bodyHtml.trim() ? htmlToText(m.bodyHtml) : '');
61
+ if (body) {
62
+ const lines = body.trim().split('\n');
63
+ for (const l of lines.slice(0, BODY_MAX_LINES)) out.push({ text: l.replace(/\s+$/, ''), tone: 'text' });
64
+ if (lines.length > BODY_MAX_LINES) out.push({ text: `… ${lines.length - BODY_MAX_LINES} more lines. Full message: quox mail list -o json`, tone: 'dim' });
65
+ } else if (m.snippet) {
66
+ out.push({ text: m.snippet, tone: 'text' });
67
+ out.push({ text: '(preview only: the full body has not been fetched from the mailbox yet)', tone: 'dim' });
68
+ } else out.push({ text: '(no body stored)', tone: 'dim' });
69
+ if (n) out.push({ text: `/mail for the list · read only: nothing is sent, moved or deleted here`, tone: 'dim' });
70
+ return out;
71
+ }
@@ -12,7 +12,7 @@ import { loadRules, rulesContext } from './rules.js';
12
12
  import { createTranscript } from './transcript.js';
13
13
  import { createScreenRecorder, screenFramesEnabled } from './screenFrames.js';
14
14
  import { applySettings } from './slash/settings.js';
15
- import { headOf, RUNNING_ROOT, execRestart, restartPlan, checkRestart, startBuildWatch, settleMsFrom, minGapMsFrom, restoreTerminal, RESTART_SLOT_ENV, RESTART_ATTACH_ENV, RESTART_NOTE_ENV, RESTART_MODE_ENV } from './slash/restart.js';
15
+ import { headOf, RUNNING_ROOT, execRestart, restartPlan, checkRestart, startBuildWatch, settleMsFrom, minGapMsFrom, restoreTerminal, RESTART_SLOT_ENV, RESTART_ATTACH_ENV, RESTART_NOTE_ENV, RESTART_MODE_ENV, RESTART_DECLINED_ENV } from './slash/restart.js';
16
16
  import { readLastSeen, writeLastSeen } from './away.js';
17
17
  import { fileHistory } from './promptHistory.js';
18
18
  import { takeLauncherHandoff, writeRestartHandoff, RESTART_EXIT_CODE } from './launcher.js';
@@ -21,6 +21,8 @@ export { parseArgs, USAGE, pickProfile, choosePick, chatCredentialProblem, noSer
21
21
  import { parseArgs, USAGE, noServerConfigured, mainPrint, pickProfile, chatCredentialProblem } from './headless.js';
22
22
 
23
23
  export { captureEarlyInput } from './earlyInput.js';
24
+ import { isNeverAnswered, healthyStamp } from './neverAnswered.js';
25
+ import { isWebPty, SHELL_REFUSED } from './webPty.js';
24
26
 
25
27
  /**
26
28
  * Clear the screen before the chat client? Never right after Getting started: its finale (the one-time
@@ -39,12 +41,12 @@ export async function main(argv = process.argv.slice(2)) {
39
41
  if (opts.errors.length) { early?.release(); process.stderr.write(`quox: ${opts.errors.join('; ')}\n${USAGE}\n`); process.exitCode = 1; return; }
40
42
  const cwd = process.cwd().replace(process.env.HOME || '\0', '~');
41
43
  const { resolveCliVersion } = await import('../core/version.js');
42
- let version = '0.4.0';
44
+ let version = '0.6.0';
43
45
  try { version = resolveCliVersion?.() || version; } catch { /* keep default */ }
44
46
  if (opts.print) return mainPrint(opts);
45
47
  let replay = null;
46
48
  let fromGettingStarted = false;
47
- if (opts.demoOnboarding || (!opts.demo && await noServerConfigured(opts, await import('../core/config.js')))) {
49
+ if (!isWebPty(process.env) && (opts.demoOnboarding || (!opts.demo && await noServerConfigured(opts, await import('../core/config.js'))))) { // QUOXON web PTY: never the installer journey
48
50
  // Getting started (FIRST_RUN_JOURNEY.md C2a): a separate view, never the chat client talking.
49
51
  if (process.stdout.isTTY && !opts.noClear) process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
50
52
  const welcome = shouldShowWelcome({ print: false });
@@ -104,6 +106,9 @@ export async function main(argv = process.argv.slice(2)) {
104
106
  if (problem) return problem.message;
105
107
  if (pick.note) return pick.note;
106
108
  const hl = await api.health(8000);
109
+ if (hl.ok && !missing) { // ONBOARD-P2: remember that this profile's server has answered, so a later outage keeps the reconnect spinner
110
+ try { const stamp = healthyStamp(config.getProfile?.(pname), Date.now()); if (stamp) config.upsertProfile?.(pname, { lastHealthyAt: stamp }); } catch { /* history is best effort */ }
111
+ }
107
112
  return hl.ok ? null : hl.absent ? `No QuoxCORE running at ${hl.url}` : `QuoxCORE at ${hl.url} is not answering (${hl.status}); messages may be slow`;
108
113
  };
109
114
  // /restart hand-over: re-enter the predecessor window's org in this window only (the profile is untouched).
@@ -127,7 +132,7 @@ export async function main(argv = process.argv.slice(2)) {
127
132
  return { ok: true, api: a2, agentLogin: residentNames().includes(name), orgName: (c && !c.error && c.name) || c2.getAuth?.()?.org_name || null, note };
128
133
  };
129
134
  const loginCandidates = profiles.filter(p => !p.loggedIn && !residentNames().includes(p.name)).map(p => p.name);
130
- props = { api, switchProfile, loginCandidates, away: { read: readLastSeen, write: writeLastSeen }, modelChoice: opts.provider, resume: opts.resume ?? null, onRestart: (r) => { restart = r; }, onDemo: () => { restart = { demo: true }; }, restartCheck: checkRestart, buildWatch: (o) => startBuildWatch({ ...o, settleMs: settleMsFrom(), minGapMs: minGapMsFrom() }), startSha: headOf(RUNNING_ROOT), rules: loadRules(), profile: pname, agentLogin: (probe.listResidentProfiles?.() || []).some(r => r.name === pname), humanProfiles: profiles.filter(p => p.loggedIn && !(probe.listResidentProfiles?.() || []).some(r => r.name === p.name)).map(p => p.name), org: missing ? null : ((carried && !carried.error && carried.name) || config.getAuth?.()?.org_name || null), startup: startupCarry, healthRecheck: () => api.health(8000), blocked: problem && !signedOut ? problem.message : null, signedOut, autoplay: replay };
135
+ props = { api, switchProfile, loginCandidates, startupSwitchOffer: true, away: { read: readLastSeen, write: writeLastSeen }, modelChoice: opts.provider, resume: opts.resume ?? null, onRestart: (r) => { restart = r; }, onDemo: () => { restart = { demo: true }; }, restartCheck: checkRestart, buildWatch: (o) => startBuildWatch({ ...o, settleMs: settleMsFrom(), minGapMs: minGapMsFrom() }), startSha: headOf(RUNNING_ROOT), rules: loadRules(), profile: pname, agentLogin: (probe.listResidentProfiles?.() || []).some(r => r.name === pname), humanProfiles: profiles.filter(p => p.loggedIn && !(probe.listResidentProfiles?.() || []).some(r => r.name === p.name)).map(p => p.name), org: missing ? null : ((carried && !carried.error && carried.name) || config.getAuth?.()?.org_name || null), startup: startupCarry, neverAnswered: !missing && !signedOut && !problem && !pick.note && !opts.serviceKey && isNeverAnswered(config.getProfile?.(pname)), onConnect: (url) => { restart = { connect: url }; }, healthRecheck: () => api.health(8000), blocked: problem && !signedOut ? problem.message : null, signedOut, autoplay: replay };
131
136
  }
132
137
  // Interactive start: a clean screen under the intro (never when piped; NO_COLOR still clears),
133
138
  // except straight after Getting started, whose finale must stay readable.
@@ -153,13 +158,29 @@ export async function main(argv = process.argv.slice(2)) {
153
158
  const restartSlot = opts.demo ? null : (process.env[RESTART_SLOT_ENV] || null); delete process.env[RESTART_SLOT_ENV]; // one hand-over only
154
159
  const restartAttach = opts.demo ? null : (process.env[RESTART_ATTACH_ENV] || null); delete process.env[RESTART_ATTACH_ENV];
155
160
  const restartMode = opts.demo ? null : (process.env[RESTART_MODE_ENV] === 'plan' ? 'plan' : null); delete process.env[RESTART_MODE_ENV]; // PLAN MODE: window-scoped, carried once
161
+ const restartDeclined = opts.demo ? null : (process.env[RESTART_DECLINED_ENV] || null); delete process.env[RESTART_DECLINED_ENV]; // a slot this window said no to: not re-asked after the restart
156
162
  const restartNote = opts.demo ? null : (process.env[RESTART_NOTE_ENV] || null); delete process.env[RESTART_NOTE_ENV]; // e.g. a failed /update, said once in the restarted client
157
163
  if (restartNote && !props.introNote) props.introNote = restartNote;
158
- const app = render(React.createElement(App, { ...props, restartSlot, restartAttach, ...(restartMode ? { mode: restartMode } : {}), welcome, tipStartup: !fromGettingStarted && !opts.demo && Boolean(process.stdout.isTTY), whatsNewStartup: !opts.demo && Boolean(process.stdout.isTTY), version, cwd, verbose: opts.verbose, earlyInput: early, sessionName: opts.name, resumeConversation: opts.conversation || null, invite: Boolean(process.stdout.isTTY) && !opts.name, slotsAvailable, transcript, historyStore: opts.demo ? null : fileHistory() }), inkRenderOptions(process.stdout, { tap: screenRec?.tap }));
164
+ const app = render(React.createElement(App, { ...props, restartSlot, restartAttach, restartDeclined, ...(restartMode ? { mode: restartMode } : {}), welcome, tipStartup: !fromGettingStarted && !opts.demo && Boolean(process.stdout.isTTY), whatsNewStartup: !opts.demo && Boolean(process.stdout.isTTY), version, cwd, verbose: opts.verbose, earlyInput: early, sessionName: opts.name, resumeConversation: opts.conversation || null, invite: Boolean(process.stdout.isTTY) && !opts.name, slotsAvailable, transcript, historyStore: opts.demo ? null : fileHistory() }), inkRenderOptions(process.stdout, { tap: screenRec?.tap }));
159
165
  try { await app.waitUntilExit(); screenRec?.stop(); transcript?.end(restart ? 'restart' : 'exit'); }
160
166
  catch (e) { screenRec?.stop(); transcript?.end(`crash: ${e?.message || e}`); throw e; }
161
167
  finally { screenRec?.stop(); process.stdout.off('resize', onResize); }
162
168
  if (!restart) return;
169
+ if (isWebPty(process.env)) { // QUOXON web PTY: no connect flow, update or re-exec on the server; the page reconnects instead
170
+ restoreTerminal();
171
+ process.stdout.write(`\n${SHELL_REFUSED}. Use Reconnect on the page to start a fresh session.\n`);
172
+ return;
173
+ }
174
+ if (restart.connect) { // ONBOARD-P2: "Connect to a different server?" said y; the connect flow owns the terminal (certificate card, sign-in)
175
+ restoreTerminal();
176
+ const { connectServer } = await import('./onboarding/connectServer.js');
177
+ const { profileNameForUrl } = await import('../core/profileTrust.js');
178
+ const { confirmInteractive } = await import('../core/confirm.js');
179
+ const profile = profileNameForUrl(restart.connect);
180
+ const c = await connectServer(restart.connect, { profile, trust: () => confirmInteractive(`Trust this server's certificate for profile "${profile}" only?`, { flag: '--trust-cert', purpose: 'trust it' }), loadConfigImpl: (await import('../core/config.js')).loadConfig });
181
+ if (c.action !== 'connected') { process.exitCode = 1; return; }
182
+ restart = { profile };
183
+ }
163
184
  if (restart.update) { // /update said y: the update owns the terminal (npm prompts, sudo), then the same restart either way
164
185
  const { runUpdate } = await import('../core/selfUpdate.js');
165
186
  const plan = restart.update;
@@ -174,7 +195,7 @@ export async function main(argv = process.argv.slice(2)) {
174
195
  }
175
196
  }
176
197
  // The app preflighted the installed quox before letting go, so a failure here is rare (it changed in between).
177
- const how = { argv: restart.demo ? ['--demo', '--autoplay'] : argv, conversationId: restart.demo ? null : restart.conversationId, profile: opts.demo ? null : (restart.profile || props.profile), name: restart.name || opts.name || null, slot: restart.demo ? null : (restart.slot || null), attach: restart.demo ? null : (restart.attach || null), org: restart.demo ? null : (restart.org || null), mode: restart.demo ? null : (restart.mode || null), bin: restart.bin || undefined };
198
+ const how = { argv: restart.demo ? ['--demo', '--autoplay'] : argv, conversationId: restart.demo ? null : restart.conversationId, profile: opts.demo ? null : (restart.profile || props.profile), name: restart.name || opts.name || null, slot: restart.demo ? null : (restart.slot || null), attach: restart.demo ? null : (restart.attach || null), org: restart.demo ? null : (restart.org || null), mode: restart.demo ? null : (restart.mode || null), declined: restart.demo ? null : (restart.declined || null), bin: restart.bin || undefined };
178
199
  // QUOXON-RESTART-LEAK: under the launcher this process EXITS and the launcher runs the successor, so exactly one
179
200
  // client stays alive per terminal. Without one (QUOX_NO_LAUNCHER=1) it falls back to waiting on a nested child.
180
201
  const launched = takeLauncherHandoff();
@@ -63,7 +63,7 @@ export function inlineText(tokens = []) {
63
63
  return tokens.map(t => {
64
64
  if (t.tokens) return inlineText(t.tokens);
65
65
  if (t.type === 'br') return ' ';
66
- return t.text ?? t.raw ?? '';
66
+ return typeof t.text === 'string' ? t.text : typeof t.raw === 'string' ? t.raw : ''; // never an object: it would print [object Object]
67
67
  }).join('').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
68
68
  }
69
69
 
@@ -90,6 +90,21 @@ export const choiceId = (ch) => (ch ? rowOf(ch)?.id || null : null);
90
90
  export const connectedChoices = (res, caps = null) =>
91
91
  CHOICES.filter(c => c.served !== false && availability(res, c, caps).state === 'connected');
92
92
 
93
+ /** Health states that mean a provider refuses turns right now. */
94
+ const REFUSING = new Set(['limited', 'no_credits']);
95
+ /**
96
+ * The active model's provider is limited / out of credits: the first CHOICES row the server reports ready whose
97
+ * provider health does not also refuse turns. -> { id, label, why } | null (no usable alternative = no offer).
98
+ */
99
+ export function limitedOffer(health, current, res, caps = null, mine = null) {
100
+ const bad = new Set((Array.isArray(health) ? health : []).filter(e => REFUSING.has(e?.state)).map(e => String(e.provider || '').toLowerCase()));
101
+ const active = String(current?.provider || mine || '').toLowerCase();
102
+ if (!active || !bad.has(active)) return null;
103
+ const alt = connectedChoices(res, caps).find(c => !bad.has(c.provider) && c.provider !== active);
104
+ if (!alt) return null;
105
+ return { id: alt.id, label: alt.name, why: alt.id === 'ollama' ? 'local Ollama, ready on this server' : 'ready on this server' };
106
+ }
107
+
93
108
  const same = (a, b) => (a?.provider || null) === (b?.provider || null) && (a?.via || null) === (b?.via || null);
94
109
 
95
110
  /** -> [[text, tone?]] lines for /model. `caps` = api.capabilities() (the server's word on each provider). */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * ONBOARD-P2: has this profile's server ever answered from this machine?
3
+ * A profile that has not (no recorded health success, never signed in) and whose server is dead is a first-run
4
+ * problem (wrong address), not an outage, so the TUI offers to connect elsewhere instead of spinning "restarting".
5
+ */
6
+ const HOUR = 3600 * 1000;
7
+
8
+ export function isNeverAnswered(profile) {
9
+ if (!profile) return false;
10
+ if (profile.lastHealthyAt) return false;
11
+ if (profile.auth?.access_token) return false; // a sign-in worked once, so the server answered
12
+ return true;
13
+ }
14
+
15
+ /** The ISO time to record after a successful health probe, or null when a recent stamp already exists. */
16
+ export function healthyStamp(profile, now = Date.now()) {
17
+ const prev = Date.parse(profile?.lastHealthyAt || '');
18
+ if (Number.isFinite(prev) && now - prev < HOUR) return null;
19
+ return new Date(now).toISOString();
20
+ }
@@ -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) {
@@ -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
 
@@ -12,6 +12,7 @@
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { askText } from '../ask.js';
14
14
  import * as T from './copy.js';
15
+ import { isWebPty, SHELL_REFUSED } from '../webPty.js';
15
16
 
16
17
  export const DOCKER_INSTALL_CMD = 'curl -fsSL https://get.docker.com | sudo sh && sudo usermod -aG docker "$USER"';
17
18
  export const DOCKER_GROUP_CMD = 'sudo usermod -aG docker "$USER"';
@@ -31,7 +32,8 @@ export function dockerOffer(rows) {
31
32
  const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
32
33
 
33
34
  /** Runs the offer with the terminal attached. -> { action: 'exit', status } */
34
- export function runDockerOffer(kind, { spawn = spawnSync, out = (s) => process.stdout.write(s), node = process.execPath, bin } = {}) {
35
+ export function runDockerOffer(kind, { spawn = spawnSync, out = (s) => process.stdout.write(s), node = process.execPath, bin, env = process.env } = {}) {
36
+ if (isWebPty(env)) { out(`\n${SHELL_REFUSED}\n`); return { action: 'exit', status: 126 }; } // QUOXON web PTY
35
37
  const command = COMMANDS[kind];
36
38
  if (command) {
37
39
  out(`\n${T.DOCKER_OFFER_RUNNING}\n ${command}\n\n`);
@@ -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
+ }