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.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/quoxon/main.js
CHANGED
|
@@ -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.
|
|
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();
|
package/lib/quoxon/markdown.js
CHANGED
|
@@ -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
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
|
|
68
68
|
}
|
|
69
69
|
|
package/lib/quoxon/model.js
CHANGED
|
@@ -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
|
|
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
|
|
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 || '?'} · ${
|
|
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 {
|
|
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,
|
|
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.
|
|
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();
|
package/lib/quoxon/orgSwitch.js
CHANGED
|
@@ -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 };
|
package/lib/quoxon/print.js
CHANGED
|
@@ -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
|
|