quoxcli 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/actions.js +15 -0
- package/lib/api/endpoints/admin.js +27 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +295 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +47 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +61 -7
- package/lib/commands/approvals.js +198 -3
- package/lib/commands/ask.js +4 -2
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +142 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +7 -33
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +155 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +1123 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +318 -0
- 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 +53 -3
- package/lib/commands/offload.js +2 -3
- package/lib/commands/org.js +21 -4
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +87 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +20 -2
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +150 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +24 -5
- 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/vm.js +2 -2
- 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/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -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 +37 -3
- package/lib/core/commandRegistrars.js +17 -0
- package/lib/core/config.js +29 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +136 -8
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +73 -7
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +8 -8
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +146 -0
- package/lib/quoxon/api.js +364 -27
- package/lib/quoxon/app.js +1629 -183
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +64 -8
- package/lib/quoxon/approvalSentence.js +48 -0
- package/lib/quoxon/approvalsView.js +124 -7
- 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 +22 -3
- package/lib/quoxon/commands.js +110 -15
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +285 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +160 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +67 -10
- package/lib/quoxon/extensions.js +636 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +182 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +4 -4
- package/lib/quoxon/loopCard.js +460 -0
- package/lib/quoxon/loopCards.js +189 -0
- package/lib/quoxon/loopStream.js +98 -0
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +29 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- 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/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +49 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +8 -3
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +187 -41
- package/lib/quoxon/roster.js +36 -17
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -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/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +139 -2
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +23 -2
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +31 -4
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- 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 +34 -3
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/toolCards.js +30 -0
- 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 +81 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +4 -2
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- 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/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -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/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
|
@@ -16,6 +16,10 @@ export function paletteQuery(draft) {
|
|
|
16
16
|
return t.slice(1).toLowerCase();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const TRANSIENT = /not running|not reachable|unreachable|could not reach|cannot reach|connection (refused|reset)|timed? ?out|econn|enotfound|fetch failed|service unavailable|\b50[234]\b/i;
|
|
20
|
+
/** Does this capability reason read as a failed dynamic check rather than a standing fact about the command? */
|
|
21
|
+
export const transientReason = (r) => TRANSIENT.test(String(r || ''));
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
24
|
* Why a command cannot run in this session, or null. `api` is the session's server wrapper (a method
|
|
21
25
|
* missing means this mode has no route for it); `caps` is GET /api/v1/capabilities when the server has
|
|
@@ -23,7 +27,10 @@ export function paletteQuery(draft) {
|
|
|
23
27
|
*/
|
|
24
28
|
export function unavailableReason(entry, { api = {}, caps = null, blocked = null } = {}) {
|
|
25
29
|
const cap = caps ? caps[entry.cap || entry.id] : null;
|
|
26
|
-
|
|
30
|
+
// A reason that is really a failed probe ("Auth service not running or not reachable...") is an error string, not a
|
|
31
|
+
// property of the command: it never becomes the palette note. The row keeps its static description; running the
|
|
32
|
+
// command reports the real failure.
|
|
33
|
+
if (cap && cap.available === false && !transientReason(cap.reason)) return cap.reason || 'not available on this server';
|
|
27
34
|
for (const m of entry.needs || []) if (typeof api[m] !== 'function') return 'not available in this mode';
|
|
28
35
|
if (blocked && entry.server) return '(sign in)';
|
|
29
36
|
return null;
|
|
@@ -96,12 +103,26 @@ export function filterPalette(items, query) {
|
|
|
96
103
|
}
|
|
97
104
|
if (best == null) return;
|
|
98
105
|
if (exact(it)) best = -1;
|
|
99
|
-
if (it.disabled) best += 0.5; // a
|
|
106
|
+
if (it.disabled) best += best >= 10 ? 1000 : 0.5; // a non-prefix match on an unavailable command ranks below every working match; a prefix keeps its place on a tie
|
|
100
107
|
scored.push({ it, best, idx });
|
|
101
108
|
});
|
|
102
109
|
return scored.sort((a, b) => a.best - b.best || a.idx - b.idx).map(x => x.it);
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
/** Bulk or destructive commands: never offered (so never pre-highlighted) by a loose query (owner 2026-10-26: Enter on "/" approved everything). */
|
|
113
|
+
export const GUARDED_IDS = new Set(['yestoall', 'approve', 'deny']);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drops GUARDED_IDS rows unless the query is an exact name, or a 3+ letter prefix of the name that no unguarded command
|
|
117
|
+
* shares. So they are reachable only by typing enough of the name to make them the unique match.
|
|
118
|
+
*/
|
|
119
|
+
export function guardBulk(list, query) {
|
|
120
|
+
const q = String(query ?? '').toLowerCase();
|
|
121
|
+
const names = (it) => [it.id, ...it.aliases.map(a => a.slice(1))].map(n => n.toLowerCase());
|
|
122
|
+
const shared = list.some(o => !GUARDED_IDS.has(o.id) && names(o).some(n => q && n.startsWith(q)));
|
|
123
|
+
return list.filter(it => !GUARDED_IDS.has(it.id) || names(it).includes(q) || (q.length >= 3 && !shared && names(it).some(n => n.startsWith(q))));
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
/** What Tab puts in the draft: the name, plus a space when the command takes arguments. */
|
|
106
127
|
export function completion(item) {
|
|
107
128
|
const takesArgs = /^\S+\s+\S/.test(String(item.usage).trim());
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { findEntry } from '../commands.js';
|
|
7
7
|
import { signedOutNotice } from '../sessionDead.js';
|
|
8
|
-
import { paletteQuery, paletteItems, filterPalette, completion, paletteView, PALETTE_MAX } from './palette.js';
|
|
8
|
+
import { paletteQuery, paletteItems, filterPalette, guardBulk, completion, paletteView, PALETTE_MAX } from './palette.js';
|
|
9
9
|
import { argContext, levelFor, staticItems, rankArgs, applyArg } from './args.js';
|
|
10
10
|
import { mentionContext, rankMentions, applyMention } from '../mentions.js';
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ export function createPalette({
|
|
|
21
21
|
getDraft, setDraft, run, say, ctx = () => ({}), source = null, refresh = () => {}, now = Date.now,
|
|
22
22
|
mentions = null // MENTIONS: { cache (mentions.js createMentionCache), local: () => receipt items }
|
|
23
23
|
}) {
|
|
24
|
-
const st = { sel: 0, q: null, dismissed: null, picked: false, aq: null, mq: null };
|
|
24
|
+
const st = { hold: null, sel: 0, q: null, dismissed: null, picked: false, aq: null, mq: null };
|
|
25
25
|
const cache = new Map(); // 'src:param' -> { at, ttl, items, pending }
|
|
26
26
|
/** Cached items for an async source; fetches when missing or stale. Never throws or blocks. */
|
|
27
27
|
const fetchItems = (src, param) => {
|
|
@@ -77,10 +77,14 @@ export function createPalette({
|
|
|
77
77
|
const rows = () => {
|
|
78
78
|
const q = paletteQuery(getDraft());
|
|
79
79
|
if (q !== st.q) { st.q = q; st.sel = 0; st.picked = false; }
|
|
80
|
-
return q === null ? [] : filterPalette(paletteItems(ctx()), q);
|
|
80
|
+
return q === null ? [] : guardBulk(filterPalette(paletteItems(ctx()), q), q);
|
|
81
81
|
};
|
|
82
82
|
const isOpen = () => {
|
|
83
83
|
const d = getDraft();
|
|
84
|
+
if (st.hold !== null) { // a command was just submitted: closed until the draft is genuinely edited (owner 2026-10-27: a stale palette ran /sleep)
|
|
85
|
+
if (d === st.hold || d === '') return false;
|
|
86
|
+
st.hold = null;
|
|
87
|
+
}
|
|
84
88
|
if (st.dismissed !== null && st.dismissed !== d) st.dismissed = null;
|
|
85
89
|
if (paletteQuery(d) === null) return argOpen() !== null || mentionOpen() !== null;
|
|
86
90
|
return st.dismissed !== d;
|
|
@@ -165,5 +169,7 @@ export function createPalette({
|
|
|
165
169
|
hint: '↑↓ select · Tab complete · Enter run · Esc close'
|
|
166
170
|
};
|
|
167
171
|
}
|
|
168
|
-
|
|
172
|
+
/** The app calls this when Enter submits a command line: the palette closes and that Enter activates nothing. */
|
|
173
|
+
const submitted = (draft = getDraft()) => { st.hold = String(draft ?? ''); st.sel = 0; st.picked = false; };
|
|
174
|
+
return { isOpen, onKey, view, submitted, state: st };
|
|
169
175
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI-QPE (owner 2026-10-19): /qpe 0|1|2|3|4, how many Quox Proof Engine lines show above the status bar.
|
|
3
|
+
* The lines are the ones whose status-row JSON says grid "qpe" (quox-qpe-row all prints four: headline, movement,
|
|
4
|
+
* coverage, and the cli.quox.ai content line, owner 2026-10-20). The count is a CLI setting
|
|
5
|
+
* on the active profile (quoxon.qpeRows); unset shows every QPE row. 0 hides them; the rows keep refreshing, so
|
|
6
|
+
* turning them back on shows a current value at once. Narrow windows still drop the coverage row (min_cols).
|
|
7
|
+
*/
|
|
8
|
+
export const QPE_GRID = 'qpe';
|
|
9
|
+
export const QPE_MAX = 4;
|
|
10
|
+
|
|
11
|
+
/** The saved count, or null (show all) when unset or not a whole number 0..4. */
|
|
12
|
+
export function qpeRowsSetting(settings) {
|
|
13
|
+
const n = settings?.qpeRows;
|
|
14
|
+
return Number.isInteger(n) && n >= 0 && n <= QPE_MAX ? n : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "/qpe <arg>" -> { text, tone, save? }. save is the value to store under qpeRows. Pure. */
|
|
18
|
+
export function qpeCommand(arg, settings) {
|
|
19
|
+
const words = String(arg || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
20
|
+
const cur = qpeRowsSetting(settings);
|
|
21
|
+
const now = cur == null ? 'all 3 proof rows and the content row' : String(cur);
|
|
22
|
+
if (!words.length || (words[0] === 'rows' && words.length === 1)) {
|
|
23
|
+
return { text: `QPE rows above the status bar: ${now}. /qpe 0|1|2|3|4 or /qpe all changes it (0 hides them, 4 adds content).` };
|
|
24
|
+
}
|
|
25
|
+
// Owner typed `/qpe 3` and `/qpe all` (2026-10-20): "rows" is optional and words map to counts.
|
|
26
|
+
const vals = words[0] === 'rows' ? words.slice(1) : words;
|
|
27
|
+
const WORD = { all: 3, full: 3, on: 3, show: 3, max: 3, off: 0, hide: 0, none: 0, hidden: 0 };
|
|
28
|
+
const v = vals.length === 1 ? (/^[0-4]$/.test(vals[0]) ? Number(vals[0]) : WORD[vals[0]]) : undefined;
|
|
29
|
+
if (v === undefined) {
|
|
30
|
+
return { text: 'Usage: /qpe 0|1|2|3|4, or all / off (0 hides the proof engine rows, 3 shows headline, movement and coverage, 4 adds content).', tone: 'warn' };
|
|
31
|
+
}
|
|
32
|
+
const n = v;
|
|
33
|
+
const what = ['hidden', 'headline only', 'headline and movement', 'headline, movement and coverage', 'headline, movement, coverage and content'][n];
|
|
34
|
+
return { text: `QPE rows: ${n} (${what}). Saved to this profile.`, tone: 'ok', save: n };
|
|
35
|
+
}
|
|
@@ -83,7 +83,14 @@ export function settleMsFrom(env = process.env) {
|
|
|
83
83
|
return env.QUOX_RESTART_SETTLE_MS != null && env.QUOX_RESTART_SETTLE_MS !== '' && Number.isFinite(n) && n >= 0 ? n : RESTART_SETTLE_MS;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
export
|
|
86
|
+
export const RESTART_MIN_GAP_MS = 15 * 60 * 1000; // no auto-restart offer this soon after this process started (builds seen meanwhile are remembered)
|
|
87
|
+
|
|
88
|
+
export function minGapMsFrom(env = process.env) {
|
|
89
|
+
const n = Number(env.QUOX_RESTART_MIN_GAP_MS);
|
|
90
|
+
return env.QUOX_RESTART_MIN_GAP_MS != null && env.QUOX_RESTART_MIN_GAP_MS !== '' && Number.isFinite(n) && n >= 0 ? n : RESTART_MIN_GAP_MS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function startBuildWatch({ onNotice, everyMs = 60000, check = newerBuild, repeat = false, buildId = installedBuildId, settleMs = 0, minGapMs = 0, now = Date.now, ...opts } = {}) {
|
|
87
94
|
let last; // repeat mode: the last build announced
|
|
88
95
|
let seen; let seenAt = 0; let seenNotice = null; // repeat mode: the build waiting to settle, and when it last changed
|
|
89
96
|
const bootAt = now();
|
|
@@ -99,6 +106,7 @@ export function startBuildWatch({ onNotice, everyMs = 60000, check = newerBuild,
|
|
|
99
106
|
seenNotice = t;
|
|
100
107
|
const t0 = now() - bootAt < RESTART_BOOT_WINDOW_MS ? Math.max(seenAt, bootAt) : seenAt;
|
|
101
108
|
if (now() - t0 < settleMs) return;
|
|
109
|
+
if (now() - bootAt < minGapMs) return; // min gap since start: remembered in seen, offered once the gap has passed
|
|
102
110
|
last = b;
|
|
103
111
|
onNotice(seenNotice, b);
|
|
104
112
|
}, everyMs);
|
|
@@ -162,6 +170,8 @@ export const RESTART_NOTE_ENV = 'QUOX_RESTART_NOTE';
|
|
|
162
170
|
export const RESTART_ORG_ENV = 'QUOX_RESTART_ORG';
|
|
163
171
|
/** Env var carrying the window's /mode (PLAN MODE: only 'plan' is carried; ask is the default) to the restarted successor. */
|
|
164
172
|
export const RESTART_MODE_ENV = 'QUOX_RESTART_MODE';
|
|
173
|
+
/** Env var carrying the slot this window declined to reattach to ("n" at the startup ask), so a restart does not re-ask. */
|
|
174
|
+
export const RESTART_DECLINED_ENV = 'QUOX_RESTART_DECLINED';
|
|
165
175
|
|
|
166
176
|
/** Terminal modes the client turns on (bracketed paste, focus reporting, hidden cursor): all off before the hand-over. */
|
|
167
177
|
export const TERMINAL_RESTORE = '\x1b[?2004l\x1b[?1004l\x1b[?25h\x1b[?1006l\x1b[?1002l\x1b[?1000l'; // bracketed paste, focus, cursor; mouse tracking off (QUOXON-MOUSE)
|
|
@@ -176,13 +186,14 @@ export function restoreTerminal({ stdin = process.stdin, stdout = process.stdout
|
|
|
176
186
|
* What a restart runs: { cmd, args, env }. The installed quox (or this tree), the same arguments with --resume
|
|
177
187
|
* for this conversation, and the one-shot hand-over env (held slot, attach, org, plan mode) for the successor.
|
|
178
188
|
*/
|
|
179
|
-
export function restartPlan({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, env = process.env, bin = findOnPath('quox', env) }) {
|
|
189
|
+
export function restartPlan({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, declined = null, env = process.env, bin = findOnPath('quox', env) }) {
|
|
180
190
|
const args = restartArgs(argv, conversationId, { profile, name });
|
|
181
191
|
let childEnv = profile && !argv.includes('--demo') ? { ...env, QUOX_PROFILE: profile } : env; // belt and braces: same profile even if -p were lost
|
|
182
192
|
if (slot) childEnv = { ...childEnv, [RESTART_SLOT_ENV]: slot }; // the held slot rides along; the new client takes it over before this one is gone
|
|
183
193
|
if (attach) childEnv = { ...childEnv, [RESTART_ATTACH_ENV]: attach }; // so does the attached agent or slot
|
|
184
194
|
if (org) childEnv = { ...childEnv, [RESTART_ORG_ENV]: org }; // and the window's org (never the profile's file)
|
|
185
195
|
if (mode === 'plan') childEnv = { ...childEnv, [RESTART_MODE_ENV]: 'plan' }; // and the window's /mode (never the profile)
|
|
196
|
+
if (declined) childEnv = { ...childEnv, [RESTART_DECLINED_ENV]: declined }; // and a declined reattach (never re-asked after a restart)
|
|
186
197
|
const [cmd, lead] = restartTarget(env, bin);
|
|
187
198
|
return { cmd, args: [...lead, ...args], env: childEnv };
|
|
188
199
|
}
|
|
@@ -193,10 +204,26 @@ export function restartPlan({ argv, conversationId, profile = null, name = null,
|
|
|
193
204
|
* Only used when there is no launcher (QUOX_NO_LAUNCHER=1, or no handoff dir): it NESTS the successor under
|
|
194
205
|
* this process, which stays resident until the successor exits. The launcher path (launcher.js) does not.
|
|
195
206
|
*/
|
|
196
|
-
export function execRestart({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, env = process.env, spawn = spawnSync, bin = findOnPath('quox', env), restore = restoreTerminal }) {
|
|
197
|
-
const plan = restartPlan({ argv, conversationId, profile, name, slot, attach, org, mode, env, bin });
|
|
207
|
+
export function execRestart({ argv, conversationId, profile = null, name = null, slot = null, attach = null, org = null, mode = null, declined = null, env = process.env, spawn = spawnSync, bin = findOnPath('quox', env), restore = restoreTerminal }) {
|
|
208
|
+
const plan = restartPlan({ argv, conversationId, profile, name, slot, attach, org, mode, declined, env, bin });
|
|
198
209
|
restore(); // the child owns the terminal now
|
|
199
210
|
const r = spawn(plan.cmd, plan.args, { stdio: 'inherit', env: plan.env });
|
|
200
211
|
if (r.error) return { error: `${r.error.code || 'failed'}: ${r.error.message}`, bin: plan.cmd };
|
|
201
212
|
return { code: r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1), bin: plan.cmd };
|
|
202
213
|
}
|
|
214
|
+
|
|
215
|
+
export const SERVER_WAIT_EVERY_MS = 10000; // poll the collector /health this often while a restart waits for it
|
|
216
|
+
export const SERVER_WAIT_MAX_MS = 3 * 60 * 1000; // ...for up to this long, then carry on as before
|
|
217
|
+
|
|
218
|
+
/** Poll `probe` (-> { ok } | truthy/falsy, may throw) until it answers ok. -> true when healthy, false on timeout or cancel. */
|
|
219
|
+
export async function waitForHealthy({ probe, everyMs = SERVER_WAIT_EVERY_MS, maxMs = SERVER_WAIT_MAX_MS, cancelled = () => false, sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); }), now = Date.now } = {}) {
|
|
220
|
+
if (typeof probe !== 'function') return true;
|
|
221
|
+
const end = now() + maxMs;
|
|
222
|
+
for (;;) {
|
|
223
|
+
if (cancelled()) return false;
|
|
224
|
+
let r = null; try { r = await probe(); } catch { r = null; }
|
|
225
|
+
if (r === true || r?.ok === true) return true;
|
|
226
|
+
if (cancelled() || now() + everyMs > end) return false;
|
|
227
|
+
await sleep(everyMs);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -16,13 +16,30 @@ export function age(iso, now = Date.now()) {
|
|
|
16
16
|
return `${Math.round(s / 86400)}d ago`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Box-drawing, block, geometric, braille and halfwidth-kana characters are what the TUI itself paints (dividers, busy strip,
|
|
20
|
+
// dragon art, scrambled labels). A title containing them was derived from rendered UI, never typed by anyone.
|
|
21
|
+
const RENDERED = /[\u2500-\u259F\u25A0-\u25FF\u2800-\u28FF\uFF61-\uFF9F]/g;
|
|
22
|
+
/** A conversation title safe to show, or null when it is made of rendered UI (3+ painted characters) or empty. */
|
|
23
|
+
export function cleanTitle(t) {
|
|
24
|
+
const s = String(t ?? '');
|
|
25
|
+
if ((s.match(RENDERED) || []).length >= 3) return null;
|
|
26
|
+
const c = s.replace(RENDERED, ' ').replace(/\s+/g, ' ').trim();
|
|
27
|
+
return c || null;
|
|
28
|
+
}
|
|
29
|
+
/** The first user message of stored turns as a title (<= 60 chars), or null. */
|
|
30
|
+
export function titleFromTurns(turns = []) {
|
|
31
|
+
const u = (turns || []).find(t => t.role === 'user' && String(t.content || '').trim());
|
|
32
|
+
const c = u ? cleanTitle(String(u.content).split('\n')[0]) : null;
|
|
33
|
+
return c ? (c.length > 60 ? `${c.slice(0, 60).trimEnd()}…` : c) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
/** -> [{ id, title, agent, updated_at, turns }] newest first. */
|
|
20
37
|
export function resumeRows(conversations = [], index = null, names = {}) {
|
|
21
38
|
const agentOf = new Map();
|
|
22
39
|
for (const r of index?.rows || []) if (r.kind === 'conversation' && r.id) agentOf.set(r.id, r.agent || null);
|
|
23
40
|
return conversations.map(c => ({
|
|
24
41
|
id: c.id,
|
|
25
|
-
title: names[c.id]?.name || c.title || '(untitled)',
|
|
42
|
+
title: cleanTitle(names[c.id]?.name) || cleanTitle(c.title) || '(untitled)',
|
|
26
43
|
agent: agentOf.get(c.id) ?? null,
|
|
27
44
|
updated_at: c.updated_at || null,
|
|
28
45
|
turns: c.turn_count ?? null
|
|
@@ -211,7 +211,7 @@ export function createStepUp({ api, agentLogin = false, onAgentLogin = () => {},
|
|
|
211
211
|
if (!card) return;
|
|
212
212
|
if (res?.step_up_token) {
|
|
213
213
|
const until = now() + WINDOW_MS;
|
|
214
|
-
for (const s of res.scopes || card.scopes) tokens.set(s, { token: res.step_up_token, until });
|
|
214
|
+
if (!card.fresh) for (const s of res.scopes || card.scopes) tokens.set(s, { token: res.step_up_token, until }); // a fresh (one-shot) token is handed to the caller only, never kept
|
|
215
215
|
if (card.window) presentUntil = until;
|
|
216
216
|
card.phase = 'ok'; card.until = until; card.now = now(); refresh(); emit({ type: 'stepup.result', ok: true });
|
|
217
217
|
await sleep(RESULT_HOLD_MS);
|
|
@@ -245,16 +245,16 @@ export function createStepUp({ api, agentLogin = false, onAgentLogin = () => {},
|
|
|
245
245
|
view() { return card ? { ...card, filled: box.count(), tries, now: now() } : null; },
|
|
246
246
|
/**
|
|
247
247
|
* Ask for a person. Resolves { ok, token?, reason? } and never throws. Reuses a live token for the scope.
|
|
248
|
-
* opts: { scope: 'approve'|'override', scopes?, orgId?, subject?, window?: true (the /authenticate window) }
|
|
248
|
+
* opts: { scope: 'approve'|'override', scopes?, orgId?, subject?, window?: true (the /authenticate window), fresh?: true (always ask for the PIN, never reuse a live token, never keep the one issued: for one-shot strict tokens) }
|
|
249
249
|
*/
|
|
250
|
-
request({ scope = 'approve', scopes = null, orgId = null, subject = {}, window: asWindow = false, title = null } = {}) {
|
|
250
|
+
request({ scope = 'approve', scopes = null, orgId = null, subject = {}, window: asWindow = false, title = null, fresh = false } = {}) {
|
|
251
251
|
if (typeof agentLogin === 'function' ? agentLogin() : agentLogin) { note(`This window is an agent login: ${NEEDS_PERSON}.`, 'warn'); onAgentLogin(); return Promise.resolve({ ok: false, reason: 'agent_login' }); }
|
|
252
|
-
if (!asWindow && live(scope)) return Promise.resolve({ ok: true, token: live(scope).token, scopes: [scope], reused: true });
|
|
252
|
+
if (!asWindow && !fresh && live(scope)) return Promise.resolve({ ok: true, token: live(scope).token, scopes: [scope], reused: true });
|
|
253
253
|
if (card) return Promise.resolve({ ok: false, reason: 'busy' });
|
|
254
254
|
if (lockedUntil > now()) { note(`⛔ LOCKED · PIN entry is locked for ${mmss(lockedUntil - now())}. Use your phone or a human session.`, 'error'); return Promise.resolve({ ok: false, reason: 'locked' }); }
|
|
255
255
|
tries = MAX_TRIES;
|
|
256
256
|
box.clear();
|
|
257
|
-
card = { phase: 'input', subject, title, scope, scopes: scopes || (asWindow ? ['approve', 'override'] : [scope]), orgId, window: asWindow, error: null, offset: 0 };
|
|
257
|
+
card = { phase: 'input', subject, title, scope, scopes: scopes || (asWindow ? ['approve', 'override'] : [scope]), orgId, window: asWindow, fresh, error: null, offset: 0 };
|
|
258
258
|
refresh();
|
|
259
259
|
return new Promise((r) => { resolveCard = r; });
|
|
260
260
|
},
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* witnessed server-side. Honest about block types no gate honours yet.
|
|
9
9
|
*/
|
|
10
10
|
import { mmss, NEEDS_PERSON } from './stepUp.js';
|
|
11
|
+
import { STEP_UP_HEADER } from '../../core/stepUpPrompt.js';
|
|
12
|
+
import { renderAnnounce } from '../../core/announce.js';
|
|
11
13
|
|
|
12
14
|
export const BLOCK_TYPES = ['safety', 'budget', 'aocl', 'ward'];
|
|
13
15
|
export const MIN_REASON = 8;
|
|
@@ -43,8 +45,79 @@ export function declinedText(reason) {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
export
|
|
48
|
+
export const verifyEmailUsage = '/verify-email <email> --reason "<why>" [--user-id <id>] (instance admin; a person confirms with a PIN; audited)';
|
|
49
|
+
const PIN_SHAPED = /(^|\D)\d{6}(\D|$)/;
|
|
50
|
+
// eslint-disable-next-line no-control-regex
|
|
51
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
52
|
+
|
|
53
|
+
/** Split on spaces, honouring "double" and 'single' quotes. */
|
|
54
|
+
function words(s) {
|
|
55
|
+
const out = []; let cur = ''; let q = null; let has = false;
|
|
56
|
+
for (const ch of String(s || '')) {
|
|
57
|
+
if (q) { if (ch === q) q = null; else cur += ch; continue; }
|
|
58
|
+
if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
|
|
59
|
+
if (/\s/.test(ch)) { if (has || cur) out.push(cur); cur = ''; has = false; continue; }
|
|
60
|
+
cur += ch; has = true;
|
|
61
|
+
}
|
|
62
|
+
if (has || cur) out.push(cur);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** -> { ok:true, email, reason, userId } | { ok:false, error }. Never accepts a PIN-shaped argument. */
|
|
67
|
+
export function parseVerifyEmail(args) {
|
|
68
|
+
const raw = String(args || '');
|
|
69
|
+
if (PIN_SHAPED.test(raw)) return { ok: false, error: 'That looks like a PIN. Never type your PIN after a command: it is asked for in a private card.' };
|
|
70
|
+
const w = words(raw);
|
|
71
|
+
let email = null; let reason = null; let userId = null;
|
|
72
|
+
for (let i = 0; i < w.length; i += 1) {
|
|
73
|
+
const t = w[i];
|
|
74
|
+
if (t === '--reason' || t.startsWith('--reason=')) {
|
|
75
|
+
const parts = t.startsWith('--reason=') ? [t.slice(9)] : [];
|
|
76
|
+
while (i + 1 < w.length && !w[i + 1].startsWith('--')) { i += 1; parts.push(w[i]); }
|
|
77
|
+
reason = parts.join(' ').trim();
|
|
78
|
+
} else if (t === '--user-id' || t.startsWith('--user-id=')) {
|
|
79
|
+
userId = t.startsWith('--user-id=') ? t.slice(10) : (w[i + 1] && !w[i + 1].startsWith('--') ? w[++i] : '');
|
|
80
|
+
} else if (!email && !t.startsWith('--')) email = t;
|
|
81
|
+
else return { ok: false, error: `Unexpected "${t}". Usage: ${verifyEmailUsage}` };
|
|
82
|
+
}
|
|
83
|
+
if (!email) return { ok: false, error: `Give the email address. Usage: ${verifyEmailUsage}` };
|
|
84
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { ok: false, error: `"${email}" is not an email address. Usage: ${verifyEmailUsage}` };
|
|
85
|
+
if (!reason) return { ok: false, error: `--reason is required: it is recorded in the audit log. Usage: ${verifyEmailUsage}` };
|
|
86
|
+
return { ok: true, email, reason, userId: userId || null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createStepUpCmds({ api, stepUp, say, lastBlock = () => null, orgOf = () => null, confirm = null }) {
|
|
47
90
|
return {
|
|
91
|
+
/** The composer line after the /verify-email preview: true when consumed as the y/n answer. */
|
|
92
|
+
answer: (text) => Boolean(confirm?.answer(text)),
|
|
93
|
+
pending: () => Boolean(confirm?.pending()),
|
|
94
|
+
touch: () => confirm?.touch(),
|
|
95
|
+
/**
|
|
96
|
+
* /verify-email: the TUI front of `quox admin users verify-email`. Same endpoint, same scope ('override'); the
|
|
97
|
+
* server (instance admin, human caller, one-shot step-up token, reason) is the only gate. Preview + y/N first,
|
|
98
|
+
* then the hidden PIN card, always fresh: a live /authenticate token is never reused or spent here.
|
|
99
|
+
*/
|
|
100
|
+
async verifyEmail(args) {
|
|
101
|
+
const p = parseVerifyEmail(args);
|
|
102
|
+
if (!p.ok) return say(p.error, 'warn');
|
|
103
|
+
if (!confirm) return say('/verify-email is not available in this mode.', 'warn');
|
|
104
|
+
const verb = `admin verify-email ${p.email}`;
|
|
105
|
+
const glyph = (opts) => renderAnnounce('quox', verb, opts).replace(ANSI, '');
|
|
106
|
+
say(`This will mark ${p.email} as email-verified WITHOUT a mailbox check${p.userId ? ` (only if it belongs to user ${p.userId})` : ''}. Reason (recorded in the audit log under your name): ${p.reason}`, 'warn');
|
|
107
|
+
say('A person must confirm with the step-up PIN next, asked in a private card. Instance admins only.');
|
|
108
|
+
return confirm.ask([], async () => {
|
|
109
|
+
const r = await stepUp.request({ scope: 'override', fresh: true, orgId: orgOf(), title: 'VERIFYING AN EMAIL NEEDS A PERSON', subject: { line1: `verify ${p.email}` } });
|
|
110
|
+
if (!r.ok) return say(`Not verified: ${declinedText(r.reason)}.`, 'warn');
|
|
111
|
+
try {
|
|
112
|
+
const data = await api.verifyEmail({ email: p.email, reason: p.reason, userId: p.userId, stepUp: { [STEP_UP_HEADER]: r.token } });
|
|
113
|
+
say(glyph({ result: `verified · user ${data.user_id}` }), 'ok');
|
|
114
|
+
say(`Email ${data.email} is now verified. Reason recorded in the audit log.`, 'ok');
|
|
115
|
+
} catch (e) {
|
|
116
|
+
say(glyph({ result: String(e?.body?.message || e?.body?.error || e?.message || e).replace(/\s+/g, ' ').trim(), status: 'fail' }), 'error');
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}, { prompt: 'Go ahead and ask for your PIN?' });
|
|
120
|
+
},
|
|
48
121
|
async authenticate() {
|
|
49
122
|
const r = await stepUp.request({ window: true, orgId: orgOf(), subject: { line1: 'confirm a person is at this keyboard' } });
|
|
50
123
|
if (!r.ok) return say(`Not authenticated: ${declinedText(r.reason)}.`, 'warn');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /todo <text> adds an Action Tracker item; /todo alone and /todos list the board; /todo move do5 done moves one
|
|
3
|
+
* (owner typed /todo, 2026-10-20). The writes and reads are core/actionItems.js, the same core as `quox actions`.
|
|
4
|
+
* Items get per-listing short ids by column: td1.. (to do), do1.. (doing), dn1.. (done). They are positions in the
|
|
5
|
+
* last list, mapped to real item ids; a move re-reads the board and says so when the list has changed.
|
|
6
|
+
* Pure helpers here: parse, short ids, wording.
|
|
7
|
+
*/
|
|
8
|
+
export const DONE_SHOWN = 10;
|
|
9
|
+
const PREFIX = { backlog: 'td', doing: 'do', done: 'dn' };
|
|
10
|
+
const COLUMN_NAME = { backlog: 'To do', doing: 'Doing', done: 'Done' };
|
|
11
|
+
const REF = /^(td|do|dn)(\d+)$/i;
|
|
12
|
+
const STATUS_WORD = { todo: 'todo', 'to-do': 'todo', td: 'todo', doing: 'doing', do: 'doing', wip: 'doing', progress: 'doing', 'in-progress': 'doing', done: 'done', dn: 'done', complete: 'done', completed: 'done', finished: 'done' };
|
|
13
|
+
const USAGE = 'Usage: /todo <text> adds. /todos lists. /todo move do5 done (or /todo do5 done, /todo mark do2 done, /todo td3 doing) moves one.';
|
|
14
|
+
|
|
15
|
+
/** A column word -> 'todo' | 'doing' | 'done' | null. Accepts "to do" and "in progress" as two words. */
|
|
16
|
+
export function statusWord(words) {
|
|
17
|
+
const w = (Array.isArray(words) ? words.join('-') : String(words || '')).trim().toLowerCase();
|
|
18
|
+
return STATUS_WORD[w] || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Leading short ids from words, comma and/or space separated ("td1,td2", "td1 td2", "td1, td2") -> { refs, used } (used = words consumed). */
|
|
22
|
+
export function takeRefs(words) {
|
|
23
|
+
const refs = [];
|
|
24
|
+
let used = 0;
|
|
25
|
+
for (const w of words) {
|
|
26
|
+
const parts = String(w).split(',').filter(Boolean);
|
|
27
|
+
if (!parts.length || !parts.every((p) => REF.test(p))) break;
|
|
28
|
+
refs.push(...parts.map((p) => p.toLowerCase()));
|
|
29
|
+
used += 1;
|
|
30
|
+
}
|
|
31
|
+
return { refs: [...new Set(refs)], used };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** "/done td1,td2" argument -> { kind: 'done', refs } | { kind: 'usage', text }. Nothing but short ids is accepted. */
|
|
35
|
+
export function parseDone(arg) {
|
|
36
|
+
const words = String(arg || '').trim().split(/\s+/).filter(Boolean);
|
|
37
|
+
const { refs, used } = takeRefs(words);
|
|
38
|
+
if (!refs.length || used !== words.length) return { kind: 'usage', text: 'Usage: /done td1,td2 (or /done td1 td2) marks those items done. /todos lists the ids.' };
|
|
39
|
+
return { kind: 'done', refs };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function todoDoneLine(title) {
|
|
43
|
+
return `\u2714 Done: ${title}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* "/todo ..." or "/todos ..." + its argument ->
|
|
48
|
+
* { kind: 'list' } | { kind: 'add', text } | { kind: 'move', ref, to } | { kind: 'usage', text }.
|
|
49
|
+
* Only /todo with free text adds. A short id plus a column word moves; an id with a bad column is a usage error, never an add.
|
|
50
|
+
*/
|
|
51
|
+
export function parseTodo(command, arg) {
|
|
52
|
+
const text = String(arg || '').trim();
|
|
53
|
+
if (String(command || '').toLowerCase() === '/todos' || !text) return { kind: 'list' };
|
|
54
|
+
const words = text.split(/\s+/);
|
|
55
|
+
const verb = /^(move|mv|mark|set)$/i.test(words[0]);
|
|
56
|
+
const rest = verb ? words.slice(1) : words;
|
|
57
|
+
const lead = takeRefs(rest);
|
|
58
|
+
if (lead.refs.length) {
|
|
59
|
+
const to = statusWord(rest.slice(lead.used));
|
|
60
|
+
if (to) return lead.refs.length > 1 ? { kind: 'move', ref: lead.refs[0], refs: lead.refs, to } : { kind: 'move', ref: lead.refs[0], to };
|
|
61
|
+
return { kind: 'usage', text: USAGE };
|
|
62
|
+
}
|
|
63
|
+
if (verb) return { kind: 'usage', text: USAGE };
|
|
64
|
+
return { kind: 'add', text };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** board {backlog,doing,done} (backlog = the to-do column) -> { map: { td1: id, ... }, shown: { todo, doing, done } } (done is capped at DONE_SHOWN). */
|
|
68
|
+
export function numberBoard(board, doneShown = DONE_SHOWN) {
|
|
69
|
+
const shown = { backlog: board.backlog || [], doing: board.doing || [], done: (board.done || []).slice(0, doneShown) };
|
|
70
|
+
const map = {};
|
|
71
|
+
for (const col of Object.keys(PREFIX)) shown[col].forEach((it, n) => { map[`${PREFIX[col]}${n + 1}`] = it.id; });
|
|
72
|
+
return { map, shown };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Confirmation after an add: the item id plus where to see the list. */
|
|
76
|
+
export function todoAddedLine(item) {
|
|
77
|
+
return `Added to your Action Tracker: ${item.id} ${item.title}. /todos lists them.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The board as short numbered lines, per column, with short ids. -> string. */
|
|
81
|
+
export function todoBoardText(board, numbered = numberBoard(board)) {
|
|
82
|
+
const total = (board.backlog || []).length + (board.doing || []).length + (board.done || []).length;
|
|
83
|
+
if (!total) return 'Nothing in your Action Tracker. /todo <text> adds an item.';
|
|
84
|
+
const out = [`Your Action Tracker (${(board.backlog || []).length} to do, ${(board.doing || []).length} doing, ${(board.done || []).length} done). /todo <text> adds, /todo move do2 done moves:`];
|
|
85
|
+
for (const col of ['backlog', 'doing', 'done']) {
|
|
86
|
+
const items = numbered.shown[col];
|
|
87
|
+
const all = (board[col] || []).length;
|
|
88
|
+
if (!all) continue;
|
|
89
|
+
out.push(`${COLUMN_NAME[col]}:`);
|
|
90
|
+
items.forEach((i, n) => out.push(` ${PREFIX[col]}${n + 1} ${i.title}${i.due_date ? ` due ${String(i.due_date).slice(0, 10)}` : ''}`));
|
|
91
|
+
if (all > items.length) out.push(` ... and ${all - items.length} more ${col === 'done' ? 'done (quox actions list --status done)' : ''}`.trimEnd());
|
|
92
|
+
}
|
|
93
|
+
return out.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a short id for a move. oldMap is the map from the last /todos (or null if none yet); fresh is the board
|
|
98
|
+
* read just now. The item is found by its REAL id from the old list, so a list that shifted still moves the item
|
|
99
|
+
* you saw. -> { id, title, note? } | { error }.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveRef(ref, oldMap, board) {
|
|
102
|
+
const fresh = numberBoard(board, Infinity);
|
|
103
|
+
const byId = {};
|
|
104
|
+
for (const [short, id] of Object.entries(fresh.map)) byId[id] = short;
|
|
105
|
+
const titleOf = (id) => [...(board.backlog || []), ...(board.doing || []), ...(board.done || [])].find((i) => i.id === id)?.title || '';
|
|
106
|
+
if (!oldMap || !oldMap[ref]) {
|
|
107
|
+
const id = fresh.map[ref];
|
|
108
|
+
if (!id) return { error: `No ${ref} on your board. /todos lists the current ids.` };
|
|
109
|
+
return { id, title: titleOf(id), note: oldMap ? undefined : `No list shown yet in this window, so ${ref} is read from the board as it is now.` };
|
|
110
|
+
}
|
|
111
|
+
const id = oldMap[ref];
|
|
112
|
+
if (!byId[id]) return { error: `${ref} is gone from your board (done elsewhere or deleted). /todos lists the current ids.` };
|
|
113
|
+
const nowRef = byId[id];
|
|
114
|
+
const shownNow = numberBoard(board).map[nowRef] === id; // the id is one the user could see in a fresh listing
|
|
115
|
+
return nowRef === ref
|
|
116
|
+
? { id, title: titleOf(id) }
|
|
117
|
+
: { id, title: titleOf(id), note: `The list changed since /todos: "${titleOf(id)}" is ${nowRef} now${shownNow ? '' : ' (beyond the short list)'}. Moving that one.` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function todoMovedLine(item) {
|
|
121
|
+
return `Moved ${item.title} to ${item.status}.`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INSTANT-FEEDBACK (owner 2026-10-26: "no spinner... I want a whirly thing like the npm install cursor"): one shared
|
|
3
|
+
* helper for every async slash command. The dispatcher wraps the handler; if it is still pending after `firstMs`
|
|
4
|
+
* (under 100 ms) a `⠋ <verb>…` line appears and turns at `frameMs`; it goes away when the handler settles (the result
|
|
5
|
+
* line or the error replaces it), when the handler prints its first line (`stop`), or after `maxMs`.
|
|
6
|
+
*/
|
|
7
|
+
export const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
8
|
+
|
|
9
|
+
/** `/inbox read all` -> `inbox`; the verb is the command word without its slash. */
|
|
10
|
+
export const spinnerVerb = (line) => String(line || '').trim().split(/\s+/)[0].replace(/^\/+/, '') || 'working';
|
|
11
|
+
export const spinnerText = (verb, i = 0) => `${SPIN_FRAMES[i % SPIN_FRAMES.length]} ${verb}…`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn` (returns a value or a promise). show(text) paints a frame, hide() clears it. register(stop) hands the caller
|
|
15
|
+
* a way to cancel the spinner early. Always resolves/rejects exactly as fn does.
|
|
16
|
+
*/
|
|
17
|
+
export async function runWithSpinner(fn, { verb, show, hide, register, firstMs = 80, frameMs = 80, maxMs = 60000 } = {}) {
|
|
18
|
+
let frame = 0, shown = false, done = false, first = null, iv = null, cap = null;
|
|
19
|
+
const stop = () => {
|
|
20
|
+
done = true;
|
|
21
|
+
if (first) { clearTimeout(first); first = null; }
|
|
22
|
+
if (iv) { clearInterval(iv); iv = null; }
|
|
23
|
+
if (cap) { clearTimeout(cap); cap = null; }
|
|
24
|
+
if (shown) { shown = false; hide?.(); }
|
|
25
|
+
};
|
|
26
|
+
first = setTimeout(() => {
|
|
27
|
+
first = null;
|
|
28
|
+
if (done) return;
|
|
29
|
+
shown = true; show?.(spinnerText(verb, frame++));
|
|
30
|
+
iv = setInterval(() => { if (!done) show?.(spinnerText(verb, frame++)); }, frameMs);
|
|
31
|
+
cap = setTimeout(stop, maxMs);
|
|
32
|
+
}, firstMs);
|
|
33
|
+
register?.(stop);
|
|
34
|
+
try { return await fn(); } finally { stop(); }
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Plain-language slot attach (owner 2026-10-28): "attach to hitl slot" is a request for /slot hitl, so the window
|
|
2
|
+
// OFFERS that command (y/N) instead of sending the sentence to the model. Only these two phrasings match.
|
|
3
|
+
const ATTACH = /^\s*(?:attach\s+(?:me\s+)?to\s+(?:the\s+)?|attach\s+(?:the\s+)?|join\s+(?:the\s+)?)([A-Za-z0-9][A-Za-z0-9._-]{0,63})\s+slot\s*[.!]?\s*$/i;
|
|
4
|
+
export function parseSlotAttach(text) {
|
|
5
|
+
if (typeof text !== 'string' || text.includes('\n') || text.startsWith('/')) return null;
|
|
6
|
+
const m = ATTACH.exec(text);
|
|
7
|
+
return m ? m[1] : null;
|
|
8
|
+
}
|
package/lib/quoxon/slotChat.js
CHANGED
|
@@ -116,6 +116,15 @@ export function slotOfHolder(rows, name) {
|
|
|
116
116
|
return r ? r.name : null;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** REPLY-ADDRESS: a message with no from_slot gets one when its sender holds a slot (by held session name, else by the slot's own name). Returns m itself when nothing applies. */
|
|
120
|
+
export function withSenderSlot(rows, m) {
|
|
121
|
+
if (!m || m.from_slot) return m;
|
|
122
|
+
const n = String(m.from || '').replace(/\s*\(.*\)\s*$/, '').trim().toLowerCase();
|
|
123
|
+
const held = (Array.isArray(rows) ? rows : []).filter(x => x?.name && x.state !== 'free');
|
|
124
|
+
const slot = slotOfHolder(held, m.from) || held.find(x => String(x.name).toLowerCase() === n)?.name || null;
|
|
125
|
+
return slot ? { ...m, from_slot: slot } : m;
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
/** A slot message as a chat line: { glyph, who, time, text, file }. */
|
|
120
129
|
export function slotLine(m) {
|
|
121
130
|
m = m && typeof m.text === 'string' ? { ...m, text: decodeEntities(m.text) } : m;
|
|
@@ -298,3 +307,11 @@ export function presenceLine(slot, presence, agoText) {
|
|
|
298
307
|
const seen = presence.last_checked_in_at ? `last checked in ${agoText(presence.last_checked_in_at)}` : 'no check-in on record';
|
|
299
308
|
return { text: `✔ delivered to ${slot} · not listening right now (${seen}); it sees this when it next checks`, tone: 'ok', listening: false };
|
|
300
309
|
}
|
|
310
|
+
|
|
311
|
+
const DEPLOY_WINDOW_MS = 10 * 60 * 1000;
|
|
312
|
+
/** The "reattach is slow" line. During a deploy (restart signal seen, or the same URL answered within ~10 min) the stale-URL/env advice is wrong, so it says so calmly; a URL that never answered keeps the full diagnostic. */
|
|
313
|
+
export function reattachSlowLine({ slot, message, restartSeenAt = 0, lastGoodAt = 0, now = Date.now() }) {
|
|
314
|
+
const recent = (t) => Number(t) > 0 && now - Number(t) <= DEPLOY_WINDOW_MS;
|
|
315
|
+
if (recent(restartSeenAt) || recent(lastGoodAt)) return `slot ${slot}: QuoxCORE is restarting (a deploy); reconnecting, nothing is lost.`;
|
|
316
|
+
return `slot ${slot}: reattaching after the restart is slow (${message}); retrying.`;
|
|
317
|
+
}
|
package/lib/quoxon/slotFormat.js
CHANGED
|
@@ -153,7 +153,7 @@ function wrapRuns(runs, width, role, prefix) {
|
|
|
153
153
|
let t = a.t;
|
|
154
154
|
if (w + sep + len(t) > room && cur.length) newRow();
|
|
155
155
|
else if (sep) { add({ t: ' ', role, bold: undefined }); w += 1; }
|
|
156
|
-
while (len(t) > room) { const g = graphemes(t); add({ t: g.slice(0, room - w).join(''), role: a.role, bold: a.bold }); t = g.slice(room - w).join(''); newRow(); }
|
|
156
|
+
while (len(t) > room) { const g = graphemes(t).map(x => x.text); add({ t: g.slice(0, room - w).join(''), role: a.role, bold: a.bold }); t = g.slice(room - w).join(''); newRow(); }
|
|
157
157
|
add({ t, role: a.role, bold: a.bold }); w += len(t);
|
|
158
158
|
gap = false;
|
|
159
159
|
}
|