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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GRIPE-QUEUE: a gripe that could not be filed because Quox did not answer (timeout, network, 5xx) is kept on disk and
|
|
3
|
+
* retried, never lost. Local only (~/.quox/gripe-queue.json, mode 0600). Each entry carries an idempotency key
|
|
4
|
+
* (sha256 of the normalised text + the UTC day) that rides on the filing request, so the collector can return the
|
|
5
|
+
* issue it already made when a request that timed out had in fact succeeded.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { redactSecrets } from './secretShape.js';
|
|
12
|
+
|
|
13
|
+
export const RETRY_MIN_MS = 2 * 60 * 1000;
|
|
14
|
+
export const GIVE_UP_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
export const RECENT_FILED_CAP = 20;
|
|
16
|
+
export const defaultQueuePath = (home = homedir()) => join(home, '.quox', 'gripe-queue.json');
|
|
17
|
+
|
|
18
|
+
const normalise = (t) => String(t || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
19
|
+
/** sha256(normalised text + UTC day) -> hex. The same gripe retyped the same day maps to one key. */
|
|
20
|
+
export function gripeKey(text, now = Date.now()) {
|
|
21
|
+
return createHash('sha256').update(`${normalise(text)}|${new Date(now).toISOString().slice(0, 10)}`).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A failure worth queueing: no HTTP status (timeout, network, refused) or a 5xx. 4xx is the user's to fix. */
|
|
25
|
+
export function isRetryable(err) {
|
|
26
|
+
const s = err?.status;
|
|
27
|
+
if (s == null) return true;
|
|
28
|
+
return Number(s) >= 500;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function gripeQueue(path = defaultQueuePath(), { now = () => Date.now() } = {}) {
|
|
32
|
+
const read = () => {
|
|
33
|
+
try { const d = JSON.parse(readFileSync(path, 'utf8')); return Array.isArray(d?.items) ? d : { items: [], lastTry: 0 }; }
|
|
34
|
+
catch { return { items: [], lastTry: 0 }; }
|
|
35
|
+
};
|
|
36
|
+
const write = (d) => {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
39
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(d), { mode: 0o600 });
|
|
41
|
+
renameSync(tmp, path);
|
|
42
|
+
return true;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
};
|
|
45
|
+
const trim = (d) => { // keep every open item, and only the most recent filed/expired ones
|
|
46
|
+
const open = d.items.filter(i => i.status === 'queued');
|
|
47
|
+
const done = d.items.filter(i => i.status !== 'queued').slice(-RECENT_FILED_CAP);
|
|
48
|
+
d.items = [...done, ...open];
|
|
49
|
+
return d;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
key: (text) => gripeKey(text, now()),
|
|
53
|
+
list: () => read().items,
|
|
54
|
+
/** Add (or find) the queued entry for this gripe. -> the entry. */
|
|
55
|
+
enqueue({ text, repo = null, key }) {
|
|
56
|
+
const d = read();
|
|
57
|
+
const k = key || gripeKey(text, now());
|
|
58
|
+
let it = d.items.find(i => i.key === k);
|
|
59
|
+
if (!it) { it = { key: k, text: redactSecrets(String(text)), repo, created_at: new Date(now()).toISOString(), status: 'queued', tries: 0 }; d.items.push(it); }
|
|
60
|
+
else if (it.status === 'expired') { it.status = 'queued'; }
|
|
61
|
+
write(trim(d));
|
|
62
|
+
return it;
|
|
63
|
+
},
|
|
64
|
+
/** Record a successful filing (from the live path or a retry) so /gripes can show the link. */
|
|
65
|
+
markFiled({ text, repo = null, key, url }) {
|
|
66
|
+
const d = read();
|
|
67
|
+
const k = key || gripeKey(text, now());
|
|
68
|
+
let it = d.items.find(i => i.key === k);
|
|
69
|
+
if (!it) { it = { key: k, text: redactSecrets(String(text)), repo, created_at: new Date(now()).toISOString(), tries: 0 }; d.items.push(it); }
|
|
70
|
+
Object.assign(it, { status: 'filed', url, filed_at: new Date(now()).toISOString() });
|
|
71
|
+
write(trim(d));
|
|
72
|
+
return it;
|
|
73
|
+
},
|
|
74
|
+
/**
|
|
75
|
+
* File what is queued, at most once per RETRY_MIN_MS across all callers, expiring entries older than 24 h.
|
|
76
|
+
* file(item) -> { filed:true, url } | { filed:false, retryable }. -> { ran, filed:[items], expired:[items], pending:n }.
|
|
77
|
+
*/
|
|
78
|
+
async retry(file, { force = false } = {}) {
|
|
79
|
+
const d = read();
|
|
80
|
+
const t = now();
|
|
81
|
+
const expired = [];
|
|
82
|
+
for (const it of d.items) if (it.status === 'queued' && t - Date.parse(it.created_at) > GIVE_UP_MS) { it.status = 'expired'; expired.push(it); }
|
|
83
|
+
const open = d.items.filter(i => i.status === 'queued');
|
|
84
|
+
if (expired.length) write(d);
|
|
85
|
+
if (!open.length) return { ran: false, filed: [], expired, pending: 0 };
|
|
86
|
+
if (!force && t - (d.lastTry || 0) < RETRY_MIN_MS) return { ran: false, filed: [], expired, pending: open.length };
|
|
87
|
+
d.lastTry = t; write(d);
|
|
88
|
+
const filed = [];
|
|
89
|
+
for (const it of open) {
|
|
90
|
+
let r;
|
|
91
|
+
try { r = await file(it); } catch (e) { r = { filed: false, retryable: isRetryable(e) }; }
|
|
92
|
+
it.tries = (it.tries || 0) + 1;
|
|
93
|
+
if (r?.filed && r.url) { Object.assign(it, { status: 'filed', url: r.url, filed_at: new Date(now()).toISOString() }); filed.push(it); }
|
|
94
|
+
else if (r && r.retryable === false) { it.status = 'failed'; it.error = r.error || 'refused'; }
|
|
95
|
+
else break; // Quox still not answering: leave the rest for the next window
|
|
96
|
+
}
|
|
97
|
+
const fresh = read(); // another process may have enqueued meanwhile; merge by key
|
|
98
|
+
for (const it of d.items) { const j = fresh.items.findIndex(x => x.key === it.key); if (j >= 0) fresh.items[j] = it; else fresh.items.push(it); }
|
|
99
|
+
fresh.lastTry = d.lastTry;
|
|
100
|
+
write(trim(fresh));
|
|
101
|
+
return { ran: true, filed, expired, pending: fresh.items.filter(i => i.status === 'queued').length };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const SAVED_LINE = "Saved; I'll file it when Quox answers again (/gripes shows the queue)";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run one live filing. run(key) -> { response, url, superseded, notes } (throws when Quox does not answer).
|
|
110
|
+
* A timeout, network or 5xx failure queues the gripe instead of losing it. -> { ok:true, ... } | { ok:false, queued, message }.
|
|
111
|
+
*/
|
|
112
|
+
export async function fileOrQueue({ text, repo = null, queue, run, queueable = true }) {
|
|
113
|
+
const key = queue.key(text);
|
|
114
|
+
try {
|
|
115
|
+
const r = await run(key);
|
|
116
|
+
if (r?.url) queue.markFiled({ text, repo, key, url: r.url });
|
|
117
|
+
return { ok: true, ...r };
|
|
118
|
+
} catch (e) {
|
|
119
|
+
const message = e?.status ? `HTTP ${e.status}: ${e.message}` : (e?.message || 'failed');
|
|
120
|
+
if (!queueable || !isRetryable(e)) return { ok: false, message };
|
|
121
|
+
queue.enqueue({ text, repo, key });
|
|
122
|
+
return { ok: false, queued: true, message, saved: SAVED_LINE };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The retry filer: straight to the governed proxy (no agent run), carrying the entry's key so a duplicate is impossible. */
|
|
127
|
+
export function makeRetryFiler({ fileDeterministically, client, orgId }) {
|
|
128
|
+
return async (it) => {
|
|
129
|
+
const r = await fileDeterministically({
|
|
130
|
+
client, complaint: it.text, orgId: orgId || null, quiet: true, throwRetryable: true,
|
|
131
|
+
options: { yes: true, ...(it.repo ? { repo: it.repo } : {}), idempotencyKey: it.key },
|
|
132
|
+
});
|
|
133
|
+
return r.filed ? { filed: true, url: r.url } : { filed: false, retryable: false, error: r.error };
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HITL-INBOX-BAR (owner 2026-10-26): the owner's Quox inbox in the chat. Pure helpers: the status-area bar text,
|
|
3
|
+
* the arrival line, the /inbox table and the one-item view. No I/O here; app.js owns the calls.
|
|
4
|
+
*/
|
|
5
|
+
import { bursts, burstLine } from './approvalBatch.js';
|
|
6
|
+
const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t; };
|
|
7
|
+
const pad = (s, n) => { const t = clip(s, n); return t + ' '.repeat(Math.max(0, n - [...t].length)); };
|
|
8
|
+
const ms = (v) => { const t = Date.parse(String(v ?? '').includes('T') || !v ? v : `${String(v).replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : 0; };
|
|
9
|
+
const CLOSED = new Set(['resolved', 'cancelled', 'expired', 'withdrawn', 'deleted', 'archived']);
|
|
10
|
+
|
|
11
|
+
/** Newest first. */
|
|
12
|
+
export const newestFirst = (items) => [...(Array.isArray(items) ? items : [])].filter((i) => i && i.id).sort((a, b) => ms(b.created_at ?? b.createdAt) - ms(a.created_at ?? a.createdAt));
|
|
13
|
+
/** Unread = the server's per-user is_read flag is not set, and the item is not closed or archived. */
|
|
14
|
+
export const isUnread = (it) => !it.is_read && !it.archived_at && !CLOSED.has(String(it.status || '').toLowerCase());
|
|
15
|
+
/** Read but still open: what `/inbox unread all` flips back. */
|
|
16
|
+
export const readItems = (items) => newestFirst(items).filter((i) => i.is_read && !i.archived_at && !CLOSED.has(String(i.status || '').toLowerCase()));
|
|
17
|
+
export const unreadItems = (items) => newestFirst(items).filter(isUnread);
|
|
18
|
+
|
|
19
|
+
const titleOf = (it) => String(it.title || it.kind || 'untitled');
|
|
20
|
+
const sameTitle = (a, b) => titleOf(a) === titleOf(b);
|
|
21
|
+
|
|
22
|
+
/** Inbox noise (owner 2026-10-27). Mutes are { [source lowercase]: expiryEpochMs }, stored per profile in the CLI settings. */
|
|
23
|
+
export const REPEAT_WINDOW_MS = 12 * 3600000;
|
|
24
|
+
export const MUTE_DEFAULT_MS = 8 * 3600000;
|
|
25
|
+
/** '30m' | '8h' | '1d' -> ms, or null when it is not a duration. */
|
|
26
|
+
export function muteDuration(text) {
|
|
27
|
+
const m = /^(\d+)\s*(m|h|d)$/i.exec(String(text || '').trim());
|
|
28
|
+
if (!m || !Number(m[1])) return null;
|
|
29
|
+
return Number(m[1]) * { m: 60000, h: 3600000, d: 86400000 }[m[2].toLowerCase()];
|
|
30
|
+
}
|
|
31
|
+
/** The mutes that have not expired. */
|
|
32
|
+
export function activeMutes(mutes, now = Date.now()) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [k, v] of Object.entries(mutes && typeof mutes === 'object' ? mutes : {})) if (Number(v) > now) out[String(k).toLowerCase()] = Number(v);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** An approval is NEVER muted: approvals always announce. */
|
|
38
|
+
export const isMuted = (it, mutes, now = Date.now()) => !isApprovalItem(it) && Boolean(activeMutes(mutes, now)[fromOf(it).toLowerCase()]);
|
|
39
|
+
/** Decide which fresh arrivals print. `announced` is a Map(title -> { at, n }) kept for the window session and updated here. -> { print, collapsed, muted } */
|
|
40
|
+
export function announceFresh(fresh, announced, { mutes = {}, now = Date.now() } = {}) {
|
|
41
|
+
const print = []; let collapsed = 0; let muted = 0;
|
|
42
|
+
for (const it of fresh) {
|
|
43
|
+
if (isMuted(it, mutes, now)) { muted++; continue; }
|
|
44
|
+
const key = titleOf(it); const prev = announced.get(key);
|
|
45
|
+
if (!isApprovalItem(it) && prev && now - prev.at < REPEAT_WINDOW_MS) { prev.n++; collapsed++; continue; }
|
|
46
|
+
announced.set(key, { at: now, n: 1 }); print.push(it);
|
|
47
|
+
}
|
|
48
|
+
return { print, collapsed, muted };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The status-area line, or '' when nothing is unread: `📨 2 new · <newest title> (×3)`. Muted sources are left out of the count. */
|
|
52
|
+
export function inboxBarText(items, cols = 80, { mutes = {}, now = Date.now(), announced = null } = {}) {
|
|
53
|
+
const un = unreadItems(items).filter((i) => !isMuted(i, mutes, now));
|
|
54
|
+
if (!un.length) return '';
|
|
55
|
+
const head = `📨 ${un.length} new · `;
|
|
56
|
+
const burst = bursts(un).find((b) => b.items.includes(un[0])); // APPROVAL-BURST: a burst of one agent's same-tool approvals reads as one line
|
|
57
|
+
if (burst) return head + clip(burstLine(burst).replace(/^📨 /, '').replace(/ · \/approvals$/, ''), Math.max(8, cols - 4 - [...head].length));
|
|
58
|
+
const reps = Math.max(un.filter((i) => sameTitle(i, un[0])).length, announced?.get(titleOf(un[0]))?.n || 0);
|
|
59
|
+
const tail = reps > 1 ? ` (×${reps})` : '';
|
|
60
|
+
return head + clip(titleOf(un[0]), Math.max(8, cols - 4 - [...head].length - [...tail].length)) + tail;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Unread items not seen on an earlier poll. `seen` is a Set of ids; the first poll seeds it and announces nothing. */
|
|
64
|
+
export function arrivals(items, seen) {
|
|
65
|
+
const un = unreadItems(items);
|
|
66
|
+
const fresh = seen ? un.filter((i) => !seen.has(i.id)) : [];
|
|
67
|
+
// Cumulative: a short or empty read (collector restarting) or an item rolling out of the latest 10 must not make it "new" on return.
|
|
68
|
+
const ids = new Set(seen || []);
|
|
69
|
+
for (const i of Array.isArray(items) ? items : []) if (i?.id) ids.add(i.id);
|
|
70
|
+
return { fresh, ids };
|
|
71
|
+
}
|
|
72
|
+
export const SEEN_KEEP = 300;
|
|
73
|
+
/** The persisted seen ids for one org (profile settings `inboxSeenByOrg`), or null when none were saved. */
|
|
74
|
+
export const loadSeen = (settings, orgId) => { const v = settings?.inboxSeenByOrg?.[orgId || '_']; return Array.isArray(v) && v.length ? new Set(v) : null; };
|
|
75
|
+
/** The settings value with this org's seen ids (newest kept, capped) stored. */
|
|
76
|
+
export const withSeen = (settings, orgId, ids) => ({ ...(settings?.inboxSeenByOrg || {}), [orgId || '_']: [...ids].slice(-SEEN_KEEP) });
|
|
77
|
+
/** One arrival line; `n` is the item's own number in the /inbox list (0 when it is not in the latest 10; defaults to 1). */
|
|
78
|
+
export const arrivalLine = (it, n = 1) => `📨 ${clip(it.title || it.kind || 'untitled', 70)} · /inbox${n ? ` ${n}` : ''}`;
|
|
79
|
+
|
|
80
|
+
export const fromOf = (it) => String(it.created_by_name || it.payload?.source || it.payload?.approval_context?.source_label || it.source || it.inbox_type || it.kind || 'system');
|
|
81
|
+
export function ageOf(v, now = Date.now()) {
|
|
82
|
+
const t = ms(v); if (!t) return '?';
|
|
83
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
84
|
+
if (s < 90) return 'now';
|
|
85
|
+
if (s < 5400) return `${Math.round(s / 60)}m`;
|
|
86
|
+
if (s < 129600) return `${Math.round(s / 3600)}h`;
|
|
87
|
+
return `${Math.round(s / 86400)}d`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The latest 10 as a compact table; unread rows carry a dot. -> [{ text, tone }] */
|
|
91
|
+
export function inboxTable(items, now = Date.now(), cols = 100, max = 10, mutes = {}) {
|
|
92
|
+
const rows = newestFirst(items).slice(0, max);
|
|
93
|
+
if (!rows.length) return [{ text: 'Your inbox is empty. (email is /mail)', tone: 'dim' }];
|
|
94
|
+
const tw = Math.max(16, Math.min(60, cols - 40));
|
|
95
|
+
const out = [{ text: ` ${pad('#', 3)}${pad('when', 6)}${pad('from', 14)}${pad('title', tw)} state`, tone: 'dim' }];
|
|
96
|
+
rows.forEach((it, i) => {
|
|
97
|
+
const un = isUnread(it); const mu = isMuted(it, mutes, now);
|
|
98
|
+
const reps = un ? unreadItems(items).filter((x) => sameTitle(x, it)).length : 0;
|
|
99
|
+
const title = titleOf(it); const shown = reps > 1 ? `${clip(title, Math.max(8, tw - 6))} (×${reps})` : title;
|
|
100
|
+
out.push({ text: `${un ? '●' : ' '} ${pad(i + 1, 3)}${pad(ageOf(it.created_at ?? it.createdAt, now), 6)}${pad(fromOf(it), 14)}${pad(shown, tw)} ${un ? 'unread' : (it.status || 'read')}${mu ? ' (muted)' : ''}`, tone: un && !mu ? 'accent' : 'dim' });
|
|
101
|
+
});
|
|
102
|
+
const un = unreadItems(items).filter((i) => !isMuted(i, mutes, now)).length;
|
|
103
|
+
out.push({ text: `${un} unread · /inbox N open one · /inbox read N|all · /inbox unread N|all · /inbox open N (approvals) · /inbox mute <from> [1h|8h|1d] · email is /mail`, tone: 'dim' });
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** '5 6', '5-8', '1,3', '#2 4-5' -> sorted unique numbers, or null when any token is not a number or range. */
|
|
108
|
+
export function parseNumbers(text) {
|
|
109
|
+
const toks = String(text || '').trim().split(/[\s,]+/).filter(Boolean);
|
|
110
|
+
if (!toks.length) return null;
|
|
111
|
+
const out = new Set();
|
|
112
|
+
for (const t of toks) {
|
|
113
|
+
const m = /^#?(\d+)(?:-#?(\d+))?$/.exec(t); if (!m) return null;
|
|
114
|
+
const a = Number(m[1]), b = m[2] === undefined ? a : Number(m[2]);
|
|
115
|
+
if (a < 1 || b < 1 || b - a > 200) return null;
|
|
116
|
+
for (let k = Math.min(a, b); k <= Math.max(a, b); k++) out.add(k);
|
|
117
|
+
}
|
|
118
|
+
return [...out].sort((x, y) => x - y);
|
|
119
|
+
}
|
|
120
|
+
const one = (kind, ns) => (ns.length === 1 ? { kind, n: ns[0] } : { kind, ns });
|
|
121
|
+
|
|
122
|
+
/** Arg grammar: '' | N | N open|read | read N..|all | open N. Several numbers and ranges (5 6, 5-8) are fine for read. -> { kind, n? | ns? | all? } | { kind:'usage' } */
|
|
123
|
+
export function parseInbox(arg) {
|
|
124
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
125
|
+
if (!a || a === 'list' || a === 'latest') return { kind: 'list' };
|
|
126
|
+
if (a === 'mutes') return { kind: 'mutes' };
|
|
127
|
+
let m = /^(mute|unmute)\s+(\S+)(?:\s+(\S+))?$/.exec(a);
|
|
128
|
+
if (m) {
|
|
129
|
+
if (m[1] === 'unmute') return m[3] ? { kind: 'usage' } : { kind: 'unmute', source: m[2] };
|
|
130
|
+
const dur = m[3] === undefined ? MUTE_DEFAULT_MS : muteDuration(m[3]);
|
|
131
|
+
return dur ? { kind: 'mute', source: m[2], ms: dur } : { kind: 'usage' };
|
|
132
|
+
}
|
|
133
|
+
m = /^(?:show\s+)?#?(\d+)$/.exec(a); if (m) return { kind: 'show', n: Number(m[1]) };
|
|
134
|
+
m = /^(open|read|unread)\s+(.+)$/.exec(a);
|
|
135
|
+
if (m) {
|
|
136
|
+
if ((m[1] === 'read' || m[1] === 'unread') && m[2] === 'all') return { kind: m[1], all: true };
|
|
137
|
+
const ns = parseNumbers(m[2]); if (!ns) return { kind: 'usage' };
|
|
138
|
+
if (m[1] === 'open') return ns.length === 1 ? { kind: 'open', n: ns[0] } : { kind: 'usage' };
|
|
139
|
+
return one(m[1], ns);
|
|
140
|
+
}
|
|
141
|
+
m = /^(.+?)\s+(open|read|unread|show)$/.exec(a); // word order: /inbox 1 open, /inbox 5-8 read
|
|
142
|
+
if (m) {
|
|
143
|
+
const ns = parseNumbers(m[1]); if (!ns) return { kind: 'usage' };
|
|
144
|
+
if (m[2] === 'open') return ns.length === 1 ? { kind: 'open', n: ns[0] } : { kind: 'usage' };
|
|
145
|
+
if (m[2] === 'show') return ns.length === 1 ? { kind: 'show', n: ns[0] } : { kind: 'usage' };
|
|
146
|
+
return one(m[2], ns);
|
|
147
|
+
}
|
|
148
|
+
return { kind: 'usage' };
|
|
149
|
+
}
|
|
150
|
+
export const INBOX_USAGE = 'Usage: /inbox | /inbox N | /inbox read N|N-M|all | /inbox unread N|N-M|all (undo a read) | /inbox open N (or /inbox 1 open, /inbox 1 read, /inbox 1 unread) | /inbox mute <from> [1h|8h|1d] | /inbox unmute <from> | /inbox mutes (email is /mail)';
|
|
151
|
+
|
|
152
|
+
/** The first non-empty string among the item's own body fields and the payload's (body_md is the stored column every task/info item uses). */
|
|
153
|
+
const bodyOf = (it) => {
|
|
154
|
+
const p = it.payload || {};
|
|
155
|
+
for (const v of [it.body_md, it.body, it.message, p.body_md, p.body, p.body_text, p.text, p.message, p.summary, p.description]) if (typeof v === 'string' && v.trim()) return v;
|
|
156
|
+
return '';
|
|
157
|
+
};
|
|
158
|
+
/** One item in full: sender, subject, the whole body (every line, nothing cut), then the actions that apply. `n` is its /inbox number. -> [{ text, tone }] */
|
|
159
|
+
export function inboxDetail(it, now = Date.now(), n = 0) {
|
|
160
|
+
const p = it.payload || {};
|
|
161
|
+
const body = bodyOf(it);
|
|
162
|
+
const subject = p.subject || p.email_subject || '';
|
|
163
|
+
const out = [{ text: `${it.title || it.kind || 'untitled'}`, tone: 'accent' },
|
|
164
|
+
{ text: `from ${p.from || p.sender || p.from_email || fromOf(it)} · ${ageOf(it.created_at ?? it.createdAt, now)} ago · ${it.kind || 'item'} · ${it.priority || 'normal'} · ${it.status || '?'}`, tone: 'dim' }];
|
|
165
|
+
if (subject && subject !== it.title) out.push({ text: `subject: ${subject}`, tone: 'dim' });
|
|
166
|
+
if (body) for (const l of String(body).split('\n')) out.push({ text: l, tone: 'text' });
|
|
167
|
+
else out.push({ text: '(no body: this item carries only a title)', tone: 'dim' });
|
|
168
|
+
if (it.dashboard_url) out.push({ text: it.dashboard_url, tone: 'dim' });
|
|
169
|
+
const at = n ? ` ${n}` : ' N';
|
|
170
|
+
out.push({ text: `actions: /inbox unread${at} (keep it unread) · /inbox read${at} · /inbox${isApprovalItem(it) ? ` open${at} (decide it in /approvals)` : ''}`, tone: 'dim' });
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** Approval-type items route to /approvals (the existing view). */
|
|
174
|
+
export const isApprovalItem = (it) => String(it.kind || '').toLowerCase() === 'approval' || Boolean(it.payload?.approval_context) || it.payload?.type === 'decision';
|
|
175
|
+
|
|
176
|
+
/** /inbox mutes -> [{ text, tone }] with each expiry. */
|
|
177
|
+
export function mutesLines(mutes, now = Date.now()) {
|
|
178
|
+
const act = Object.entries(activeMutes(mutes, now));
|
|
179
|
+
if (!act.length) return [{ text: 'No sources muted. /inbox mute <from> [1h|8h|1d] (default 8h). Approvals are never muted.', tone: 'dim' }];
|
|
180
|
+
const left = (v) => { const m = Math.ceil((v - now) / 60000); return m < 90 ? `${m}m` : m < 2880 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`; };
|
|
181
|
+
return act.map(([k, v]) => ({ text: `${k} muted until ${new Date(v).toISOString().slice(0, 16).replace('T', ' ')} UTC (${left(v)} left)`, tone: 'dim' }));
|
|
182
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAZY-AI-CLI (owner 2026-10-19): "Quox can depend heavily on your choice or blend of Claude and OpenAI models.
|
|
3
|
+
* We advise installing both Claude Code and Codex." Draw people in step by step: when something needs a vendor CLI
|
|
4
|
+
* that is missing, offer to install it THEN, as a y/N.
|
|
5
|
+
*
|
|
6
|
+
* ONE flow behind `quox provider install` and the TUI offer cards (first-run suggestion + just-in-time), so they
|
|
7
|
+
* cannot drift. The server downloads the vendor's own npm package (never a binary Quox ships), as the Quox service
|
|
8
|
+
* user, into a volume that survives updates, audited start and finish (services/collector/routes/providerCli.js).
|
|
9
|
+
*/
|
|
10
|
+
import { detectCallerSource } from '../core/callerSource.js';
|
|
11
|
+
|
|
12
|
+
export const INSTALL_TOOLS = {
|
|
13
|
+
'claude-code': { id: 'claude-code', name: 'Claude Code', vendor: 'Anthropic', bin: 'claude', after: 'Claude sign-in is available now: sign in from the dashboard Terminal, then pick Claude in /model.' },
|
|
14
|
+
codex: { id: 'codex', name: 'Codex', vendor: 'OpenAI', bin: 'codex', after: 'Codex is ready: sign in to ChatGPT from the dashboard Terminal (Codex), then pick it in /model.' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const SUGGEST_LINE = 'Quox works best with both Claude and OpenAI models. We suggest installing Claude Code and Codex.';
|
|
18
|
+
|
|
19
|
+
const ALIASES = { claude: 'claude-code', 'claude-code': 'claude-code', claudecode: 'claude-code', codex: 'codex', 'openai-codex': 'codex' };
|
|
20
|
+
export const toolFor = (s) => ALIASES[String(s || '').trim().toLowerCase()] || null;
|
|
21
|
+
|
|
22
|
+
const sizeText = (p) => (p?.downloadMb ? `about ${p.downloadMb} MB` : null);
|
|
23
|
+
|
|
24
|
+
export function planLine(p) {
|
|
25
|
+
const t = INSTALL_TOOLS[p?.id] || { name: p?.name || p?.id };
|
|
26
|
+
const size = sizeText(p);
|
|
27
|
+
return `${t.name} ${p?.version || ''} (${t.vendor ? `${t.vendor}'s ` : ''}${p?.package || ''}) from ${p?.source || 'registry.npmjs.org'}${size ? `, ${size} download` : ''}${p?.installedMb ? `, ${p.installedMb} MB on disk` : ''}. It installs on this server as the Quox service user and stays across updates.`.replace(/\s+/g, ' ');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function offerQuestion(tool, plan) {
|
|
31
|
+
const t = INSTALL_TOOLS[tool];
|
|
32
|
+
const size = sizeText(plan);
|
|
33
|
+
return `${t.name} isn't installed on this server. Install it now? (${size ? `${size} from npm` : 'from npm'})`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The CLIs the server says are missing, in a stable order. */
|
|
37
|
+
export function missingTools(status) {
|
|
38
|
+
if (!status?.ok || !Array.isArray(status.tools)) return [];
|
|
39
|
+
return Object.keys(INSTALL_TOOLS).filter((id) => status.tools.some((t) => t.id === id && t.installed === false));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An agent (a session, an attached agent, a Claude process) never self-approves an install: it files a card. */
|
|
43
|
+
export function isAgentCaller(env = process.env, detect = detectCallerSource) {
|
|
44
|
+
if (env.QUOX_AGENT_ID || env.QUOX_SESSION_ID || env.QUOX_SESSION) return true;
|
|
45
|
+
let src = null;
|
|
46
|
+
try { src = detect(env); } catch { src = null; }
|
|
47
|
+
if (!src) return false;
|
|
48
|
+
if (['attached_agent', 'held_slot', 'QUOX_STREAM'].includes(src.via)) return true;
|
|
49
|
+
return src.via === 'process' && /^claude\b/i.test(String(src.label || ''));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const POLL_MS = 3000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Start the server install and wait for it. Never throws.
|
|
56
|
+
* @returns {Promise<{ok:boolean, version?:string|null, lines:string[]}>}
|
|
57
|
+
*/
|
|
58
|
+
export async function runInstall(api, tool, { approvalId = null, onProgress = () => {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, timeoutMs = 15 * 60 * 1000 } = {}) {
|
|
59
|
+
const t = INSTALL_TOOLS[tool];
|
|
60
|
+
if (!t) return { ok: false, lines: [`No installable CLI called "${tool}". Use one of: ${Object.keys(INSTALL_TOOLS).join(', ')}`] };
|
|
61
|
+
const start = await api.providerCliInstall(tool, { approvalId });
|
|
62
|
+
if (!start?.ok || !start.job_id) {
|
|
63
|
+
if (start?.status === 404 || start?.status === 405) return { ok: false, lines: [`This Quox server is too old to install ${t.name} for you. Update it first: quox server update`] };
|
|
64
|
+
if (start?.status === 403) return { ok: false, lines: [`✖ ${start.error || 'Only an instance admin can install software on this server.'} Nothing was installed.`] };
|
|
65
|
+
return { ok: false, lines: [`✖ ${t.name} was not installed: ${start?.error || 'the server did not start the install'}`] };
|
|
66
|
+
}
|
|
67
|
+
const began = now();
|
|
68
|
+
let last = began;
|
|
69
|
+
for (;;) {
|
|
70
|
+
await sleep(POLL_MS);
|
|
71
|
+
const j = await api.providerCliJob(start.job_id);
|
|
72
|
+
if (j?.state === 'done' && j.ok) {
|
|
73
|
+
return { ok: true, version: j.version || null, lines: [`✔ ${t.name} ${j.version || ''} is installed on this server.`.replace(/\s+/g, ' ').replace(' .', '.'), t.after] };
|
|
74
|
+
}
|
|
75
|
+
if (j?.state === 'failed') return { ok: false, lines: [`✖ ${t.name} was not installed: ${j.error || 'the install failed'}`] };
|
|
76
|
+
if (now() - began >= timeoutMs) return { ok: false, lines: [`◐ ${t.name} is still running its install on the server. Check again shortly: quox provider list`] };
|
|
77
|
+
if (now() - last >= 15000) { last = now(); onProgress(`◐ installing ${t.name} (${Math.round((now() - began) / 1000)}s)`); }
|
|
78
|
+
}
|
|
79
|
+
}
|
package/lib/quoxon/keys.js
CHANGED
|
@@ -53,7 +53,7 @@ const CSI_KEYS = {
|
|
|
53
53
|
'\x1bOA': 'up', '\x1bOB': 'down', '\x1bOC': 'right', '\x1bOD': 'left',
|
|
54
54
|
'\x1b[H': 'home', '\x1b[F': 'end', '\x1bOH': 'home', '\x1bOF': 'end',
|
|
55
55
|
'\x1b[1~': 'home', '\x1b[4~': 'end', '\x1b[7~': 'home', '\x1b[8~': 'end',
|
|
56
|
-
'\x1b[3~': 'delete', '\x1b[Z': 'shift-tab',
|
|
56
|
+
'\x1b[3~': 'delete', '\x1b[5~': 'pageup', '\x1b[6~': 'pagedown', '\x1b[Z': 'shift-tab',
|
|
57
57
|
'\x1b[13;2u': 'shift-enter', '\x1b[27;2;13~': 'shift-enter', '\x1b\r': 'shift-enter', '\x1b\n': 'shift-enter',
|
|
58
58
|
'\x1b[1;5D': 'word-left', '\x1b[1;5C': 'word-right', '\x1b[1;3D': 'word-left', '\x1b[1;3C': 'word-right',
|
|
59
59
|
// COMPOSER-EDIT (owner 2026-10-17: edit like Claude Code's prompt box). Alt+B/F (macOS Option+Left/Right), iTerm2 Alt+arrows,
|
|
@@ -68,8 +68,8 @@ const CSI_KEYS = {
|
|
|
68
68
|
const CSI_LIST = Object.keys(CSI_KEYS).sort((a, b) => b.length - a.length);
|
|
69
69
|
|
|
70
70
|
const CTRL = {
|
|
71
|
-
'\x01': 'home', '\x05': 'end', '\x03': 'ctrl-c', '\x04': 'ctrl-d', '\x07': 'ctrl-g',
|
|
72
|
-
'\x0b': 'kill-line-forward', '\x0f': 'ctrl-o', '\x13': 'ctrl-s', '\x15': 'kill-line', '\x17': 'delete-word',
|
|
71
|
+
'\x01': 'home', '\x05': 'end', '\x03': 'ctrl-c', '\x04': 'ctrl-d', '\x07': 'ctrl-g', '\x12': 'ctrl-r',
|
|
72
|
+
'\x0b': 'kill-line-forward', '\x0f': 'ctrl-o', '\x13': 'ctrl-s', '\x1d': 'ctrl-]', '\x15': 'kill-line', '\x17': 'delete-word',
|
|
73
73
|
'\x1f': 'undo', '\x19': 'redo', '\x0a': 'newline', '\x7f': 'backspace', '\x08': 'backspace',
|
|
74
74
|
'\r': 'enter', '\t': 'tab', '\x02': 'left', '\x06': 'right'
|
|
75
75
|
};
|
|
@@ -118,7 +118,7 @@ export function parseChunk(chunk, state = initialKeyState(), draft = '') {
|
|
|
118
118
|
const run = /^[^\x00-\x1f\x7f]+/.exec(s);
|
|
119
119
|
if (run) { events.push({ type: 'text', text: run[0] }); s = s.slice(run[0].length); continue; }
|
|
120
120
|
const c = s[0];
|
|
121
|
-
if (CTRL[c]) { events.push({ type: 'key', name: CTRL[c] }); }
|
|
121
|
+
if (CTRL[c]) { events.push(c === '\x01' ? { type: 'key', name: CTRL[c], via: 'ctrl-a' } : { type: 'key', name: CTRL[c] }); } // via: a double Ctrl+A selects all (app.js)
|
|
122
122
|
s = s.slice(1);
|
|
123
123
|
}
|
|
124
124
|
return { events: coalesceBurst(events, draft), state: st };
|