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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAZY PATH (QUOXON, 2026-10-29; plan first-run-onboarding-plan.md section 4): /deck on a machine that lacks the
|
|
3
|
+
* quoxdeck extension, or the ALSA headers its native midi dependency builds against, OFFERS one y/N instead of a raw
|
|
4
|
+
* build error. Pure planning plus an injected runner: nothing here touches the machine until the user says y, and
|
|
5
|
+
* sudo is the user's own (the exact command is shown first; no password is ever read, stored or passed).
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export const DECK_PKG = 'quoxdeck';
|
|
10
|
+
export const ALSA_PKG = 'libasound2-dev';
|
|
11
|
+
const SYSTEM = { apt: { cmd: `sudo apt-get install -y ${ALSA_PKG}`, pkg: ALSA_PKG }, dnf: { cmd: 'sudo dnf install -y alsa-lib-devel', pkg: 'alsa-lib-devel' } };
|
|
12
|
+
const NPM_CMD = `npm i -g ${DECK_PKG}`;
|
|
13
|
+
const ADD_CMD = `quox ext add ${DECK_PKG}`;
|
|
14
|
+
|
|
15
|
+
const quick = (file, args) => { try { return spawnSync(file, args, { stdio: 'ignore', timeout: 3000 }); } catch { return { status: 1 }; } };
|
|
16
|
+
/** Cheap ALSA header check: pkg-config, else the header path. */
|
|
17
|
+
export async function defaultHasAlsa() {
|
|
18
|
+
if (quick('pkg-config', ['--exists', 'alsa']).status === 0) return true;
|
|
19
|
+
return ['/usr/include/alsa/asoundlib.h'].some((p) => quick('test', ['-f', p]).status === 0);
|
|
20
|
+
}
|
|
21
|
+
/** apt or dnf only; anything else is null (we never guess further). */
|
|
22
|
+
export async function defaultPackageManager() {
|
|
23
|
+
for (const pm of ['apt-get', 'dnf']) if (quick('sh', ['-c', `command -v ${pm}`]).status === 0) return pm === 'dnf' ? 'dnf' : 'apt';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* -> null (nothing to offer) | { text } (no automatic path: say it, show manual commands)
|
|
29
|
+
* | { question, steps:[{label, cmd, interactive?}], manual:[cmd] }
|
|
30
|
+
*/
|
|
31
|
+
export async function deckOffer({ platform = process.platform, isInstalled, hasAlsa = defaultHasAlsa, packageManager = defaultPackageManager } = {}) {
|
|
32
|
+
const installed = Boolean(await isInstalled());
|
|
33
|
+
const alsaMissing = platform === 'linux' && !(await hasAlsa());
|
|
34
|
+
if (installed && !alsaMissing) return null;
|
|
35
|
+
const pm = alsaMissing ? await packageManager() : null;
|
|
36
|
+
if (alsaMissing && !pm) {
|
|
37
|
+
return { steps: null, text: `quoxdeck needs the ALSA development headers (${ALSA_PKG} on Debian, alsa-lib-devel on Fedora). This machine has neither apt or dnf, so install them with your own package manager, then run: ${NPM_CMD} && ${ADD_CMD}` };
|
|
38
|
+
}
|
|
39
|
+
const steps = [];
|
|
40
|
+
if (alsaMissing) steps.push({ label: `system package ${SYSTEM[pm].pkg}`, cmd: SYSTEM[pm].cmd, interactive: true });
|
|
41
|
+
steps.push({ label: installed ? 'rebuild the quoxdeck extension' : 'install the quoxdeck extension', cmd: NPM_CMD });
|
|
42
|
+
steps.push({ label: 'list it in quoxon', cmd: ADD_CMD });
|
|
43
|
+
const need = [...(alsaMissing ? [`system package ${SYSTEM[pm].pkg}`] : []), 'the quoxdeck extension'];
|
|
44
|
+
return { steps, manual: steps.map((s) => s.cmd), question: `quoxdeck needs: ${need.join(', then ')}. Install now?` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const declineText = (o) => `Nothing to turn off. When you want it: ${o.manual.join(' && ')}`;
|
|
48
|
+
|
|
49
|
+
/** Runs the steps in order via the injected run(step); stops at the first failure; re-runs /deck only on full success. */
|
|
50
|
+
export async function runDeckOffer(o, { run, say = () => {}, rerun = async () => {} }) {
|
|
51
|
+
for (const s of o.steps) {
|
|
52
|
+
say(`Running: ${s.cmd}`);
|
|
53
|
+
let r; try { r = await run(s); } catch (e) { r = { code: 1, error: e?.message }; }
|
|
54
|
+
if (r?.code !== 0) { say(`Stopped at: ${s.cmd} (${r?.error || `exit ${r?.code ?? 'unknown'}`}). Nothing after it ran. To finish by hand: ${o.manual.slice(o.steps.indexOf(s)).join(' && ')}`, 'warn'); return { ok: false, failed: s.cmd }; }
|
|
55
|
+
}
|
|
56
|
+
await rerun();
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const AUTOPLAY_LABEL = 'DEMO: demo data, nothing real runs.';
|
|
6
6
|
export const AUTOPLAY_REQUEST = 'Show me what you can do (demo)';
|
|
7
|
-
export const AUTOPLAY_PROOF = '/proof';
|
|
7
|
+
export const AUTOPLAY_PROOF = '/proof 1'; // bare /proof lists envelope ids (d73f04ab4); 1 is the newest chain, which the journey shows
|
|
8
8
|
export const AUTOPLAY = Object.freeze({ seed: 20261013, startMs: 900, typeMinMs: 38, typeMaxMs: 78, afterTypeMs: 450, approvalHoldMs: 2500, afterKeyMs: 400, afterTurnMs: 900, finalHoldMs: 600 });
|
|
9
9
|
|
|
10
10
|
/** Small seeded PRNG (mulberry32): deterministic across runs and platforms. */
|
package/lib/quoxon/demo.js
CHANGED
|
@@ -19,13 +19,15 @@ function decisionStore(file) {
|
|
|
19
19
|
const wait = (ms) => new Promise(r => setTimeout(r, ms || 0));
|
|
20
20
|
return {
|
|
21
21
|
// staleMs: an answered item is still listed for this long (a server that lags behind its own answers)
|
|
22
|
-
|
|
22
|
+
// failAfterAnswers: the server is down once anything was answered (the walkthrough's closing re-check)
|
|
23
|
+
async open() { const d = load(); await wait(d.approvalsDelayMs); if (d.failAfterAnswers && d.log.length) { const e = new Error('connect ECONNREFUSED'); e.status = 503; throw e; } return d.items.filter(x => x.status === 'open' || (d.staleMs && x.answeredAt && Date.now() - x.answeredAt < d.staleMs)); },
|
|
23
24
|
async answer(id, key, note) {
|
|
24
25
|
const d0 = load(); await wait(d0.answerDelayMs);
|
|
25
26
|
const d = load(); const it = d.items.find(x => x.id === id);
|
|
26
27
|
if (!it || it.status !== 'open') { const e = new Error('already handled'); e.status = 404; throw e; }
|
|
27
28
|
it.status = 'answered'; it.answeredAt = Date.now(); it.answer = { key, note: note || null };
|
|
28
29
|
d.log.push({ id, key, note: note || null, at: Date.now() });
|
|
30
|
+
if (d.arriveOnAnswer && !d.arrived) { d.items.push(...d.arriveOnAnswer); d.arrived = true; } // arriveOnAnswer: new items land while the owner is answering
|
|
29
31
|
writeFileSync(file, JSON.stringify(d));
|
|
30
32
|
return { ok: true };
|
|
31
33
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A DESTRUCTIVE approval (owner 2026-10-27 16:15 UTC, after two delete_workflow cards were approved by one 'y' each)
|
|
2
|
+
// needs a stronger confirm: the human types the object's name. One place decides what is destructive.
|
|
3
|
+
import { describeInboxItem } from './events.js';
|
|
4
|
+
|
|
5
|
+
/** Tools named delete_*, remove_*, purge_*, destroy_*, drop_* or wipe_* (the prefix only; "undelete_x" is not one). */
|
|
6
|
+
export const DESTRUCTIVE_TOOL = /^(delete|remove|purge|destroy|drop|wipe)_/i;
|
|
7
|
+
export const isDestructiveTool = (tool) => DESTRUCTIVE_TOOL.test(String(tool ?? '').trim());
|
|
8
|
+
|
|
9
|
+
const flagged = (o) => Boolean(o && (o.destructive === true || o.irreversible === true));
|
|
10
|
+
|
|
11
|
+
/** True when the server marks the item destructive or irreversible, else when its tool name follows the rule. */
|
|
12
|
+
export function isDestructive(it) {
|
|
13
|
+
if (!it) return false;
|
|
14
|
+
const p = it.payload || {}; const c = p.approval_context || {};
|
|
15
|
+
if (flagged(it) || flagged(p) || flagged(c) || flagged(c.method) || flagged(p.what)) return true;
|
|
16
|
+
return isDestructiveTool(describeInboxItem(it).tool);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : typeof v === 'number' ? String(v) : null);
|
|
20
|
+
const argsOf = (it) => {
|
|
21
|
+
const p = it?.payload || {}; const c = p.approval_context || {}; const m = c.method || {};
|
|
22
|
+
const a = m.args ?? m.input ?? c.args ?? c.input ?? p.args ?? p.input ?? p.action_params ?? null;
|
|
23
|
+
if (a && typeof a === 'object' && !Array.isArray(a)) return a;
|
|
24
|
+
if (typeof a === 'string') { try { const j = JSON.parse(a); if (j && typeof j === 'object' && !Array.isArray(j)) return j; } catch { /* not JSON */ } }
|
|
25
|
+
return {};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** What gets deleted: { label, name }. The human name when the input has one (name, workflow_name, host_name...), else the id from the input, else the short item id. name is what the human must type. */
|
|
29
|
+
export function destructiveObject(it) {
|
|
30
|
+
const a = argsOf(it);
|
|
31
|
+
const nameKey = Object.keys(a).find(k => /(^|_)name$/i.test(k) && str(a[k]));
|
|
32
|
+
const idKey = Object.keys(a).find(k => /(^|_)id$/i.test(k) && str(a[k]));
|
|
33
|
+
const v = (nameKey && str(a[nameKey])) || (idKey && str(a[idKey])) || String(it?.id ?? 'item').slice(0, 8);
|
|
34
|
+
return { label: v, name: v };
|
|
35
|
+
}
|
|
@@ -153,13 +153,14 @@ export function chainCells(d, now, { still = false } = {}) {
|
|
|
153
153
|
* Cells without `rgb` take the rule's own colour; `label: true` takes the label's own colour.
|
|
154
154
|
* `still` (a slow redraw): no blink and no katakana, but every lit dot and the end colours stay.
|
|
155
155
|
*/
|
|
156
|
-
export function dividerFrame({ cols, label, d, now, mono = false, reduced = false, still = false, seed = 0 }) {
|
|
156
|
+
export function dividerFrame({ cols, label, extra = null, d, now, mono = false, reduced = false, still = false, seed = 0 }) {
|
|
157
157
|
if (mono || reduced || !d || cols < MIN_COLS || !label || !dividerLive(d, now)) return null;
|
|
158
158
|
const lab = ` ${label} `;
|
|
159
159
|
const lw = stringWidth(lab);
|
|
160
160
|
const chain = chainCells(d, now, { still });
|
|
161
161
|
const cw = chain.cells.length;
|
|
162
|
-
const
|
|
162
|
+
const ew = extra ? stringWidth(` ${extra.text} `) : 0;
|
|
163
|
+
const left = cols - lw - 1 - cw - ew;
|
|
163
164
|
if (left < 4) return null;
|
|
164
165
|
// the newest lit layer names itself to the left of the chain, in its colour, while it is new
|
|
165
166
|
const tag = chain.newest && now - chain.newestAt < TAG_MS ? ` ${chain.newest} ${LAYER_NAMES[chain.newest] || ''} ` : '';
|
|
@@ -184,7 +185,7 @@ export function dividerFrame({ cols, label, d, now, mono = false, reduced = fals
|
|
|
184
185
|
}
|
|
185
186
|
} else labelCells = [{ t: lab, label: true }];
|
|
186
187
|
}
|
|
187
|
-
return { cells: [...leftCells, ...chain.cells, ...labelCells, { t: '─' }] };
|
|
188
|
+
return { cells: [...leftCells, ...chain.cells, ...labelCells, ...(extra ? [{ t: ` ${extra.text} `, hex: extra.hex, bold: true }] : []), { t: '─' }] };
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
/** Plain text of a frame (tests and pty proofs). */
|
|
@@ -38,7 +38,7 @@ export function paintFrame(f, { ruleColor, labelColor }) {
|
|
|
38
38
|
const flush = () => { if (run) out += paint(run); run = ''; };
|
|
39
39
|
for (const c of f.cells) {
|
|
40
40
|
const rgb = c.rgb ? c.rgb.map(v => Math.max(0, Math.min(255, Math.round(v / RUN_Q) * RUN_Q))) : null;
|
|
41
|
-
const st = { rgb, hex: rgb ? null : (c.label ? labelColor : ruleColor) || null, bold: Boolean(c.bold) };
|
|
41
|
+
const st = { rgb, hex: rgb ? null : (c.hex || (c.label ? labelColor : ruleColor)) || null, bold: Boolean(c.bold) };
|
|
42
42
|
const k = `${rgb ? rgb.join(',') : st.hex}|${st.bold}`;
|
|
43
43
|
if (k !== key) { flush(); key = k; style = st; }
|
|
44
44
|
run += c.t;
|
|
@@ -48,10 +48,10 @@ export function paintFrame(f, { ruleColor, labelColor }) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/** The bottom rule as a strip element, or null when the caller should draw its normal Rule (a plain call, not a component). */
|
|
51
|
-
export function dividerRuleEl({ cols, label, divider, now, ruleColor, labelColor, mono = false, reduced = false, slow = null }) {
|
|
51
|
+
export function dividerRuleEl({ cols, label, extra = null, divider, now, ruleColor, labelColor, mono = false, reduced = false, slow = null }) {
|
|
52
52
|
if (!divider) return null;
|
|
53
53
|
const isSlow = noteFrameGap(slow, divider, now);
|
|
54
|
-
const f = dividerFrame({ cols, label, d: divider, now, mono, reduced, still: isSlow }); // slow: keep the lit pips, drop the motion
|
|
54
|
+
const f = dividerFrame({ cols, label, extra, d: divider, now, mono, reduced, still: isSlow }); // slow: keep the lit pips, drop the motion
|
|
55
55
|
if (!f) return null;
|
|
56
56
|
return h(Text, { wrap: 'truncate-end' }, paintFrame(f, { ruleColor, labelColor }));
|
|
57
57
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DO-NOT-DISTURB (owner 2026-10-28: "sleep means sleep"). Pure helpers for /donotdisturb (/dnd) and /morningreport.
|
|
3
|
+
* While DND is on the window collects instead of announcing; the app holds the collected arrivals and these functions
|
|
4
|
+
* turn them into the morning digest. No I/O here.
|
|
5
|
+
*/
|
|
6
|
+
export const DND_NOTE = 'do not disturb';
|
|
7
|
+
export const DND_USAGE = 'Usage: /donotdisturb [on|off] [until HH:MM] (alias /dnd; bare turns it on)';
|
|
8
|
+
|
|
9
|
+
/** `/dnd [on|off] [until HH:MM]` -> { ok, op: 'on'|'off'|'toggle', until: {h,m}|null } | { ok:false, error }. */
|
|
10
|
+
export function parseDndArgs(arg) {
|
|
11
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
12
|
+
if (!a) return { ok: true, op: 'toggle', until: null };
|
|
13
|
+
const m = /^(?:(on|off)\s*)?(?:until\s+(\d{1,2}):(\d{2}))?$/.exec(a);
|
|
14
|
+
if (!m || (!m[1] && m[2] === undefined)) return { ok: false, error: DND_USAGE };
|
|
15
|
+
let until = null;
|
|
16
|
+
if (m[2] !== undefined) {
|
|
17
|
+
const h = Number(m[2]), mi = Number(m[3]);
|
|
18
|
+
if (h > 23 || mi > 59) return { ok: false, error: DND_USAGE };
|
|
19
|
+
if (m[1] === 'off') return { ok: false, error: DND_USAGE };
|
|
20
|
+
until = { h, m: mi };
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, op: m[1] || 'on', until };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The next local HH:MM after `now` as epoch ms. */
|
|
26
|
+
export function untilEpoch({ h, m }, now = Date.now()) {
|
|
27
|
+
const d = new Date(now); d.setHours(h, m, 0, 0);
|
|
28
|
+
if (d.getTime() <= now) d.setDate(d.getDate() + 1);
|
|
29
|
+
return d.getTime();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Text that says the owner is heading off: going to sleep / off to bed / signing off for the night / night all. */
|
|
33
|
+
const SLEEP_RE = /\b(?:i['’]?m|i am|im|we['’]?re|owner(?: is)?|adam(?: is)?)?\s*(?:going to (?:sleep|bed)|off to (?:bed|sleep)|heading to (?:bed|sleep)|going offline|signing off(?: for (?:the )?(?:night|today|now))?|logging off for the night|turning in(?: for the night)?)\b|\bnight[,!. ]+(?:all|everyone|team|folks)\b|\bgood ?night (?:all|everyone|team|folks)\b/i;
|
|
34
|
+
export const sleepIntent = (text) => SLEEP_RE.test(String(text || ''));
|
|
35
|
+
|
|
36
|
+
/** `/afk <text>` whose text says sleep/bed/night: the owner means /sleep. */
|
|
37
|
+
export const afkMeansSleep = (text) => /\b(?:sleep|sleeping|asleep|bed|bedtime|night|goodnight)\b/i.test(String(text || '')) || /\bgood night\b/i.test(String(text || ''));
|
|
38
|
+
|
|
39
|
+
export const newCollected = () => ({ inbox: [], slots: [], since: Date.now() });
|
|
40
|
+
|
|
41
|
+
const titleOf = (it) => String(it.title || it.kind || 'untitled');
|
|
42
|
+
const INCIDENT_RE = /incident|outage|critical|sev[ -]?[01]|down\b|breach|alert|failed/i;
|
|
43
|
+
export const isIncident = (it) => INCIDENT_RE.test(`${it.kind || ''} ${it.severity || ''} ${it.priority || ''} ${titleOf(it)}`);
|
|
44
|
+
|
|
45
|
+
/** Collapse by title with a count: [{ title, n }] in first-seen order. */
|
|
46
|
+
export function collapseByTitle(items) {
|
|
47
|
+
const m = new Map();
|
|
48
|
+
for (const it of items) { const k = titleOf(it); m.set(k, (m.get(k) || 0) + 1); }
|
|
49
|
+
return [...m].map(([title, n]) => ({ title, n }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How many things wait for the owner: approvals + collected inbox arrivals + collected slot messages. */
|
|
53
|
+
export const waitingCount = (c, approvals = 0) => approvals + (c?.inbox?.length || 0) + (c?.slots?.length || 0);
|
|
54
|
+
|
|
55
|
+
/** The digest as [text, tone] lines. Incidents first, then approvals, inbox (xN), slot messages, sessions that need you. */
|
|
56
|
+
export function morningLines(c, { approvals = 0, sessions = [] } = {}) {
|
|
57
|
+
const lines = [];
|
|
58
|
+
const inc = c.inbox.filter(isIncident), rest = c.inbox.filter((i) => !isIncident(i));
|
|
59
|
+
const group = (head, items, tone) => { if (items.length) { lines.push([head, tone]); for (const g of collapseByTitle(items)) lines.push([` ${g.title}${g.n > 1 ? ` (×${g.n})` : ''}`, 'plain']); } };
|
|
60
|
+
group(`Incidents (${inc.length})`, inc, 'warn');
|
|
61
|
+
if (approvals) lines.push([`Approvals waiting (${approvals}): /approvals`, 'warn']);
|
|
62
|
+
group(`Inbox (${rest.length})`, rest, 'plain');
|
|
63
|
+
if (c.slots.length) {
|
|
64
|
+
lines.push([`Slot messages (${c.slots.length})`, 'plain']);
|
|
65
|
+
for (const s of c.slots.slice(0, 10)) lines.push([` ${s.from}: ${String(s.text).replace(/\s+/g, ' ').slice(0, 90)}`, 'plain']);
|
|
66
|
+
if (c.slots.length > 10) lines.push([` ... ${c.slots.length - 10} more`, 'dim']);
|
|
67
|
+
}
|
|
68
|
+
if (sessions.length) { lines.push([`Sessions that need you (${sessions.length})`, 'warn']); for (const s of sessions.slice(0, 8)) lines.push([` ${s}`, 'plain']); }
|
|
69
|
+
if (!lines.length) lines.push(['Nothing collected while you were away.', 'ok']);
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus). The controller owns attach state and feeds REAL stream
|
|
3
|
+
* events to dragonlab's pure view (integration/companions-v1/view.mjs: the same reducer and renderer the CLI
|
|
4
|
+
* uses). No art, state logic or timers are duplicated here; this file only decides, translates and lays out.
|
|
5
|
+
* Decisions: docs/dragons/COMPANION-UX.md. The reducer never overwrites pending approvals or failures, but the host only SHOWS the dragon during a dwell (attach, key moment, invoke).
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { Box, Text } from 'ink';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { cellNodes, dragonActive, clipRoom } from './qmotion.js';
|
|
12
|
+
import { useFrame } from './motion/frame.js';
|
|
13
|
+
import { loadCompanionView } from '../dragon/companionView.js';
|
|
14
|
+
import { ART_MIN_COLS as SLOT_MIN_COLS, ART_MIN_ROWS as SLOT_MIN_ROWS } from '../dragon/frames.js';
|
|
15
|
+
import { companionPlan, motionPolicy, normaliseMode, COMPANION_MODES, VENDORED_DRAGONLAB, dragonForAgent, companionMode } from '../dragon/select.js';
|
|
16
|
+
import { createMomentEngine, createRosterWatch, createSenderWatch, LONG_TASK_MS, SUMMON_COOLDOWN_MS } from './dragonMoments.js';
|
|
17
|
+
import { moodForEvent } from './motion/level.js';
|
|
18
|
+
|
|
19
|
+
const h = React.createElement;
|
|
20
|
+
const AGES_OUT = 10 * 60 * 1000; // an attach that is not a greeting starts with its entrance already over
|
|
21
|
+
/** How long the dragon stays after any appearance (attach, key moment, invoke). The one dwell constant. */
|
|
22
|
+
export const DRAGON_DWELL_MS = 10000;
|
|
23
|
+
/** A keypress inside the first part of a dwell does not send it away: invoking with Enter and then typing must not kill it. */
|
|
24
|
+
export const KEY_GRACE_MS = 2000;
|
|
25
|
+
/** A turn at least this long counts as a "long task" when it finishes (defined with the moment engine). */
|
|
26
|
+
export { LONG_TASK_MS };
|
|
27
|
+
/** Art that is still being drawn when its dwell would end is waited for this long (from the appearance) before giving up. */
|
|
28
|
+
export const FIRST_FRAME_GRACE_MS = 20000;
|
|
29
|
+
|
|
30
|
+
/** Art needs room; below this the companion is one plain text line. */
|
|
31
|
+
export const ART_MIN_COLS = SLOT_MIN_COLS, ART_MIN_ROWS = SLOT_MIN_ROWS;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether this /attach greets. Every explicit, typed /attach does (even a repeat of the same agent in one session); only an
|
|
35
|
+
* automatic re-attach (after a restart, or resuming a conversation) is quiet. The quiet flag is read AND cleared in one step
|
|
36
|
+
* at the start of the command, so an attach that bails early (a slot, a busy turn) can never leave it armed for the next typed one.
|
|
37
|
+
*/
|
|
38
|
+
export function takeGreeting(flags) { const quiet = Boolean(flags.noGreeting); flags.noGreeting = false; return !quiet; }
|
|
39
|
+
|
|
40
|
+
/** Quoxon stream/poll facts -> the reducer's event vocabulary. Unknown shapes pass through (the reducer ignores them). */
|
|
41
|
+
export function toCompanionEvent(action) {
|
|
42
|
+
if (!action) return null;
|
|
43
|
+
if (action.type === 'turn.begin') return { type: 'turn.started', turn_id: action.turnId };
|
|
44
|
+
if (action.type === 'approval.found') {
|
|
45
|
+
const key = action.item?.id || action.item?.approval_id || action.item?.approvalId;
|
|
46
|
+
return key ? { type: 'approval.requested', call_id: String(key) } : null;
|
|
47
|
+
}
|
|
48
|
+
if (action.type === 'event') return action.ev || null;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createCompanionController({ settings = () => ({}), env = process.env, isTTY = true, exists = existsSync, now = Date.now, importer = null, makeView = loadCompanionView, jobs = null, typing = () => false } = {}) {
|
|
53
|
+
let noticed = false, narrowNoticed = false, showUntil = 0, shownAt = 0, unpainted = false, waitingFrame = false, trigger = 'attach', forced = false, timer = null, job = null;
|
|
54
|
+
let summoned = null, summonCaption = null; const lastSummon = new Map(); const rosterWatch = createRosterWatch(); const senderWatch = createSenderWatch({ now }); // a domain moment's dragon (not the attached one) and its truthful caption
|
|
55
|
+
const moments = createMomentEngine({ now });
|
|
56
|
+
let agent = null, plan = { enabled: false, why: 'no agent attached' }, view = null, state = null, token = 0, greet = true;
|
|
57
|
+
const listeners = new Set();
|
|
58
|
+
const notify = () => listeners.forEach(f => f());
|
|
59
|
+
const replan = (as = null) => companionPlan({ agent: as ?? agent, settings: settings(), env: forced ? { ...env, QUOX_DRAGON_COMPANION: 'greeting' } : env, isTTY, exists, bundled: VENDORED_DRAGONLAB });
|
|
60
|
+
|
|
61
|
+
/** (Re)start the dwell from shownAt; a timer wakes the renderer at expiry. */
|
|
62
|
+
function arm() {
|
|
63
|
+
showUntil = shownAt + DRAGON_DWELL_MS;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
timer = setTimeout(() => { if (summoned && now() >= showUntil) restore(); else notify(); }, Math.max(0, showUntil - now()) + 50); timer.unref?.();
|
|
66
|
+
}
|
|
67
|
+
/** Show for one dwell, then leave on its own. The dwell and the keypress grace start when the first frame is actually drawn (see snapshot). */
|
|
68
|
+
function appear(why = 'attach') {
|
|
69
|
+
if (!view) return;
|
|
70
|
+
trigger = why; shownAt = now(); unpainted = true; waitingFrame = false;
|
|
71
|
+
arm();
|
|
72
|
+
readJob();
|
|
73
|
+
notify();
|
|
74
|
+
}
|
|
75
|
+
/** The dragon's one job: a real read (cached 30 s inside `jobs`), asked only when the dragon is about to be shown. Its caption replaces the generic one when it lands. */
|
|
76
|
+
function readJob() {
|
|
77
|
+
const mine = token, id = plan.id;
|
|
78
|
+
if (!jobs || !id) return;
|
|
79
|
+
const first = jobs.peek?.(id);
|
|
80
|
+
if (first) job = first;
|
|
81
|
+
Promise.resolve().then(() => jobs.run(id)).then((r) => { if (mine === token && r) { job = r; notify(); } }, () => { /* a failed job read leaves the generic caption: never a made-up one */ });
|
|
82
|
+
}
|
|
83
|
+
/** Still on screen: inside the dwell, or waiting for art that has not been drawn yet (the dwell then counts from the first picture). */
|
|
84
|
+
const live = () => now() < showUntil || (waitingFrame && unpainted && now() < shownAt + FIRST_FRAME_GRACE_MS);
|
|
85
|
+
/** Key moments only in mode `on`; `greeting` and `off` never show for approvals or failures. */
|
|
86
|
+
const keyMoment = () => { if (view && companionMode(settings(), env) === 'on') appear('moment'); };
|
|
87
|
+
|
|
88
|
+
/** A summoned dragon's dwell is over (or it was dismissed): drop it and put the attached agent's dragon back, quietly. */
|
|
89
|
+
function restore() {
|
|
90
|
+
summoned = null; summonCaption = null;
|
|
91
|
+
if (agent) load({ quiet: true }); else { token++; view?.dispose?.(); view = null; state = null; showUntil = 0; plan = replan(); notify(); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function load({ show = false, persist = null, why = 'attach', as = null, caption = null, quiet = false } = {}) {
|
|
95
|
+
const mine = ++token;
|
|
96
|
+
view?.dispose?.(); view = null; state = null; showUntil = 0; job = null;
|
|
97
|
+
summoned = as; summonCaption = caption;
|
|
98
|
+
plan = replan(as);
|
|
99
|
+
if (!plan.enabled) return notify();
|
|
100
|
+
try {
|
|
101
|
+
const opts = { still: plan.still, mono: plan.mono, persist: persist ?? plan.persist };
|
|
102
|
+
let v;
|
|
103
|
+
if (importer) v = await (await importer(pathToFileURL(plan.viewFile).href)).loadDragonView(plan.id, opts);
|
|
104
|
+
else {
|
|
105
|
+
// The playback path: frames come from a worker through a cache (dragon/frames.js). If the worker cannot start, the
|
|
106
|
+
// art still shows through dragonlab's own view (slower, inline), as before.
|
|
107
|
+
try { v = await makeView(plan.root, plan.id, { ...opts, persist: true, onFrame: notify }); } // persist: this host's dwell (showUntil) decides how long it stays, not the entrance length (Lucid's is 3 s)
|
|
108
|
+
catch { v = await (await import(pathToFileURL(plan.viewFile).href)).loadDragonView(plan.id, opts); }
|
|
109
|
+
}
|
|
110
|
+
if (mine !== token) { v.dispose?.(); return; }
|
|
111
|
+
view = v; state = v.initial(now() - (greet && !quiet ? 0 : AGES_OUT));
|
|
112
|
+
if (!quiet && (show || greet)) appear(why);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (mine !== token) return;
|
|
115
|
+
plan = { ...plan, enabled: false, why: `could not load dragon art: ${e.message}` };
|
|
116
|
+
}
|
|
117
|
+
notify();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
subscribe(f) { listeners.add(f); return () => listeners.delete(f); },
|
|
122
|
+
/** Attach: entrance when `greet` (explicit attach), none when re-attaching after a restart. */
|
|
123
|
+
attach(a, { greet: g = true } = {}) { agent = a || null; greet = g; forced = false; moments.reset(); return load(); },
|
|
124
|
+
detach() { agent = null; token++; view?.dispose?.(); view = null; state = null; showUntil = 0; job = null; forced = false; clearTimeout(timer); plan = replan(); notify(); },
|
|
125
|
+
/** Re-read the saved mode (after /dragon companion on|off|greeting). An enable greets. */
|
|
126
|
+
refresh() { greet = true; forced = false; return load(); },
|
|
127
|
+
/** Explicit /dragon: show the attached agent's dragon for one dwell, in any mode (even off). false = no dragon agent attached. */
|
|
128
|
+
async invoke() {
|
|
129
|
+
if (!agent || !dragonForAgent(agent)) return false;
|
|
130
|
+
// The view hides itself once its arrival is over unless it persists, and `greeting`/`off` (or a resumed attach) do not persist:
|
|
131
|
+
// an explicit call reloads it persistent so the whole dwell shows, whatever mode and however long ago it attached.
|
|
132
|
+
if (!view || !plan.persist) { forced = true; await load({ show: true, persist: true, why: 'invoke' }); return Boolean(view); }
|
|
133
|
+
appear('invoke'); return true;
|
|
134
|
+
},
|
|
135
|
+
/** Send it away now. */
|
|
136
|
+
dismiss() { if (showUntil) { showUntil = 0; if (summoned) restore(); else notify(); } },
|
|
137
|
+
/** A keypress: sends it away, but not during the first KEY_GRACE_MS of a dwell (the Enter that invoked it is followed by typing). */
|
|
138
|
+
keypress() { if (showUntil && now() - shownAt >= KEY_GRACE_MS) this.dismiss(); },
|
|
139
|
+
/**
|
|
140
|
+
* A domain moment calls a dragon: companion mode `on` only, never while the user is typing, never while another dragon is on
|
|
141
|
+
* screen, and not the same dragon again inside SUMMON_COOLDOWN_MS. Works with nothing attached. The caption is the moment's own
|
|
142
|
+
* truthful line. -> true when it showed.
|
|
143
|
+
*/
|
|
144
|
+
canSummon(dragon) {
|
|
145
|
+
if (companionMode(settings(), env) !== 'on' || typing() || live()) return false;
|
|
146
|
+
const last = lastSummon.get(dragon);
|
|
147
|
+
return !(last != null && now() - last < SUMMON_COOLDOWN_MS);
|
|
148
|
+
},
|
|
149
|
+
async summon(dragon, caption) {
|
|
150
|
+
if (!this.canSummon(dragon)) return false;
|
|
151
|
+
lastSummon.set(dragon, now());
|
|
152
|
+
if (agent && dragonForAgent(agent) === dragon && view && plan.persist) { summonCaption = caption; appear('summon'); return true; }
|
|
153
|
+
await load({ show: true, as: dragon, caption, persist: true, why: 'summon' });
|
|
154
|
+
return Boolean(view);
|
|
155
|
+
},
|
|
156
|
+
/**
|
|
157
|
+
* A slot message landed in the owner's stream: a fresh line from a session whose agent has a dragon summons that dragon for one
|
|
158
|
+
* dwell (dragonMoments.js slotMessageMoment; deduped, per-sender cooldown). `opts` = { broadcast, run }. -> true when it showed.
|
|
159
|
+
*/
|
|
160
|
+
async slotMessage(m, opts = {}) {
|
|
161
|
+
const mo = senderWatch.observe(m, opts);
|
|
162
|
+
if (!mo || !this.canSummon(mo.dragon)) return false;
|
|
163
|
+
senderWatch.shown(mo.sender);
|
|
164
|
+
return this.summon(mo.dragon, mo.caption);
|
|
165
|
+
},
|
|
166
|
+
/** Another session's approval card newly waiting (state prev -> next), asked by a dragon agent: that dragon appears. */
|
|
167
|
+
async waitingCards(prev, next) {
|
|
168
|
+
const mo = senderWatch.card(prev, next);
|
|
169
|
+
if (!mo || !this.canSummon(mo.dragon)) return false;
|
|
170
|
+
senderWatch.shown(mo.sender);
|
|
171
|
+
return this.summon(mo.dragon, mo.caption);
|
|
172
|
+
},
|
|
173
|
+
/** Roster rows (every refresh) -> the first objective/loop that just finished or failed summons its dragon. */
|
|
174
|
+
watchRoster(rows) { const m = rosterWatch.observe(rows)[0]; return m ? this.summon(m.dragon, m.caption) : Promise.resolve(false); },
|
|
175
|
+
/** True while the dragon is on screen and moving: the host asks the shared clock for frames only then. */
|
|
176
|
+
animating() { return Boolean(view && state && !plan.still && live()); },
|
|
177
|
+
/** Feed a reducer action (turn.begin / event / approval.found). Cheap no-op when no companion is active. */
|
|
178
|
+
tap(action) {
|
|
179
|
+
if (!view) return;
|
|
180
|
+
const ev = toCompanionEvent(action);
|
|
181
|
+
if (ev) { state = view.reduce(state, ev, now()); jobs?.observe?.(ev); }
|
|
182
|
+
if (action?.type === 'turn.begin') moments.turnStarted();
|
|
183
|
+
const m = moments.observe(ev);
|
|
184
|
+
if (m?.companion) keyMoment();
|
|
185
|
+
},
|
|
186
|
+
/** True while an attached companion shows key moments (mode on): it owns them, so the Q dragon stays quiet. */
|
|
187
|
+
ownsMoments() { return Boolean(view && companionMode(settings(), env) === 'on'); },
|
|
188
|
+
/** The Q dragon's mood for a state change, or null when the companion owns the moment (one dragon per moment). */
|
|
189
|
+
moodFor(prev, action, next) { return this.ownsMoments() ? null : moodForEvent(prev, action, next); },
|
|
190
|
+
plan: () => plan,
|
|
191
|
+
/** Why the art cannot show (mode on/greeting but art missing or unloadable), or null. Independent of the attached agent. */
|
|
192
|
+
artProblem() {
|
|
193
|
+
if (companionMode(settings(), env) === 'off') return null;
|
|
194
|
+
if (/dragon art/.test(plan.why || '')) return plan.why;
|
|
195
|
+
const p = companionPlan({ agent: 'lucid', settings: settings(), env, isTTY: true, exists, bundled: VENDORED_DRAGONLAB });
|
|
196
|
+
return !p.enabled && /dragon art/.test(p.why || '') ? p.why : null;
|
|
197
|
+
},
|
|
198
|
+
/** One line, once per session, when a dragon agent attaches but the art cannot show. Otherwise null. */
|
|
199
|
+
takeArtNotice() {
|
|
200
|
+
if (noticed || !agent || !dragonForAgent(agent) || plan.enabled || !/dragon art/.test(plan.why || '')) return null;
|
|
201
|
+
noticed = true;
|
|
202
|
+
return `Dragon companion is ${plan.mode} but its art could not be loaded, so no dragon will show (${plan.why}). /dragon companion status`;
|
|
203
|
+
},
|
|
204
|
+
mode: () => plan.mode,
|
|
205
|
+
/**
|
|
206
|
+
* Once per session: a dragon is attached and would show, but this window is too small for the art, so all that appears is a
|
|
207
|
+
* one-line label. Say why in plain words rather than hiding it silently. Otherwise null.
|
|
208
|
+
*/
|
|
209
|
+
narrowNotice({ cols, rows }) {
|
|
210
|
+
if (narrowNoticed || !agent || !dragonForAgent(agent) || !plan.enabled || !view) return null;
|
|
211
|
+
if (cols >= ART_MIN_COLS && rows >= ART_MIN_ROWS) return null;
|
|
212
|
+
narrowNoticed = true;
|
|
213
|
+
return `The dragon needs ${ART_MIN_COLS} columns and ${ART_MIN_ROWS} rows to be drawn; this window is ${cols} by ${rows}, so you only get its caption line. Widen the window to see it.`;
|
|
214
|
+
},
|
|
215
|
+
/** -> null | { kind:'art', frame, caption, name } | { kind:'label', text } */
|
|
216
|
+
snapshot(t, { cols, rows }) {
|
|
217
|
+
const out = this.snapshotNow(t, { cols, rows });
|
|
218
|
+
if (out && unpainted) { unpainted = false; shownAt = now(); arm(); } // first picture on screen: the dwell and the keypress grace count from here, not from when the art finished loading
|
|
219
|
+
return out;
|
|
220
|
+
},
|
|
221
|
+
snapshotNow(t, { cols, rows }) {
|
|
222
|
+
if (summoned && view && !live()) { restore(); return null; } // a summoned dragon's dwell ended: back to the attached one (never while its art is still loading: a frame drawn then cancelled the summon)
|
|
223
|
+
if (!view || !state || !live()) return null;
|
|
224
|
+
const big = cols >= ART_MIN_COLS && rows >= ART_MIN_ROWS;
|
|
225
|
+
let v;
|
|
226
|
+
const mood = job?.mood === 'concerned' && !state.approvals.length ? 'concerned' : null;
|
|
227
|
+
try { v = view.view(state, t, { cols, rows: big ? rows : 1, mood }); } catch { return null; }
|
|
228
|
+
if (v.hidden) return null;
|
|
229
|
+
// One caption per trigger: "Attached." only on an attach; an invoke says who is here; an approval always says so.
|
|
230
|
+
let caption = v.caption;
|
|
231
|
+
const summonedCaption = summonCaption;
|
|
232
|
+
if (state.approvals.length) caption = 'Waiting for your approval.';
|
|
233
|
+
else if (job?.caption && !state.issue) caption = job.caption; // the dragon's job: a real read (jobs.js); an approval or a recorded failure still comes first
|
|
234
|
+
else if (state.phase === 'connecting') caption = trigger === 'attach' ? 'Attached.' : trigger === 'invoke' ? `${view.name} is here.` : v.caption;
|
|
235
|
+
if (summonedCaption && now() < showUntil) caption = summonedCaption; // a domain moment states its own fact
|
|
236
|
+
if (!big) return { kind: 'label', text: `${view.name}: ${caption}` };
|
|
237
|
+
if (!v.frame) { waitingFrame = true; return null; } // the first frame is still being drawn off the main thread: draw nothing rather than a gap
|
|
238
|
+
return { kind: 'art', frame: v.frame, caption, name: view.name };
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** "/dragon companion <arg>" -> { text, tone, save?, refresh? }. Pure. */
|
|
244
|
+
export function companionCommand(arg, { settings = {}, plan = null, artProblem = null } = {}) {
|
|
245
|
+
// `invoke: true` = the host also plays the attached agent's dragon for a dwell (a status check is a call for the dragon too).
|
|
246
|
+
const w = String(arg || '').trim().toLowerCase();
|
|
247
|
+
const cur = normaliseMode(settings.dragonCompanion);
|
|
248
|
+
if (!w || w === 'status') {
|
|
249
|
+
const mode = cur || plan?.mode || 'on';
|
|
250
|
+
const now = plan?.enabled ? `showing ${plan.id}` : `not showing (${plan?.why || 'no agent attached'})`;
|
|
251
|
+
if (artProblem && mode !== 'off') return { text: `Dragon companion: ${mode}, but the art can't be loaded so no dragon will show (${artProblem}). /dragon companion on|greeting|off.`, tone: 'warn' };
|
|
252
|
+
return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan and Quoxperticus have dragons.`, invoke: true };
|
|
253
|
+
}
|
|
254
|
+
const m = normaliseMode(w);
|
|
255
|
+
if (!m) return { text: `Usage: /dragon companion ${COMPANION_MODES.join('|')}|status`, tone: 'warn' };
|
|
256
|
+
const what = { on: 'appears on attach and at key moments such as a new approval, a failure or a long task finishing, then leaves after a few seconds', greeting: 'appears on attach only, then leaves', off: 'never appears on its own' }[m];
|
|
257
|
+
if (artProblem && m !== 'off') return { text: `Dragon companion: ${m} saved, but the art can't be loaded so no dragon will show (${artProblem}).`, tone: 'warn', save: m, refresh: true };
|
|
258
|
+
return { text: `Dragon companion: ${m} (${what}). /dragon always calls it. Saved to this profile.`, tone: 'ok', save: m, refresh: true };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The live block. Re-renders on the shared clock; draws nothing when there is no companion. */
|
|
262
|
+
export function DragonCompanionLive({ ctl, cols, rows }) {
|
|
263
|
+
const t = useFrame();
|
|
264
|
+
const [, bump] = React.useState(0);
|
|
265
|
+
React.useEffect(() => ctl.subscribe(() => bump(x => x + 1)), [ctl]);
|
|
266
|
+
const snap = ctl.snapshot(t, { cols, rows });
|
|
267
|
+
if (!snap) return null;
|
|
268
|
+
if (dragonActive()) return null; // an explicit /dragon <clip> owns the canvas: the companion steps aside (both stacked overflow a 34-row terminal and Ink wipes the screen, 2026-10-25)
|
|
269
|
+
if (snap.kind === 'label') return h(Box, { paddingLeft: 2 }, h(Text, { wrap: 'truncate' }, snap.text));
|
|
270
|
+
return h(Box, { flexDirection: 'row', marginTop: 1 },
|
|
271
|
+
h(Box, { flexDirection: 'column', flexShrink: 0 }, ...snap.frame.map((r, k) => h(Text, { key: k, wrap: 'truncate' }, ...cellNodes(r, `dc${k}-`)))),
|
|
272
|
+
h(Box, { flexDirection: 'column', paddingLeft: 2 },
|
|
273
|
+
h(Text, { bold: true }, snap.name.toUpperCase()),
|
|
274
|
+
h(Text, { wrap: 'wrap' }, snap.caption)));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* /detach back to Q: with dragons on (mode on or greeting) the Q dragon gets one greeting, the way an attach shows the agent's.
|
|
279
|
+
* -> null | { clip:'pick' } | { note } (the window is too small: say so in one line). Pure.
|
|
280
|
+
*/
|
|
281
|
+
export function detachGreeting({ settings = {}, env = process.env, rows = null, chromeH = 10, hadAgent = true } = {}) {
|
|
282
|
+
if (!hadAgent || companionMode(settings, env) === 'off' || motionPolicy(env).off) return null;
|
|
283
|
+
const room = clipRoom('dragon', rows, chromeH);
|
|
284
|
+
return room.ok ? { clip: 'pick' } : { note: room.note };
|
|
285
|
+
}
|
|
@@ -54,6 +54,7 @@ export function parseDragonArg(restArg) {
|
|
|
54
54
|
case 'hero': return rest.length ? null : { mode: 'hero' };
|
|
55
55
|
// `/dragon explain <thing>` is a question about that thing (owner 2026-10-15: it used to print usage).
|
|
56
56
|
case 'explain': return rest.length ? { mode: 'ask', question: rest.join(' ').trim() } : { mode: 'explain' };
|
|
57
|
+
case 'companion': return { mode: 'companion', arg: rest.join(' ') };
|
|
57
58
|
case 'sleep': return rest.length ? null : { mode: 'sleep' };
|
|
58
59
|
case 'fly': return rest.length ? null : { mode: 'fly' };
|
|
59
60
|
case 'ask': return { mode: 'ask', question: rest.join(' ').trim() };
|