quoxcli 0.4.0 → 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 (175) 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/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/inbox.js +6 -0
  9. package/lib/api/endpoints/inboxq.js +19 -0
  10. package/lib/api/endpoints/index.js +2 -0
  11. package/lib/api/endpoints/mon.js +122 -0
  12. package/lib/api/endpoints/tools.js +13 -0
  13. package/lib/api/endpoints/v1.js +13 -0
  14. package/lib/commands/admin.js +35 -0
  15. package/lib/commands/agentAttach.js +15 -0
  16. package/lib/commands/approvals.js +193 -3
  17. package/lib/commands/ask.js +1 -1
  18. package/lib/commands/auth.js +64 -11
  19. package/lib/commands/backup.js +11 -4
  20. package/lib/commands/capabilities.js +3 -2
  21. package/lib/commands/chat.js +15 -0
  22. package/lib/commands/claude.js +4 -1
  23. package/lib/commands/compliance.js +109 -0
  24. package/lib/commands/connect.js +69 -0
  25. package/lib/commands/dev.js +6 -8
  26. package/lib/commands/doctor.js +22 -3
  27. package/lib/commands/evidence.js +2 -1
  28. package/lib/commands/gripe.js +24 -13
  29. package/lib/commands/inbox.js +37 -3
  30. package/lib/commands/inboxq.js +147 -0
  31. package/lib/commands/kuma.js +78 -5
  32. package/lib/commands/licenceLinkView.js +311 -0
  33. package/lib/commands/linkedin.js +19 -0
  34. package/lib/commands/loop.js +30 -3
  35. package/lib/commands/lucid.js +1 -0
  36. package/lib/commands/mail.js +161 -20
  37. package/lib/commands/memory.js +68 -4
  38. package/lib/commands/memorySync.js +158 -0
  39. package/lib/commands/mon.js +55 -144
  40. package/lib/commands/notes.js +29 -7
  41. package/lib/commands/notesSeal.js +259 -0
  42. package/lib/commands/objective.js +5 -2
  43. package/lib/commands/offload.js +2 -3
  44. package/lib/commands/plan.js +17 -0
  45. package/lib/commands/provider.js +29 -8
  46. package/lib/commands/qlarity.js +32 -1
  47. package/lib/commands/run.js +2 -1
  48. package/lib/commands/server.js +37 -0
  49. package/lib/commands/setup.js +127 -75
  50. package/lib/commands/skills.js +4 -1
  51. package/lib/commands/slot.js +49 -9
  52. package/lib/commands/status.js +92 -44
  53. package/lib/commands/tool.js +30 -0
  54. package/lib/commands/vault.js +6 -3
  55. package/lib/commands/walkthrough.js +14 -2
  56. package/lib/commands/watch.js +11 -2
  57. package/lib/commands/wazuh.js +824 -0
  58. package/lib/commands/wazuhIntake.js +317 -0
  59. package/lib/commands/wazuhReport.js +192 -0
  60. package/lib/commands/workflow.js +47 -4
  61. package/lib/commands/x.js +50 -0
  62. package/lib/core/announce.js +86 -0
  63. package/lib/core/auth.js +20 -2
  64. package/lib/core/callMe.js +118 -0
  65. package/lib/core/client.js +27 -1
  66. package/lib/core/commandRegistrars.js +8 -0
  67. package/lib/core/config.js +28 -2
  68. package/lib/core/confirm.js +68 -7
  69. package/lib/core/crossOrgApprovals.js +69 -0
  70. package/lib/core/errors.js +52 -1
  71. package/lib/core/governedSession.js +135 -5
  72. package/lib/core/inboxResolution.js +3 -2
  73. package/lib/core/loginGuard.js +73 -0
  74. package/lib/core/offload.js +37 -0
  75. package/lib/core/org-override.js +5 -1
  76. package/lib/core/output.js +1 -1
  77. package/lib/core/profileTrust.js +182 -0
  78. package/lib/core/quoxglyph.js +22 -1
  79. package/lib/core/router.js +14 -0
  80. package/lib/core/stepUpPrompt.js +99 -0
  81. package/lib/core/version.js +1 -1
  82. package/lib/core/walkthrough.js +9 -5
  83. package/lib/dragon/select.js +2 -2
  84. package/lib/quoxon/EXTENSIONS.md +142 -19
  85. package/lib/quoxon/api.js +185 -34
  86. package/lib/quoxon/app.js +1032 -152
  87. package/lib/quoxon/approvalBatch.js +117 -0
  88. package/lib/quoxon/approvalFacts.js +56 -2
  89. package/lib/quoxon/approvalSentence.js +8 -4
  90. package/lib/quoxon/approvalsView.js +69 -11
  91. package/lib/quoxon/autoOpen.js +94 -0
  92. package/lib/quoxon/backgroundWork.js +59 -0
  93. package/lib/quoxon/bang.js +1 -1
  94. package/lib/quoxon/capabilities.js +7 -2
  95. package/lib/quoxon/commandShort.js +7 -0
  96. package/lib/quoxon/commands.js +40 -10
  97. package/lib/quoxon/connect.js +5 -3
  98. package/lib/quoxon/deckLazy.js +58 -0
  99. package/lib/quoxon/destructive.js +35 -0
  100. package/lib/quoxon/dnd.js +71 -0
  101. package/lib/quoxon/dragonCompanion.js +37 -7
  102. package/lib/quoxon/dragonMoments.js +78 -0
  103. package/lib/quoxon/echo.js +1 -0
  104. package/lib/quoxon/events.js +43 -7
  105. package/lib/quoxon/extensions.js +398 -38
  106. package/lib/quoxon/gripeQueue.js +135 -0
  107. package/lib/quoxon/inboxView.js +125 -22
  108. package/lib/quoxon/keys.js +2 -2
  109. package/lib/quoxon/loopCard.js +5 -1
  110. package/lib/quoxon/loopCards.js +17 -8
  111. package/lib/quoxon/loopStream.js +20 -9
  112. package/lib/quoxon/mailView.js +71 -0
  113. package/lib/quoxon/main.js +20 -5
  114. package/lib/quoxon/markdown.js +1 -1
  115. package/lib/quoxon/model.js +15 -0
  116. package/lib/quoxon/neverAnswered.js +20 -0
  117. package/lib/quoxon/objects/away.js +43 -5
  118. package/lib/quoxon/onboarding/connectServer.js +162 -0
  119. package/lib/quoxon/onboarding/copy.js +6 -0
  120. package/lib/quoxon/onboarding/index.js +5 -43
  121. package/lib/quoxon/onboarding/probe.js +20 -1
  122. package/lib/quoxon/onboarding/view.js +16 -3
  123. package/lib/quoxon/orgSwitch.js +22 -0
  124. package/lib/quoxon/print.js +2 -0
  125. package/lib/quoxon/profileSwitch.js +7 -0
  126. package/lib/quoxon/promptHistory.js +3 -1
  127. package/lib/quoxon/render.js +63 -14
  128. package/lib/quoxon/roster.js +10 -5
  129. package/lib/quoxon/rosterSessions.js +44 -0
  130. package/lib/quoxon/screenContext.js +1 -1
  131. package/lib/quoxon/secretShape.js +86 -0
  132. package/lib/quoxon/slash/agents.js +2 -1
  133. package/lib/quoxon/slash/argSources.js +9 -2
  134. package/lib/quoxon/slash/args.js +4 -2
  135. package/lib/quoxon/slash/broadcast.js +18 -5
  136. package/lib/quoxon/slash/broadcastRun.js +13 -2
  137. package/lib/quoxon/slash/cliLeaves.json +63 -1
  138. package/lib/quoxon/slash/hatWalk.js +1 -1
  139. package/lib/quoxon/slash/palette.js +22 -1
  140. package/lib/quoxon/slash/paletteController.js +10 -4
  141. package/lib/quoxon/slash/restart.js +7 -4
  142. package/lib/quoxon/slash/stepUp.js +5 -5
  143. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  144. package/lib/quoxon/slashSpinner.js +35 -0
  145. package/lib/quoxon/slotAttach.js +8 -0
  146. package/lib/quoxon/slotChat.js +17 -0
  147. package/lib/quoxon/slotFormat.js +1 -1
  148. package/lib/quoxon/slotOutbox.js +113 -0
  149. package/lib/quoxon/suggestCommand.js +1 -1
  150. package/lib/quoxon/term.js +25 -1
  151. package/lib/quoxon/transcript.js +2 -1
  152. package/lib/quoxon/welcome.js +1 -1
  153. package/lib/quoxon/whatsnew.js +1 -1
  154. package/lib/quoxon/whatsnew.jsonl +36 -0
  155. package/lib/server/installFlow.js +168 -0
  156. package/lib/server/status.js +15 -1
  157. package/lib/toolOutputShape.js +55 -0
  158. package/package.json +3 -2
  159. package/vendor/dragonlab/SOURCE +3 -3
  160. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  161. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  162. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  163. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  164. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  165. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  166. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  167. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  168. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  169. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  170. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  171. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  172. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  173. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  174. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  175. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -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,7 @@ 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';
24
25
 
25
26
  /**
26
27
  * Clear the screen before the chat client? Never right after Getting started: its finale (the one-time
@@ -39,7 +40,7 @@ export async function main(argv = process.argv.slice(2)) {
39
40
  if (opts.errors.length) { early?.release(); process.stderr.write(`quox: ${opts.errors.join('; ')}\n${USAGE}\n`); process.exitCode = 1; return; }
40
41
  const cwd = process.cwd().replace(process.env.HOME || '\0', '~');
41
42
  const { resolveCliVersion } = await import('../core/version.js');
42
- let version = '0.4.0';
43
+ let version = '0.5.0';
43
44
  try { version = resolveCliVersion?.() || version; } catch { /* keep default */ }
44
45
  if (opts.print) return mainPrint(opts);
45
46
  let replay = null;
@@ -104,6 +105,9 @@ export async function main(argv = process.argv.slice(2)) {
104
105
  if (problem) return problem.message;
105
106
  if (pick.note) return pick.note;
106
107
  const hl = await api.health(8000);
108
+ if (hl.ok && !missing) { // ONBOARD-P2: remember that this profile's server has answered, so a later outage keeps the reconnect spinner
109
+ try { const stamp = healthyStamp(config.getProfile?.(pname), Date.now()); if (stamp) config.upsertProfile?.(pname, { lastHealthyAt: stamp }); } catch { /* history is best effort */ }
110
+ }
107
111
  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
112
  };
109
113
  // /restart hand-over: re-enter the predecessor window's org in this window only (the profile is untouched).
@@ -127,7 +131,7 @@ export async function main(argv = process.argv.slice(2)) {
127
131
  return { ok: true, api: a2, agentLogin: residentNames().includes(name), orgName: (c && !c.error && c.name) || c2.getAuth?.()?.org_name || null, note };
128
132
  };
129
133
  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 };
134
+ 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
135
  }
132
136
  // Interactive start: a clean screen under the intro (never when piped; NO_COLOR still clears),
133
137
  // except straight after Getting started, whose finale must stay readable.
@@ -153,13 +157,24 @@ export async function main(argv = process.argv.slice(2)) {
153
157
  const restartSlot = opts.demo ? null : (process.env[RESTART_SLOT_ENV] || null); delete process.env[RESTART_SLOT_ENV]; // one hand-over only
154
158
  const restartAttach = opts.demo ? null : (process.env[RESTART_ATTACH_ENV] || null); delete process.env[RESTART_ATTACH_ENV];
155
159
  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
160
+ 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
161
  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
162
  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 }));
163
+ 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
164
  try { await app.waitUntilExit(); screenRec?.stop(); transcript?.end(restart ? 'restart' : 'exit'); }
160
165
  catch (e) { screenRec?.stop(); transcript?.end(`crash: ${e?.message || e}`); throw e; }
161
166
  finally { screenRec?.stop(); process.stdout.off('resize', onResize); }
162
167
  if (!restart) return;
168
+ if (restart.connect) { // ONBOARD-P2: "Connect to a different server?" said y; the connect flow owns the terminal (certificate card, sign-in)
169
+ restoreTerminal();
170
+ const { connectServer } = await import('./onboarding/connectServer.js');
171
+ const { profileNameForUrl } = await import('../core/profileTrust.js');
172
+ const { confirmInteractive } = await import('../core/confirm.js');
173
+ const profile = profileNameForUrl(restart.connect);
174
+ 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 });
175
+ if (c.action !== 'connected') { process.exitCode = 1; return; }
176
+ restart = { profile };
177
+ }
163
178
  if (restart.update) { // /update said y: the update owns the terminal (npm prompts, sudo), then the same restart either way
164
179
  const { runUpdate } = await import('../core/selfUpdate.js');
165
180
  const plan = restart.update;
@@ -174,7 +189,7 @@ export async function main(argv = process.argv.slice(2)) {
174
189
  }
175
190
  }
176
191
  // 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 };
192
+ 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
193
  // QUOXON-RESTART-LEAK: under the launcher this process EXITS and the launcher runs the successor, so exactly one
179
194
  // client stays alive per terminal. Without one (QUOX_NO_LAUNCHER=1) it falls back to waiting on a nested child.
180
195
  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
 
@@ -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
+ }
@@ -18,6 +18,7 @@ import * as T from './copy.js';
18
18
  import { matchIntent, yesNo, parseConnect } from './intent.js';
19
19
  import { loadJourney, saveJourney, clearJourney, resumeStep } from './journey.js';
20
20
  import { pendingRows, runPreflight, preflightPassed, detectOllama, defaultDeps } from './preflight.js';
21
+ import { probeLocalQuox } from './probe.js';
21
22
  import { credentialOptions, KEY_PROVIDERS, normaliseOllamaUrl, nextSelectable } from './credential.js';
22
23
  import { needsConsent, providerLabel } from '../consent.js';
23
24
  import { HelloIntro, helloIntroMs } from '../qmotion.js';
@@ -101,8 +102,8 @@ const Hint = ({ text, cols }) => h(Box, { paddingLeft: 2 }, h(Text, { color: c('
101
102
 
102
103
  // ---- the view ---------------------------------------------------------------------------------
103
104
  export function GettingStarted({
104
- columns, version = '0.4.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
105
- journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findOllama = detectOllama,
105
+ columns, version = '0.5.0', cwd = '', welcome = false, animateIntro, earlyInput = null,
106
+ journeyPath, dryRun = false, runner = null, preflightDeps, runChecks = runPreflight, findLocal = probeLocalQuox, findOllama = detectOllama,
106
107
  requestCredential, broker = null, finisher = null, onExit = () => {}, now = () => Date.now()
107
108
  }) {
108
109
  const { stdin, setRawMode, isRawModeSupported } = useStdin();
@@ -179,7 +180,7 @@ export function GettingStarted({
179
180
 
180
181
  function offerLines() {
181
182
  const items = [{ kind: 'guide', text: T.OFFER }];
182
- items.push({ kind: 'ask', text: T.OFFER_ASK }, { kind: 'dim', text: T.ALREADY });
183
+ items.push({ kind: 'ask', text: T.OFFER_ASK }, { kind: 'dim', text: T.CHOICES }, { kind: 'dim', text: T.ALREADY });
183
184
  push(...items);
184
185
  set({ step: 'offer' });
185
186
  persist();
@@ -218,6 +219,10 @@ export function GettingStarted({
218
219
  if (text.startsWith('/')) { onSlash(text); return; }
219
220
  if (s.step === 'ask') return onRequest(text);
220
221
  if (s.step === 'offer' || s.step === 'declined') {
222
+ const pick = text.toLowerCase();
223
+ if (pick === 'a') { push({ kind: 'user', text }); startPreflight(); return; } // install here: the same flow as yes
224
+ if (pick === 'b') { push({ kind: 'user', text }, { kind: 'dim', text: T.LOCAL_CHECKING }); useLocal(); return; }
225
+ if (pick === 'c') { push({ kind: 'user', text }, { kind: 'guide', text: T.CONNECT_ELSEWHERE }); return; }
221
226
  const yn = yesNo(text);
222
227
  if (yn === true) { push({ kind: 'user', text: text || 'yes' }); startPreflight(); return; }
223
228
  if (yn === false) { push({ kind: 'user', text }, { kind: 'guide', text: T.DECLINED }, { kind: 'dim', text: T.DECLINED_HINT }); set({ step: 'declined' }); persist(); return; }
@@ -225,6 +230,14 @@ export function GettingStarted({
225
230
  }
226
231
  }
227
232
 
233
+ async function useLocal() {
234
+ const f = await findLocal();
235
+ if (!live.current || S.current.done) return;
236
+ if (!f.found) { push({ kind: 'guide', tone: 'warn', text: T.LOCAL_NONE }); return; }
237
+ push({ kind: 'guide', tone: 'ok', text: T.localFound(f.url) }, { kind: 'guide', text: T.connectChecking(f.url) });
238
+ exit({ action: 'connect', url: f.url });
239
+ }
240
+
228
241
  async function startPreflight() {
229
242
  set({ step: 'preflight', rows: pendingRows(), fixNote: null });
230
243
  persist();
@@ -8,7 +8,29 @@ export const orgRows = (list) => (Array.isArray(list) ? list : []).map((o) => ({
8
8
  * query found in the name or slug (so "acme widgets" finds "Acme Widgets Ltd"). Never guesses between ties.
9
9
  * -> { org } | { ambiguous: [orgs] } | { none: true }
10
10
  */
11
+ const VERBS = new Set(['switch', 'swith', 'swich', 'sw', 'to', 'go', 'use']);
12
+ /** Drop ONE leading verb-ish token ("swith quox dev" -> "quox dev"); a lone verb is kept (it may be the name). */
13
+ export function stripOrgVerb(query) {
14
+ const m = /^(\S+)\s+(\S[\s\S]*)$/.exec(String(query ?? '').trim());
15
+ return m && VERBS.has(m[1].toLowerCase()) ? m[2].trim() : String(query ?? '').trim();
16
+ }
17
+
18
+ /** Orgs for a picker when nothing matched uniquely: those hitting the most query words (verb stripped), at least one. */
19
+ export function orgCandidates(query, list) {
20
+ const words = norm(stripOrgVerb(query)).split(' ').filter(Boolean);
21
+ const scored = orgRows(list).map((o) => { const hay = `${norm(o.name)} ${norm(o.slug)}`; return { o, n: words.filter((w) => hay.includes(w)).length }; });
22
+ const best = Math.max(0, ...scored.map((x) => x.n));
23
+ return best ? scored.filter((x) => x.n === best).map((x) => x.o) : [];
24
+ }
25
+
11
26
  export function matchOrg(query, list) {
27
+ const first = matchOrgExact(query, list);
28
+ if (!first.none) return first;
29
+ const stripped = stripOrgVerb(query);
30
+ return stripped !== String(query ?? '').trim() ? matchOrgExact(stripped, list) : first;
31
+ }
32
+
33
+ function matchOrgExact(query, list) {
12
34
  const rows = orgRows(list);
13
35
  const q = norm(query);
14
36
  if (!q) return { none: true };
@@ -4,6 +4,7 @@
4
4
  * Exit codes: 0 completed, 2 held for approval, 3 interrupted, 4 auth, 5 unconfirmed
5
5
  * (stream closed without turn.finished), 1 other.
6
6
  */
7
+ import { eventLine } from '../core/announce.js';
7
8
  import { textGate } from './events.js';
8
9
  import { streamFields, fallbackNote } from './model.js';
9
10
  import { connectOfferFor } from './connect.js';
@@ -60,6 +61,7 @@ export async function runPrint({ api, prompt, agent = 'quox', output = 'text', c
60
61
  else if (ev.type === 'done' && !text && ev.full_response) text = ev.full_response;
61
62
  else if (ev.type === 'tool.started' && !seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; }
62
63
  else if (ev.type === 'tool.finished') { if (!seen.has(ev.call_id)) { seen.add(ev.call_id); sum.tools++; } if (ev.receipt?.ward_seq != null) sum.receipts.push(ev.receipt.ward_seq); }
64
+ else if (ev.type === 'quox_event') { if (output !== 'json') { const q = eventLine(ev); if (q) err(`${q}\n`); } } // QUOX-ANNOUNCE A2: stdout stays the answer
63
65
  else if (ev.type === 'approval.requested') { sawApproval = true; sum.approvals++; }
64
66
  else if (ev.type === 'approval.resolved') sawApproval = false;
65
67
  else if (ev.type === 'turn.objective_suggested' && ev.objective_suggested?.goal) objective = ev.objective_suggested; // QUOXON objective offer
@@ -23,6 +23,13 @@ export const switchAskLine = (profile, human) => askText('Switch?', {
23
23
  action: `switch this window to your login '${human}' (same conversation, attach, slot and groups)`,
24
24
  });
25
25
 
26
+ /** The startup offer on an agent login (owner 2026-10-28): restart as the human profile; /profile lists the rest. */
27
+ export const startupSwitchAskLine = (profile, human, others = 0) => askText(`Restart as you (${human})?`, {
28
+ title: `This window would run as the agent '${profile}'.`,
29
+ action: `restart this window as '${human}' (same directory, nothing else changes)`,
30
+ reason: others > 0 ? `${others} other signed-in profile${others === 1 ? '' : 's'}: /profile lists them` : '',
31
+ });
32
+
26
33
  /** The ask card for a /slot name that does not exist (the near misses are said on the line above). */
27
34
  export const slotCreateAskLine = (nm) => askText('Create it?', { title: `No slot ${nm}.`, action: `create and hold slot ${nm}` });
28
35