quoxcli 0.2.0 → 0.3.1
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/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/admin.js +26 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +2 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +30 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +6 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +86 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +163 -27
- package/lib/commands/agentAttach.js +94 -6
- package/lib/commands/approvals.js +73 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +57 -2
- package/lib/commands/claude.js +34 -8
- package/lib/commands/contacts.js +49 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +17 -6
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +311 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +178 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/licence.js +14 -155
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +11 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +11 -1
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +38 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +64 -9
- package/lib/commands/server.js +292 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/skills.js +48 -5
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/core/actingAs.js +2 -2
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +144 -35
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/config.js +142 -24
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/global-flags.js +105 -5
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +4 -4
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +504 -25
- package/lib/quoxon/app.js +3000 -230
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +363 -38
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +44 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +244 -25
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +49 -6
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +92 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +24 -3
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +9 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +58 -37
- package/lib/quoxon/render.js +638 -119
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +95 -9
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +172 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +21 -7
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/restart.js +71 -13
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +4 -1
- package/lib/core/websiteSession.js +0 -148
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON handoff offer (owner, live 2026-10-17 19:51). Attached to TRIAGE-Q, the owner asked which VMs use the most CPU
|
|
3
|
+
* and memory; TRIAGE-Q had no fleet tools and told him to detach and ask CommanderQ. Offer, don't instruct: the server
|
|
4
|
+
* sends turn.handoff_suggested (from the turn's tool selection, never the answer text) and the TUI asks once:
|
|
5
|
+
*
|
|
6
|
+
* ⚠ TRIAGE-Q asks: Hand this question to CommanderQ?
|
|
7
|
+
* do ask CommanderQ "which VMs use the most CPU and memory" once; you stay attached to TRIAGE-Q
|
|
8
|
+
* why TRIAGE-Q can't read VM stats.
|
|
9
|
+
* ▶ Hand this question to CommanderQ? y yes · n no
|
|
10
|
+
*
|
|
11
|
+
* y sends the SAME question to CommanderQ for that one turn; the window stays attached. n does nothing.
|
|
12
|
+
*/
|
|
13
|
+
import { askText } from './ask.js';
|
|
14
|
+
|
|
15
|
+
const clipQ = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
|
16
|
+
|
|
17
|
+
/** The suggestion from a turn event, when it applies to THIS window (attached to the agent it names). Else null. */
|
|
18
|
+
export function handoffFromEvent(ev, attachedAgent) {
|
|
19
|
+
if (!ev || ev.type !== 'turn.handoff_suggested') return null;
|
|
20
|
+
const h = ev.handoff_suggested;
|
|
21
|
+
const at = String(attachedAgent || '').toLowerCase();
|
|
22
|
+
if (!h || typeof h !== 'object' || !h.agent || !at) return null;
|
|
23
|
+
if (String(h.agent).toLowerCase() === at) return null; // already talking to the target
|
|
24
|
+
if (h.from && String(h.from).toLowerCase() !== at) return null; // a suggestion about some other agent
|
|
25
|
+
return { agent: String(h.agent), name: String(h.name || h.agent), from: at, fromName: String(h.from_name || at.toUpperCase()), reason: String(h.reason || '').trim() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The ask line (drawn as the approval-style card). question: the user's own words being handed over. */
|
|
29
|
+
export function handoffAskText(h, question, cols = 100) {
|
|
30
|
+
const action = `ask ${h.name} "${clipQ(question, Math.max(20, cols - 60))}" once; you stay attached to ${h.fromName}`;
|
|
31
|
+
return askText(`Hand this question to ${h.name}?`, { keys: 'y/N', who: h.fromName, reason: h.reason, action });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The one line after a yes-path answer: who answered, and that nothing about the attach changed. */
|
|
35
|
+
export const handoffAnsweredNote = (h) => `↪ ${h.name} answered that one. Still attached to ${h.fromName}.`;
|
|
36
|
+
|
|
37
|
+
/** The agent's own suggested command, dropped because the handoff answers the question (never silently). */
|
|
38
|
+
export const handoffSupersededNote = (h, line) => `↷ ${h.fromName} also suggested ${line || 'a command'}; not offered, because ${h.name} can answer the question itself.`;
|
|
39
|
+
|
|
40
|
+
/** A handoff that could not be offered because a plan card is open (never silently). */
|
|
41
|
+
export const handoffSkippedNote = (h) => `↷ ${h.name} could answer this, but the plan card is open, so the handoff is not offered.`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* QUOXON handoff streak (owner, live 05:10-05:17): attached to TRIAGE-Q he asked three infra questions and every one ended
|
|
45
|
+
* in "Hand this question to CommanderQ?". When the SAME attached agent has suggested a handoff on 2 turns in a row, the
|
|
46
|
+
* card offers the bigger move (back to CommanderQ) instead of the same small one. c ("keep asking") silences the bigger
|
|
47
|
+
* offer until a new streak starts; a turn with no suggestion, or a different agent, breaks the streak.
|
|
48
|
+
*/
|
|
49
|
+
export function createHandoffStreak() {
|
|
50
|
+
let agent = null, count = 0, muted = false;
|
|
51
|
+
return {
|
|
52
|
+
/** A turn ended with a handoff suggestion from `from`. -> { count, escalate } */
|
|
53
|
+
offered(from) {
|
|
54
|
+
const f = String(from || '').toLowerCase();
|
|
55
|
+
if (f === agent) count += 1; else { agent = f; count = 1; muted = false; }
|
|
56
|
+
return { count, escalate: count >= 2 && !muted };
|
|
57
|
+
},
|
|
58
|
+
/** A turn ended without a suggestion (or the window moved agents): the streak is over. */
|
|
59
|
+
clear() { agent = null; count = 0; muted = false; },
|
|
60
|
+
/** n (just this one): the bigger offer needs a fresh run of two before it returns. */
|
|
61
|
+
restart() { count = 0; },
|
|
62
|
+
/** c (keep asking): no bigger offer until a new streak starts. */
|
|
63
|
+
mute() { muted = true; },
|
|
64
|
+
peek: () => ({ agent, count, muted }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const HANDOFF_BIG_KEYS = (h) => `y switch · n just this one · c keep asking ${h.fromName}`;
|
|
69
|
+
|
|
70
|
+
/** The bigger card: offer to move the whole window back instead of handing over one more question. */
|
|
71
|
+
export function handoffBiggerAskText(h, count) {
|
|
72
|
+
return askText(`Switch this window back to ${h.name}?`, {
|
|
73
|
+
keys: HANDOFF_BIG_KEYS(h),
|
|
74
|
+
who: h.fromName,
|
|
75
|
+
reason: `${h.fromName} has handed off your last ${count} questions`,
|
|
76
|
+
action: `/detach: this window stops being attached to ${h.fromName} and talks to ${h.name}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /hat helpers (pure), quox#1183. Match a hat the way a person names it (key, display name, "the DevOps hat", a unique
|
|
3
|
+
* prefix) and the lines /hat prints. Never guesses between ties; an unknown name is { none: true }.
|
|
4
|
+
*/
|
|
5
|
+
const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().replace(/^the /, '').replace(/ hat$/, '').trim();
|
|
6
|
+
|
|
7
|
+
export const isPrivileged = (hat) => hat?.definition?.transitions?.privileged === true || hat?.privileged === true;
|
|
8
|
+
|
|
9
|
+
/** -> { hat } | { ambiguous: [hats] } | { none: true } */
|
|
10
|
+
export function matchHat(query, hats) {
|
|
11
|
+
const rows = Array.isArray(hats) ? hats.filter(Boolean) : [];
|
|
12
|
+
const raw = String(query ?? '').trim();
|
|
13
|
+
const q = norm(raw);
|
|
14
|
+
if (!q) return { none: true };
|
|
15
|
+
const byId = rows.filter((h) => h.id && h.id === raw);
|
|
16
|
+
if (byId.length === 1) return { hat: byId[0] };
|
|
17
|
+
const exact = rows.filter((h) => norm(h.key) === q || norm(h.name) === q);
|
|
18
|
+
if (exact.length === 1) return { hat: exact[0] };
|
|
19
|
+
if (exact.length > 1) return { ambiguous: exact };
|
|
20
|
+
const pre = rows.filter((h) => norm(h.key).startsWith(q) || norm(h.name).startsWith(q));
|
|
21
|
+
if (pre.length === 1) return { hat: pre[0] };
|
|
22
|
+
if (pre.length > 1) return { ambiguous: pre };
|
|
23
|
+
return { none: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** "key (Name)" for each hat, for an error that tells you what to type. */
|
|
27
|
+
export const hatChoices = (hats) => (Array.isArray(hats) ? hats : []).map((h) => (h.name && h.name !== h.key ? `${h.key} (${h.name})` : h.key)).join(', ');
|
|
28
|
+
|
|
29
|
+
/** One line per hat; the active one is marked with *. */
|
|
30
|
+
export function hatLines(hats, activeKey) {
|
|
31
|
+
const rows = Array.isArray(hats) ? hats : [];
|
|
32
|
+
const w = Math.min(28, Math.max(4, ...rows.map((h) => [...String(h.name || h.key)].length)));
|
|
33
|
+
return rows.map((h) => `${h.key === activeKey ? '* ' : ' '}${String(h.name || h.key).padEnd(w)} ${h.key}${isPrivileged(h) ? ' (privileged: asks you first)' : ''}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A conversation id in the collector's own shape (turnStream ensureConversationId), for a window with none yet. */
|
|
37
|
+
export const mintConversationId = (uuid) => `QXC_${uuid}`;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON-LATENCY: the headless half of the conversation client (`quox --print`), with no
|
|
3
|
+
* React / Ink / App import. bin/quox.js routes --print here directly, so a headless turn
|
|
4
|
+
* no longer pays ~2 s of renderer load before its request is sent. main.js re-exports
|
|
5
|
+
* every name below, so the interactive client and its importers are unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import { parseModelArg } from './model.js';
|
|
8
|
+
import { loadRules, rulesContext } from './rules.js';
|
|
9
|
+
|
|
10
|
+
const FLAGS = new Set(['-p', '--profile', '--verbose', '--demo', '--print', '--output', '--conversation', '--turn-id', '--timeout', '--color', '--no-clear', '-n', '--name', '--service-key', '--demo-onboarding', '--autoplay', '--autoplay-exit', '--provider', '--model', '--agent', '--prompt-profile', '--resume', '-h', '--help', '--']);
|
|
11
|
+
const isFlag = (a) => FLAGS.has(a) || /^--(profile|verbose|print|output|conversation|turn-id|timeout|provider|model|agent|prompt-profile|resume)=/.test(a);
|
|
12
|
+
|
|
13
|
+
export function parseArgs(argv) {
|
|
14
|
+
const o = { profile: null, verbose: 'on', demo: false, help: false, print: false, prompt: null, output: 'text', conversation: null, turnId: null, timeout: 0, color: false, noClear: false, name: null, serviceKey: false, demoOnboarding: false, autoplay: false, autoplayExit: null, provider: null, agent: null, resume: null, verboseSet: false, errors: [] };
|
|
15
|
+
let rawProvider = null, rawModel = null;
|
|
16
|
+
const value = (flag, i) => { const v = argv[i + 1]; if (v === undefined || (v.startsWith('-') && isFlag(v))) { o.errors.push(`${flag} needs a value`); return undefined; } return v; };
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
if (a === '--') { const rest = argv.slice(i + 1).join(' '); if (rest) o.prompt = rest; break; }
|
|
20
|
+
if (a === '-p' || a === '--profile') { const v = value(a, i); if (v !== undefined) { o.profile = v; i++; } }
|
|
21
|
+
else if (a.startsWith('--profile=')) o.profile = a.slice(10);
|
|
22
|
+
else if (a === '--verbose') { o.verboseSet = true; const v = argv[i + 1]; if (['off', 'on', 'full'].includes(v)) { o.verbose = v; i++; } else o.verbose = 'on'; } // no value means on
|
|
23
|
+
else if (a.startsWith('--verbose=')) { o.verboseSet = true; o.verbose = a.slice(10); }
|
|
24
|
+
else if (a === '--resume') { const v = argv[i + 1]; if (v !== undefined && !v.startsWith('-')) { o.resume = v; i++; } else o.resume = true; } // alone: the picker
|
|
25
|
+
else if (a.startsWith('--resume=')) o.resume = a.slice(9) || true;
|
|
26
|
+
else if (a === '-n' || a === '--name') { const v = value(a, i); if (v !== undefined) { o.name = v; i++; } }
|
|
27
|
+
else if (a.startsWith('--name=')) o.name = a.slice(7);
|
|
28
|
+
else if (a === '--demo') o.demo = true;
|
|
29
|
+
else if (a === '--autoplay') o.autoplay = true;
|
|
30
|
+
else if (a === '--autoplay-exit') { o.autoplay = true; const v = argv[i + 1]; if (v !== undefined && /^\d+(\.\d+)?$/.test(v)) { o.autoplayExit = Number(v); i++; } else o.autoplayExit = 5; } // seconds to hold the final frame
|
|
31
|
+
else if (a === '--demo-onboarding') o.demoOnboarding = true;
|
|
32
|
+
else if (a === '--print') { o.print = true; const n = argv[i + 1]; if (n !== undefined && !isFlag(n)) { o.prompt = n; i++; } }
|
|
33
|
+
else if (a.startsWith('--print=')) { o.print = true; o.prompt = a.slice(8); }
|
|
34
|
+
else if (a === '--output') { const v = value(a, i); if (v !== undefined) { o.output = v; i++; } }
|
|
35
|
+
else if (a.startsWith('--output=')) o.output = a.slice(9);
|
|
36
|
+
else if (a === '--conversation') { const v = value(a, i); if (v !== undefined) { o.conversation = v; i++; } }
|
|
37
|
+
else if (a === '--agent') { const v = value(a, i); if (v !== undefined) { o.agent = v; i++; } }
|
|
38
|
+
else if (a.startsWith('--agent=')) o.agent = a.slice(8) || null;
|
|
39
|
+
else if (a === '--turn-id') { const v = value(a, i); if (v !== undefined) { o.turnId = v; i++; } }
|
|
40
|
+
else if (a === '--timeout') { const v = value(a, i); if (v !== undefined) { o.timeout = Number(v) || 0; i++; } }
|
|
41
|
+
else if (a === '--color') o.color = true;
|
|
42
|
+
else if (a === '--no-clear') o.noClear = true;
|
|
43
|
+
else if (a === '--service-key') o.serviceKey = true;
|
|
44
|
+
else if (a === '--provider' || a === '--model') { const v = value(a, i); if (v !== undefined) { if (a === '--provider') rawProvider = v; else rawModel = v; i++; } }
|
|
45
|
+
else if (a.startsWith('--provider=')) rawProvider = a.slice(11);
|
|
46
|
+
else if (a.startsWith('--model=')) rawModel = a.slice(8);
|
|
47
|
+
else if (a === '--prompt-profile') { const v = value(a, i); if (v !== undefined) { o.promptProfile = v; i++; } }
|
|
48
|
+
else if (a.startsWith('--prompt-profile=')) o.promptProfile = a.slice(17);
|
|
49
|
+
else if (a === '-h' || a === '--help') o.help = true;
|
|
50
|
+
else if (!a.startsWith('-') && o.prompt === null && argv.includes('--print')) o.prompt = a; // `--print` given earlier or later; this is its prompt
|
|
51
|
+
else o.errors.push(a.startsWith('-') ? `unknown option ${a}` : `unexpected argument ${a}`);
|
|
52
|
+
}
|
|
53
|
+
if (rawModel && !rawProvider) o.errors.push('--model needs --provider (e.g. --provider openai --model gpt-5.4)');
|
|
54
|
+
else if (rawProvider) {
|
|
55
|
+
const m = parseModelArg(rawModel ? `${rawProvider} ${rawModel}` : rawProvider);
|
|
56
|
+
if (!m.ok) o.errors.push(m.error); else o.provider = m.choice || 'auto'; // 'auto' = this run ignores the saved /model choice
|
|
57
|
+
}
|
|
58
|
+
if (o.promptProfile !== undefined && !['compact', 'full'].includes(o.promptProfile)) { o.errors.push(`invalid --prompt-profile "${o.promptProfile}" (use compact or full)`); delete o.promptProfile; }
|
|
59
|
+
if (!['off', 'on', 'full'].includes(o.verbose)) o.verbose = 'on';
|
|
60
|
+
if (!['text', 'json'].includes(o.output)) { o.errors.push(`invalid --output "${o.output}" (use text or json)`); o.output = 'text'; }
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const USAGE = 'quox [-p|--profile <name>] [-n|--name <session name>] [--conversation <id>] [--resume [id|name]] [--verbose off|on|full] [--demo [--autoplay [--autoplay-exit [s]]]] [--no-clear] [--service-key] [--provider openai|anthropic|ollama|openrouter|huggingface|groq|together|lmstudio|vllm|custom|auto [--model <id>]] (quoxon is an alias; the old menu is `quox tui`)\n quox --conversation <id> resume that conversation interactively (its saved turns load into the window)\n quox --resume [id|name] pick up a recent conversation; alone it opens a picker\n quox --print "<prompt>" [--output text|json] [--conversation <id>] [--turn-id <id>] [--timeout <s>] [--agent <id>] [--provider <p> [--model <id>]] [--prompt-profile compact|full] (prompt from stdin if absent or -; use -- before a prompt that starts with -)\n exit: 0 completed, 1 other, 2 held for approval, 3 interrupted or abandoned, 4 not signed in, 5 ended without server confirmation\n --provider asks the server for that provider this run (default: your /model choice for this profile, else auto); turn.started from the server says what ran\n --service-key lets chat use an ambient QUOX_SERVICE_KEY when the profile has no login (never used silently)\n --demo plays a simulated journey on its own (no server, nothing real runs)\n --autoplay (with --demo, or QUOX_DEMO_AUTOPLAY=1) plays the whole journey with no input on a fixed script; --autoplay-exit [s] quits s seconds (default 5) after the last frame\n --demo-onboarding walks Getting started with a dry-run install (nothing is downloaded, built or started)';
|
|
65
|
+
|
|
66
|
+
/** Default profile: a resident (agent) login cannot chat, so use the single logged-in human profile. */
|
|
67
|
+
export function pickProfile(explicit, cfgMod, env = process.env) {
|
|
68
|
+
if (explicit) return { name: explicit, note: null };
|
|
69
|
+
if (env.QUOX_PROFILE) return { name: env.QUOX_PROFILE, note: null }; // an explicit one-shot choice (same as -p), never "the default profile"
|
|
70
|
+
|
|
71
|
+
const probe = cfgMod.loadConfig();
|
|
72
|
+
const active = probe.getActiveProfileName?.();
|
|
73
|
+
const residents = new Set((probe.listResidentProfiles?.() || []).map(r => r.name));
|
|
74
|
+
return choosePick(active, residents, probe.listProfiles?.() || []);
|
|
75
|
+
}
|
|
76
|
+
export function choosePick(active, residents, profiles) {
|
|
77
|
+
if (!residents.has(active)) return { name: undefined, note: null };
|
|
78
|
+
const humans = profiles.filter(p => !residents.has(p.name) && p.loggedIn);
|
|
79
|
+
if (humans.length === 1) return { name: humans[0].name, note: null }; // the status line shows the profile; no transcript noise
|
|
80
|
+
const names = profiles.filter(p => !residents.has(p.name) && p.loggedIn).map(p => p.name);
|
|
81
|
+
const hint = names.length ? ` Pass -p <your profile>. Human profiles found: ${names.join(', ')} (start with: quox -p ${names[0]}).` : ' Pass -p <your profile>.';
|
|
82
|
+
return { name: undefined, note: `The default profile "${active}" is an agent login, so this chat runs as the agent, not as a person.${hint}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Chat identity gate. A named profile that does not exist is an error (never created on the fly), and an
|
|
87
|
+
* ambient QUOX_SERVICE_KEY is only a credential when the user asked for it (--service-key or FORCE).
|
|
88
|
+
* -> null when chat may proceed, else { message }.
|
|
89
|
+
*/
|
|
90
|
+
export function chatCredentialProblem({ profileArg, profiles, hasStoredLogin, profileName, env = process.env, serviceKeyFlag = false }) {
|
|
91
|
+
if (profileArg && !profiles.some(p => p.name === profileArg)) return { message: `profile ${profileArg} does not exist; create it with: quox login -p ${profileArg}` };
|
|
92
|
+
if (hasStoredLogin) return null;
|
|
93
|
+
if ((serviceKeyFlag || env.QUOX_FORCE_SERVICE_KEY) && env.QUOX_SERVICE_KEY) return null;
|
|
94
|
+
return { message: `Not signed in for chat on profile ${profileName}. Run: quox login -p ${profileName}${env.QUOX_SERVICE_KEY ? ' (an ambient QUOX_SERVICE_KEY is ignored unless you pass --service-key)' : ''}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Is this a machine with no Quox server configured at all? Then bare `quox` is Getting started, and --print
|
|
99
|
+
* says how to get there. Reads profiles only (no network).
|
|
100
|
+
*/
|
|
101
|
+
export async function noServerConfigured(opts, cfgMod, env = process.env) {
|
|
102
|
+
const { needsGettingStarted } = await import('./onboarding/detect.js');
|
|
103
|
+
const { loadJourney } = await import('./onboarding/journey.js');
|
|
104
|
+
const probe = cfgMod.loadConfig();
|
|
105
|
+
return needsGettingStarted({ profiles: probe.listProfiles?.() || [], residents: probe.listResidentProfiles?.() || [], profileArg: opts.profile, env, serviceKeyFlag: opts.serviceKey, journey: loadJourney() });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** --print without a usable login: today's message, plus the way in when no server exists yet. */
|
|
109
|
+
export function printAuthMessage(problem, gettingStarted, hint) {
|
|
110
|
+
return `quox: ${problem.message}\n${gettingStarted ? `quox: ${hint}\n` : ''}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function mainPrint(opts) {
|
|
114
|
+
const { runPrint, resolvePrompt, EXIT } = await import('./print.js');
|
|
115
|
+
const out = (s) => process.stdout.write(s);
|
|
116
|
+
const err = (s) => process.stderr.write(s);
|
|
117
|
+
const prompt = await resolvePrompt(opts.prompt, process.stdin);
|
|
118
|
+
let api;
|
|
119
|
+
if (opts.demo) api = (await import('./demo.js')).createDemoApi({ speed: 20 });
|
|
120
|
+
else {
|
|
121
|
+
const cfgMod = await import('../core/config.js');
|
|
122
|
+
const { createAuth } = await import('../core/auth.js');
|
|
123
|
+
const { createClient } = await import('../core/client.js');
|
|
124
|
+
const { createApi } = await import('./api.js');
|
|
125
|
+
const probe = cfgMod.loadConfig();
|
|
126
|
+
const profiles = probe.listProfiles?.() || [];
|
|
127
|
+
const missing = opts.profile && !profiles.some(p => p.name === opts.profile);
|
|
128
|
+
const pick = pickProfile(opts.profile, cfgMod);
|
|
129
|
+
if (pick.note) err(pick.note + '\n');
|
|
130
|
+
const config = missing ? probe : cfgMod.loadConfig(pick.name);
|
|
131
|
+
const auth = createAuth(config);
|
|
132
|
+
const pname = missing ? opts.profile : (config.getActiveProfileName?.() || 'default');
|
|
133
|
+
const problem = chatCredentialProblem({ profileArg: opts.profile, profiles, hasStoredLogin: missing ? false : Boolean(config.getAuth?.()?.access_token), profileName: pname, serviceKeyFlag: opts.serviceKey });
|
|
134
|
+
if (problem) {
|
|
135
|
+
const { NO_SERVER_PRINT } = await import('./onboarding/copy.js');
|
|
136
|
+
err(printAuthMessage(problem, await noServerConfigured(opts, cfgMod), NO_SERVER_PRINT));
|
|
137
|
+
process.exitCode = EXIT.auth; return;
|
|
138
|
+
}
|
|
139
|
+
const { createWindowClient } = await import('./windowOrg.js');
|
|
140
|
+
api = createApi({ ...createWindowClient({ config, auth, createClient }), config }); // #1034: /org stays in this process
|
|
141
|
+
if (opts.conversation && api.conversationExists) {
|
|
142
|
+
const c = await api.conversationExists(opts.conversation);
|
|
143
|
+
if (c.exists === false) { err(`quox: conversation not found: ${opts.conversation}\n`); process.exitCode = EXIT.error; return; }
|
|
144
|
+
if (c.exists === null) err(`quox: could not verify conversation ${opts.conversation} (${c.reason}); continuing\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const rules = opts.demo ? null : loadRules();
|
|
148
|
+
for (const s of rules?.skipped || []) err(`quox: ignoring ${s.path}: ${s.reason}\n`);
|
|
149
|
+
const choice = opts.provider === 'auto' ? null : opts.provider || api.modelChoice?.() || null;
|
|
150
|
+
process.exitCode = await runPrint({ api, choice, agent: opts.agent || undefined, promptProfile: opts.promptProfile, context: rulesContext(rules), prompt, output: opts.output, conversation: opts.conversation, turnId: opts.turnId || undefined, timeoutS: opts.timeout, color: opts.color, profile: opts.profile || null, out, err });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** bin/quox.js entry for --print: the same arg checks main() makes, then the headless turn. */
|
|
154
|
+
export async function runHeadless(argv) {
|
|
155
|
+
const opts = parseArgs(argv);
|
|
156
|
+
if (!opts.print) return false; // not a headless run after all (e.g. `-- --print` is a prompt): main() owns it
|
|
157
|
+
if (opts.help) { console.log(USAGE); return true; }
|
|
158
|
+
if (opts.errors.length) { process.stderr.write(`quox: ${opts.errors.join('; ')}\n${USAGE}\n`); process.exitCode = 1; return true; }
|
|
159
|
+
await mainPrint(opts);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Z2H-R3: which address to PRINT for the dashboard and for the host installer. Over SSH to a fresh
|
|
3
|
+
* install the profile is https://localhost:3000, which only works on the Quox box, so a printed
|
|
4
|
+
* localhost is wrong for anyone reading it elsewhere. Order: the server's app URL (app_base_url),
|
|
5
|
+
* else the address this CLI reached the server on, else the address the install finale showed,
|
|
6
|
+
* else this machine's detected LAN address. Loopback is the last resort, never the first answer.
|
|
7
|
+
*/
|
|
8
|
+
import { networkInterfaces } from 'node:os';
|
|
9
|
+
|
|
10
|
+
export const isLoopbackUrl = (u) => {
|
|
11
|
+
try { return /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|::1|0\.0\.0\.0)$/i.test(new URL(u).hostname); } catch { return true; }
|
|
12
|
+
};
|
|
13
|
+
const ok = (u) => typeof u === 'string' && /^https?:\/\/[^\s/]+/.test(u) && !isLoopbackUrl(u);
|
|
14
|
+
const origin = (u) => new URL(u).origin;
|
|
15
|
+
|
|
16
|
+
/** First non-internal IPv4 address (private ranges preferred), or null. */
|
|
17
|
+
export function detectLanIp(ifaces = networkInterfaces()) {
|
|
18
|
+
const all = Object.entries(ifaces || {}).filter(([n]) => !/^(docker|br-|veth|virbr|cni|flannel|cali|tun|tap)/i.test(n)).flatMap(([, v]) => v || [])
|
|
19
|
+
.filter(a => a && a.family === 'IPv4' && !a.internal && a.address);
|
|
20
|
+
const priv = all.find(a => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address));
|
|
21
|
+
return (priv || all[0])?.address || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Dashboard base: { url, source } . source: server | profile | install | lan | local. */
|
|
25
|
+
export function resolveDashboardBase({ serverApp, profileUrl, journeyUrl, lanIp } = {}) {
|
|
26
|
+
if (ok(serverApp)) return { url: origin(serverApp), source: 'server' };
|
|
27
|
+
if (ok(profileUrl)) return { url: origin(profileUrl), source: 'profile' };
|
|
28
|
+
if (ok(journeyUrl)) return { url: origin(journeyUrl), source: 'install' };
|
|
29
|
+
if (lanIp) return { url: `https://${lanIp}:3000`, source: 'lan' };
|
|
30
|
+
return { url: profileUrl || 'https://localhost:3000', source: 'local' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Base that serves GET /install (the collector, :9848): { url, source }. */
|
|
34
|
+
export function resolveCollectorBase({ collectorUrl, serverApp, journeyUrl, lanIp } = {}) {
|
|
35
|
+
if (ok(collectorUrl)) return { url: origin(collectorUrl), source: 'profile' };
|
|
36
|
+
for (const [u, source] of [[serverApp, 'server'], [journeyUrl, 'install']]) {
|
|
37
|
+
if (ok(u)) return { url: `http://${new URL(u).hostname}:9848`, source };
|
|
38
|
+
}
|
|
39
|
+
if (lanIp) return { url: `http://${lanIp}:9848`, source: 'lan' };
|
|
40
|
+
return { url: collectorUrl || null, source: 'local' };
|
|
41
|
+
}
|
package/lib/quoxon/indicator.js
CHANGED
|
@@ -64,8 +64,10 @@ export function normUsage(u, doneCost) {
|
|
|
64
64
|
const tokens = Number.isFinite(Number(out)) && out != null ? Number(out)
|
|
65
65
|
: Number.isFinite(Number(total)) && total != null ? Number(total)
|
|
66
66
|
: Number.isFinite(Number(inp)) && inp != null ? Number(inp) : null;
|
|
67
|
+
// cost_source 'claude-cli': the claude CLI's own API-equivalent estimate on the subscription path, not a price.
|
|
68
|
+
const costSource = typeof o.cost_source === 'string' ? o.cost_source : null;
|
|
67
69
|
const cost = Number(o.cost_usd ?? doneCost);
|
|
68
|
-
return { tokens, cost: Number.isFinite(cost) && cost > 0 ? cost : null, real: tokens != null };
|
|
70
|
+
return { tokens, cost: !costSource && Number.isFinite(cost) && cost > 0 ? cost : null, real: tokens != null, costSource };
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
/** Live token figure: the real number once the server reported it, else an estimate from received text (~chars/4). */
|
|
@@ -82,7 +84,9 @@ export function turnTokens(turn) {
|
|
|
82
84
|
* Sans Mono fallback (ェエ like I/E, ュョユヨ boxes, ニ "=", ノ "/", フ "7", ヘ "^", リ "U", レ "L", ロ a box, ー a dash)
|
|
83
85
|
* and the voiced marks ゙ ゚.
|
|
84
86
|
*/
|
|
85
|
-
|
|
87
|
+
/** Decode glyphs: every half-width katakana (U+FF66-FF9D, one cell each, minus the long-vowel bar ー) plus the
|
|
88
|
+
* code-rain digits. Owner 2026-10-15: the reveal felt like "just a few characters" with 44 look-alike kana. */
|
|
89
|
+
export const SCRAMBLE_GLYPHS = 'ヲァィゥェォャュョッアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789';
|
|
86
90
|
|
|
87
91
|
/** Deterministic hash in [0,1). */
|
|
88
92
|
const hsh = (a, b, c = 0) => { let x = (a * 374761393 + b * 668265263 + c * 2147483647) | 0; x = Math.imul(x ^ (x >>> 13), 1274126177); return ((x ^ (x >>> 16)) >>> 0) / 4294967296; };
|
package/lib/quoxon/intent.js
CHANGED
|
@@ -13,14 +13,25 @@ const PANELS = [
|
|
|
13
13
|
function PANEL_RULES() { return PANELS.map(([re, cmd]) => [re, () => cmd]); }
|
|
14
14
|
|
|
15
15
|
const RULES = [
|
|
16
|
-
|
|
16
|
+
// owner 2026-10-15: "attach to TRIAGE-Q agent" / "reattach to ..." took four turns; the tip must catch them
|
|
17
|
+
[new RegExp(`^(?:please )?(?:re)?attach(?: me)?(?: to)?(?: the)? ${WORD}(?: agent)?(?: please)?$`, 'i'), (m) => `/attach ${m[1].toLowerCase()}`],
|
|
17
18
|
[/^rename (?:this|the|my) (?:session|conversation|chat) (?:to|as) ([\w.-]+(?: [\w.-]+){0,2})$/i, (m) => m[1].split(' ').some(w => /^(and|then|but|so|please)$/i.test(w)) ? null : `/rename ${m[1]}`],
|
|
18
19
|
[/^(?:what(?:'s| is) running|what is running now)$/i, () => '/roster'],
|
|
19
20
|
[/^(?:show|list|open) (?:my |the )?(?:pending )?approvals$/i, () => '/approvals'],
|
|
20
21
|
[/^(?:what(?:'s| is)|show me what(?:'s| is)) (?:waiting|pending) (?:for )?(?:my )?approval$/i, () => '/approvals'],
|
|
22
|
+
[/^(?:so )?(?:what (?:did i miss|have i missed)(?: (?:while|when) i was (?:away|out|gone|offline))?|what (?:happened|changed) (?:while|when) i was (?:away|out|gone|offline)|(?:did )?anything happen(?:ed)? while i was (?:away|out|gone|offline)|catch me up)$/i, () => '/away'],
|
|
23
|
+
[/^(?:(?:give me |show me |can i get )?(?:a |the )?sit ?rep(?: please)?|what(?:'s| is) the situation(?: now)?|situation report(?: please)?)$/i, () => '/sitrep'],
|
|
24
|
+
[/^(?:how(?:'s| is) (?:the |my |our )?fleet(?: doing)?|(?:show|list) (?:me )?(?:the |my |our )?fleet|fleet (?:status|health)|what(?:'s| is) (?:the )?(?:state|health) of (?:the |my |our )?(?:fleet|computers|servers))$/i, () => '/fleet'],
|
|
25
|
+
[/^(?:open|show|take me to|go to) (?:me )?(?:to )?(?:the |my )?(?:quox )?(?:web )?dashboard$/i, () => '/dashboard'],
|
|
21
26
|
[/^detach(?: from (?:the )?agent)?$/i, () => '/detach'],
|
|
27
|
+
[/^(?:clear|cls|clear (?:the )?screen)$/i, () => '/clear'], // owner typed bare "clear" (2026-10-15)
|
|
22
28
|
[/^(?:start )?(?:a )?new conversation$/i, () => '/new'],
|
|
23
29
|
[/^(?:please )?(?:compact|summari[sz]e and compact|compress) (?:this|the|our|my) (?:conversation|chat|session|context)(?: please)?$/i, () => '/compact'],
|
|
30
|
+
[/^(?:please )?(?:update|upgrade) (?:the |my |this )?(?:quox )?(?:server|install|instance|quox)(?: (?:to (?:the )?latest|please))?$/i, () => 'quox server update --dry-run (in a terminal; shows the plan first)'],
|
|
31
|
+
[/^(?:please )?roll ?back (?:the |my |this )?(?:quox )?(?:server|install|instance|update)$/i, () => 'quox server rollback --dry-run (in a terminal)'],
|
|
32
|
+
[/^(?:please )?uninstall (?:the |my |this )?(?:quox )?(?:server|install|instance|quox)$/i, () => 'quox server uninstall --dry-run (in a terminal; lists what goes first)'],
|
|
33
|
+
[/^(?:what version is (?:the |my )?(?:quox )?server(?: on| running)?|is (?:the |my )?(?:quox )?server (?:up to date|healthy)|server status)$/i, () => '/server'],
|
|
34
|
+
[/^(?:make|create|write) (?:a )?(?:support|diag(?:nostic)?s?) bundle$/i, () => 'quox server diag (in a terminal; written locally, never uploaded)'],
|
|
24
35
|
...PANEL_RULES()
|
|
25
36
|
];
|
|
26
37
|
|
package/lib/quoxon/keys.js
CHANGED
|
@@ -5,8 +5,21 @@
|
|
|
5
5
|
*
|
|
6
6
|
* parseChunk(chunk, state) -> { events, state }
|
|
7
7
|
* Events: { type: 'text', text } | { type: 'paste', text } | { type: 'key', name, ctrl?, alt? } | { type: 'focus', focused }
|
|
8
|
+
* | { type: 'mouse', action: 'press'|'release'|'drag'|'move'|'wheel', button, x, y, shift, alt, ctrl } (SGR 1006, QUOXON-MOUSE)
|
|
9
|
+
* | { type: 'cursor', row, col } (a cursor position report, the answer to DSR ESC[6n)
|
|
8
10
|
*/
|
|
9
11
|
|
|
12
|
+
const BUTTONS = ['left', 'middle', 'right', 'none'];
|
|
13
|
+
const WHEEL = ['up', 'down', 'left', 'right'];
|
|
14
|
+
/** One SGR mouse report (ESC [ < b ; x ; y M|m) as an event. */
|
|
15
|
+
function mouseEvent(b, x, y, final) {
|
|
16
|
+
const low = b & 3;
|
|
17
|
+
const mods = { shift: Boolean(b & 4), alt: Boolean(b & 8), ctrl: Boolean(b & 16) };
|
|
18
|
+
if (b & 64) return { type: 'mouse', action: 'wheel', button: WHEEL[low], x, y, ...mods };
|
|
19
|
+
if (b & 32) return { type: 'mouse', action: low === 3 ? 'move' : 'drag', button: BUTTONS[low], x, y, ...mods };
|
|
20
|
+
return { type: 'mouse', action: final === 'm' ? 'release' : 'press', button: BUTTONS[low], x, y, ...mods };
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
export const PASTE_ON = '\x1b[?2004h';
|
|
11
24
|
export const PASTE_OFF = '\x1b[?2004l';
|
|
12
25
|
const PASTE_START = '\x1b[200~';
|
|
@@ -42,18 +55,27 @@ const CSI_KEYS = {
|
|
|
42
55
|
'\x1b[1~': 'home', '\x1b[4~': 'end', '\x1b[7~': 'home', '\x1b[8~': 'end',
|
|
43
56
|
'\x1b[3~': 'delete', '\x1b[Z': 'shift-tab',
|
|
44
57
|
'\x1b[13;2u': 'shift-enter', '\x1b[27;2;13~': 'shift-enter', '\x1b\r': 'shift-enter', '\x1b\n': 'shift-enter',
|
|
45
|
-
'\x1b[1;5D': 'word-left', '\x1b[1;5C': 'word-right', '\x1b[1;3D': 'word-left', '\x1b[1;3C': 'word-right'
|
|
58
|
+
'\x1b[1;5D': 'word-left', '\x1b[1;5C': 'word-right', '\x1b[1;3D': 'word-left', '\x1b[1;3C': 'word-right',
|
|
59
|
+
// COMPOSER-EDIT (owner 2026-10-17: edit like Claude Code's prompt box). Alt+B/F (macOS Option+Left/Right), iTerm2 Alt+arrows,
|
|
60
|
+
// Alt+Backspace, Alt+D, Ctrl/Alt+Delete, and Shift (+Ctrl/Alt) arrows, Home and End for selection.
|
|
61
|
+
'\x1bb': 'word-left', '\x1bf': 'word-right', '\x1b\x1b[D': 'word-left', '\x1b\x1b[C': 'word-right',
|
|
62
|
+
'\x1b\x7f': 'delete-word', '\x1b\x08': 'delete-word', '\x1bd': 'delete-word-forward',
|
|
63
|
+
'\x1b[3;5~': 'delete-word-forward', '\x1b[3;3~': 'delete-word-forward',
|
|
64
|
+
'\x1b[1;2D': 'select-left', '\x1b[1;2C': 'select-right', '\x1b[1;2A': 'select-up', '\x1b[1;2B': 'select-down',
|
|
65
|
+
'\x1b[1;2H': 'select-home', '\x1b[1;2F': 'select-end',
|
|
66
|
+
'\x1b[1;6D': 'select-word-left', '\x1b[1;6C': 'select-word-right', '\x1b[1;4D': 'select-word-left', '\x1b[1;4C': 'select-word-right'
|
|
46
67
|
};
|
|
47
68
|
const CSI_LIST = Object.keys(CSI_KEYS).sort((a, b) => b.length - a.length);
|
|
48
69
|
|
|
49
70
|
const CTRL = {
|
|
50
71
|
'\x01': 'home', '\x05': 'end', '\x03': 'ctrl-c', '\x04': 'ctrl-d', '\x07': 'ctrl-g',
|
|
51
|
-
'\x0f': 'ctrl-o', '\x13': 'ctrl-s', '\x15': 'kill-line', '\x17': 'delete-word',
|
|
72
|
+
'\x0b': 'kill-line-forward', '\x0f': 'ctrl-o', '\x13': 'ctrl-s', '\x15': 'kill-line', '\x17': 'delete-word',
|
|
52
73
|
'\x1f': 'undo', '\x19': 'redo', '\x0a': 'newline', '\x7f': 'backspace', '\x08': 'backspace',
|
|
53
74
|
'\r': 'enter', '\t': 'tab', '\x02': 'left', '\x06': 'right'
|
|
54
75
|
};
|
|
55
76
|
|
|
56
|
-
|
|
77
|
+
/** `draft` = the composer text before this chunk (a burst split mid-line continues the line already typed). */
|
|
78
|
+
export function parseChunk(chunk, state = initialKeyState(), draft = '') {
|
|
57
79
|
const events = [];
|
|
58
80
|
let s = String(chunk);
|
|
59
81
|
let st = { partial: '', ...state };
|
|
@@ -77,6 +99,12 @@ export function parseChunk(chunk, state = initialKeyState()) {
|
|
|
77
99
|
if (s[0] === '\x1b') {
|
|
78
100
|
// Focus reporting (CSI ?1004h, enabled by sound/index.js): CSI I = focus in, CSI O = focus out.
|
|
79
101
|
if (s.startsWith('\x1b[I') || s.startsWith('\x1b[O')) { events.push({ type: 'focus', focused: s[2] === 'I' }); s = s.slice(3); continue; }
|
|
102
|
+
// QUOXON-MOUSE: SGR mouse reports; a report split across chunks is held, never typed into the composer.
|
|
103
|
+
const mm = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(s);
|
|
104
|
+
if (mm) { events.push(mouseEvent(Number(mm[1]), Number(mm[2]), Number(mm[3]), mm[4])); s = s.slice(mm[0].length); continue; }
|
|
105
|
+
if (/^\x1b\[<[\d;]*$/.test(s)) { st.partial = s; s = ''; break; }
|
|
106
|
+
const cr = /^\x1b\[(\d+);(\d+)R/.exec(s);
|
|
107
|
+
if (cr) { events.push({ type: 'cursor', row: Number(cr[1]), col: Number(cr[2]) }); s = s.slice(cr[0].length); continue; }
|
|
80
108
|
const hit = CSI_LIST.find(k => s.startsWith(k));
|
|
81
109
|
if (hit) { events.push({ type: 'key', name: CSI_KEYS[hit] }); s = s.slice(hit.length); continue; }
|
|
82
110
|
// Unknown escape sequence: swallow ESC [ ... final byte, or a bare ESC+char (alt-x).
|
|
@@ -93,19 +121,34 @@ export function parseChunk(chunk, state = initialKeyState()) {
|
|
|
93
121
|
if (CTRL[c]) { events.push({ type: 'key', name: CTRL[c] }); }
|
|
94
122
|
s = s.slice(1);
|
|
95
123
|
}
|
|
96
|
-
return { events: coalesceBurst(events), state: st };
|
|
124
|
+
return { events: coalesceBurst(events, draft), state: st };
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
/**
|
|
100
128
|
* If a chunk produced text and then an Enter/newline FOLLOWED by more text, it is
|
|
101
129
|
* a non-bracketed paste: fold it into one paste event. A trailing single Enter
|
|
102
130
|
* (text then Enter at the end) stays as text + enter so fast typing still submits.
|
|
131
|
+
*
|
|
132
|
+
* Except a burst whose every line is a slash command (R3 D3): that is typed input batched by tmux,
|
|
133
|
+
* SSH/mosh or a script ("/help" Enter "/model x" Enter), never a paste, so each Enter runs its line.
|
|
134
|
+
* Folding it made a multi-line "/" draft that trapped every later Enter.
|
|
103
135
|
*/
|
|
104
|
-
|
|
136
|
+
const isEnter = (e) => e.type === 'key' && (e.name === 'enter' || e.name === 'newline');
|
|
137
|
+
function commandLines(events, draft = '') {
|
|
138
|
+
let line = draft.includes('\n') ? '\n' : draft; // a multi-line draft is never a command line
|
|
139
|
+
for (const e of events) {
|
|
140
|
+
if (e.type === 'text') line += e.text;
|
|
141
|
+
else if (e.name === 'enter') { if (!line.startsWith('/')) return false; line = ''; }
|
|
142
|
+
else return false; // newline (Ctrl+J), tab or any other key: not plain typed commands
|
|
143
|
+
}
|
|
144
|
+
return line === '' || line.startsWith('/');
|
|
145
|
+
}
|
|
146
|
+
function coalesceBurst(events, draft = '') {
|
|
105
147
|
const hasInner = events.some((e, i) =>
|
|
106
|
-
|
|
148
|
+
isEnter(e) && i > 0 && i < events.length - 1 &&
|
|
107
149
|
events.slice(i + 1).some(x => x.type === 'text'));
|
|
108
150
|
if (!hasInner) return events;
|
|
151
|
+
if (commandLines(events, draft)) return events;
|
|
109
152
|
let text = '';
|
|
110
153
|
const rest = [];
|
|
111
154
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON-RESTART-LEAK: the conversation client's top-level launcher.
|
|
3
|
+
*
|
|
4
|
+
* Before this, /restart (and /update, /restart auto, a profile switch that restarts) ran the successor with
|
|
5
|
+
* spawnSync from INSIDE the old client and waited for it. The old client froze but stayed resident with its
|
|
6
|
+
* whole heap, so every restart nested one more TUI under the last: the owner's terminal reached 98 nested
|
|
7
|
+
* clients and 15 GB RSS (on the dev box, 2026-10-17).
|
|
8
|
+
*
|
|
9
|
+
* Now the first interactive `quox` on a terminal becomes this launcher: no React, no Ink, no api, no timers,
|
|
10
|
+
* never touches stdin. It runs the client as its child and waits. A client that wants a restart writes a
|
|
11
|
+
* handoff file (what to run, the one-shot env it carries) and exits with RESTART_EXIT_CODE; the launcher
|
|
12
|
+
* reads it, deletes it, and runs the successor. Any other exit is passed straight through. So after any
|
|
13
|
+
* number of restarts there is exactly one client process per terminal (plus this launcher).
|
|
14
|
+
*
|
|
15
|
+
* Tiny on purpose: it must import nothing heavy (bin/quox.js loads it before React, Ink or any command).
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir, constants as osConstants } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** The client asks its launcher for a restart with this exit code AND a handoff file (both, never one alone). */
|
|
23
|
+
export const RESTART_EXIT_CODE = 75;
|
|
24
|
+
/** Env var naming the handoff file. Read and DELETED by the client at startup, so nothing it spawns inherits it. */
|
|
25
|
+
export const LAUNCHER_HANDOFF_ENV = 'QUOX_LAUNCHER_HANDOFF';
|
|
26
|
+
/** QUOX_NO_LAUNCHER=1 turns the launcher off (the client then restarts the old, nested way). */
|
|
27
|
+
export const NO_LAUNCHER_ENV = 'QUOX_NO_LAUNCHER';
|
|
28
|
+
|
|
29
|
+
const ONE_SHOT = /^QUOX_RESTART_/; // carried into ONE successor only, never re-applied by the launcher
|
|
30
|
+
|
|
31
|
+
let handoff; // undefined = not read yet; null = not launched; { path, base } = launched
|
|
32
|
+
/**
|
|
33
|
+
* Client side, once at startup: am I running under a launcher? Reads and deletes the env var (a shell or
|
|
34
|
+
* `quox` the client spawns must never think it is the launched client). -> { path, base } | null.
|
|
35
|
+
*/
|
|
36
|
+
export function takeLauncherHandoff(env = process.env) {
|
|
37
|
+
if (env === process.env && handoff !== undefined) return handoff;
|
|
38
|
+
const path = env[LAUNCHER_HANDOFF_ENV] || null;
|
|
39
|
+
delete env[LAUNCHER_HANDOFF_ENV];
|
|
40
|
+
const r = path ? { path, base: { ...env } } : null; // base: the env as launched, to send only what a restart changes
|
|
41
|
+
if (env === process.env) handoff = r;
|
|
42
|
+
return r;
|
|
43
|
+
}
|
|
44
|
+
/** Test hook: forget the cached handoff. */
|
|
45
|
+
export function resetLauncherHandoff() { handoff = undefined; }
|
|
46
|
+
|
|
47
|
+
/** Should this invocation become the launcher? Only an interactive client on a real terminal, never a launched child. */
|
|
48
|
+
export function shouldLaunch({ argv = [], env = process.env, isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY) } = {}) {
|
|
49
|
+
if (!isTTY || env[LAUNCHER_HANDOFF_ENV] || env[NO_LAUNCHER_ENV] === '1') return false;
|
|
50
|
+
const head = argv.indexOf('--') === -1 ? argv : argv.slice(0, argv.indexOf('--'));
|
|
51
|
+
return !head.some(a => a === '--print' || a.startsWith('--print=') || a === '-h' || a === '--help');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Client side: hand a restart to the launcher. `plan` is { cmd, args, env } (restartPlan in slash/restart.js);
|
|
56
|
+
* only the env entries that differ from the launched env are written (the launcher adds them to its own).
|
|
57
|
+
* -> true when the handoff file was written (the caller then exits with RESTART_EXIT_CODE).
|
|
58
|
+
*/
|
|
59
|
+
export function writeRestartHandoff(h, plan, { write = writeFileSync } = {}) {
|
|
60
|
+
if (!h?.path || !plan?.cmd) return false;
|
|
61
|
+
const env = {};
|
|
62
|
+
for (const [k, v] of Object.entries(plan.env || {})) if (v !== undefined && (ONE_SHOT.test(k) || h.base[k] !== v)) env[k] = v; // a one-shot always rides: the launcher strips them between runs
|
|
63
|
+
try { write(h.path, JSON.stringify({ cmd: plan.cmd, args: plan.args || [], env }), { mode: 0o600 }); return true; }
|
|
64
|
+
catch { return false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Launcher side: run the client, and again each time it asks for a restart. Resolves with the exit code to
|
|
69
|
+
* leave with. `deps` lets tests stand in for spawn and the handoff directory.
|
|
70
|
+
*/
|
|
71
|
+
export async function runLauncher({ argv = [], execPath = process.execPath, script = process.argv[1], env = process.env, deps = {}, onSpawn = null } = {}) {
|
|
72
|
+
const d = { spawn, mkdtemp: () => mkdtempSync(join(tmpdir(), 'quox-launch-')), read: (p) => readFileSync(p, 'utf8'), rm: (p) => rmSync(p, { force: true, recursive: true }), signals: process, ...deps };
|
|
73
|
+
let dir;
|
|
74
|
+
try { dir = d.mkdtemp(); } catch { return null; } // no handoff dir: the caller runs the client in-process (old behaviour)
|
|
75
|
+
const file = join(dir, 'restart.json');
|
|
76
|
+
const carry = (e) => Object.fromEntries(Object.entries(e).filter(([k]) => !ONE_SHOT.test(k) && k !== LAUNCHER_HANDOFF_ENV)); // later runs never re-apply a one-shot
|
|
77
|
+
let child = null;
|
|
78
|
+
const forward = (sig) => () => { try { child?.kill(sig); } catch { /* gone */ } };
|
|
79
|
+
const handlers = { SIGINT: () => {}, SIGQUIT: () => {}, SIGTERM: forward('SIGTERM'), SIGHUP: forward('SIGHUP') }; // ^C belongs to the client (same process group)
|
|
80
|
+
for (const [s, f] of Object.entries(handlers)) d.signals.on(s, f);
|
|
81
|
+
let next = { cmd: execPath, args: [script, ...argv], env: { ...env } }; // the first run keeps any one-shot it was started with
|
|
82
|
+
try {
|
|
83
|
+
for (;;) {
|
|
84
|
+
d.rm(file);
|
|
85
|
+
const r = await new Promise((done) => {
|
|
86
|
+
try { child = d.spawn(next.cmd, next.args, { stdio: 'inherit', env: { ...next.env, [LAUNCHER_HANDOFF_ENV]: file } }); }
|
|
87
|
+
catch (e) { done({ error: e }); return; }
|
|
88
|
+
onSpawn?.(child);
|
|
89
|
+
child.once('error', (e) => done({ error: e }));
|
|
90
|
+
child.once('exit', (code, signal) => done({ code, signal }));
|
|
91
|
+
});
|
|
92
|
+
child = null;
|
|
93
|
+
if (r.error) { process.stderr.write(`quox: could not start ${next.cmd} (${r.error.code || r.error.message})\n`); return 1; }
|
|
94
|
+
let ask = null;
|
|
95
|
+
if (r.code === RESTART_EXIT_CODE) { try { ask = JSON.parse(d.read(file)); } catch { ask = null; } }
|
|
96
|
+
if (!ask?.cmd) return r.signal ? 128 + (osConstants.signals[r.signal] || 0) : (r.code ?? 1);
|
|
97
|
+
next = { cmd: ask.cmd, args: Array.isArray(ask.args) ? ask.args : [], env: { ...carry(next.env), ...(ask.env || {}) } }; // the env the client was launched with, plus what its restart changed
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
for (const [s, f] of Object.entries(handlers)) d.signals.off(s, f);
|
|
101
|
+
d.rm(dir);
|
|
102
|
+
}
|
|
103
|
+
}
|