quoxcli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/admin.js +26 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +2 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +30 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +6 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +86 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +163 -27
- package/lib/commands/agentAttach.js +94 -6
- package/lib/commands/approvals.js +73 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +57 -2
- package/lib/commands/claude.js +34 -8
- package/lib/commands/contacts.js +49 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +17 -6
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +311 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +178 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/licence.js +14 -155
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +11 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +11 -1
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +38 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +64 -9
- package/lib/commands/server.js +292 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/skills.js +48 -5
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/core/actingAs.js +2 -2
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +144 -35
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/config.js +142 -24
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/global-flags.js +105 -5
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +4 -4
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +504 -25
- package/lib/quoxon/app.js +3000 -230
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +363 -38
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +44 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +244 -25
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +49 -6
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +92 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +24 -3
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +9 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +58 -37
- package/lib/quoxon/render.js +638 -119
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +95 -9
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +172 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +21 -7
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/restart.js +71 -13
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +4 -1
- package/lib/core/websiteSession.js +0 -148
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POKE-WAKE (owner 2026-10-15: "/poke cli-site ... not listening right now ... this delay not ideal").
|
|
3
|
+
*
|
|
4
|
+
* A page is a slot message; an idle Claude Code session only reads slot mail when a turn starts
|
|
5
|
+
* (the UserPromptSubmit slot-inbox hook). Nothing starts a turn in an idle session except input.
|
|
6
|
+
* So `wake` is a slot CONTROL command (server-gated to a human org admin, never an agent) that the
|
|
7
|
+
* session's own keeper runs: it types ONE FIXED LINE into the session's tmux pane, and only when
|
|
8
|
+
* the pane is still and its prompt box is empty. The page text is never typed; the session reads
|
|
9
|
+
* it through the normal slot path. Pure decisions live here; the keeper does the I/O.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const WAKE_TTL_MS = 10 * 60 * 1000; // a busy session is retried each control tick for this long
|
|
13
|
+
export const WAKE_COALESCE_MS = 60 * 1000; // repeat pokes inside this window do not type again
|
|
14
|
+
export const WAKE_SETTLE_MS = 2000; // two pane samples this far apart: any change = busy or a human typing
|
|
15
|
+
|
|
16
|
+
const SLOT_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
17
|
+
// A box rule; the TOP one may carry the session title ("──── QUOXON ─", seen live 2026-10-15).
|
|
18
|
+
const RULE_RE = /^\s*─{8,}(?:.*─)?\s*$/;
|
|
19
|
+
const PROMPT_RE = /^\s*❯(?:[\s ]|$)/;
|
|
20
|
+
// FORCE-WAKE: captures are escape-aware (`capture-pane -e`). Claude Code draws its empty-box suggestion FAINT
|
|
21
|
+
// (ESC[2m) straight after the prompt; plain capture read it as a draft (proven live, v2.1.288, 2026-10-16).
|
|
22
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
23
|
+
const PLACEHOLDER_RE = /❯[\s ]*(?:\x1b\[(?!2m)[0-9;]*m)*\x1b\[2m/;
|
|
24
|
+
const UNCAPTURABLE_RE = /\[(?:Pasted text|Image)\b/;
|
|
25
|
+
const plain = (line) => line.replace(ANSI_RE, '');
|
|
26
|
+
|
|
27
|
+
/** The only text a wake ever types. Names the slot, never carries the page. */
|
|
28
|
+
export function wakeText(slot) {
|
|
29
|
+
if (typeof slot !== 'string' || !SLOT_RE.test(slot)) throw new Error(`not a slot name: ${JSON.stringify(slot)}`);
|
|
30
|
+
return `[quox slot wake] a page is waiting on slot ${slot}: read it with \`quox slot read ${slot}\` and answer there with \`quox slot say ${slot}\`.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const flat = (s) => String(s).replace(/\s+/g, '');
|
|
34
|
+
/** WAKE-REAL: do these box lines (draftLines) hold exactly this slot's wake line, as Claude Code wrapped it? */
|
|
35
|
+
export function isWakeLine(lines, slot) {
|
|
36
|
+
if (!Array.isArray(lines) || !lines.length || !SLOT_RE.test(String(slot || ''))) return false;
|
|
37
|
+
return flat(lines.join('')) === flat(wakeText(slot));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// WAKE-REAL: what Claude Code (v2.1.288, seen live 2026-10-17) shows while a submitted line waits for a running
|
|
41
|
+
// turn: the line above the box with "ctrl+x ctrl+s to send now", and "Press up to edit queued messages" in it.
|
|
42
|
+
const QUEUED_RE = /Press up to edit queued|ctrl\+x ctrl\+s to send now/;
|
|
43
|
+
/** Did the last submit land in Claude Code's queue (session mid-turn) rather than start a turn? */
|
|
44
|
+
export function wakeQueued(text) {
|
|
45
|
+
return typeof text === 'string' && QUEUED_RE.test(aboveFooter(text).split('\n').map(plain).join('\n'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The state of the LAST Claude Code prompt box on screen: 'empty', 'draft' (typing would send a
|
|
50
|
+
* human's unsent text) or 'unknown' (no box: a dialog, menu or pager; never treated as empty).
|
|
51
|
+
*/
|
|
52
|
+
export function promptBoxState(text) {
|
|
53
|
+
const lines = draftLines(text);
|
|
54
|
+
if (lines === null) return 'unknown';
|
|
55
|
+
return lines.join('\n').replace(/\u00a0/g, ' ').trim() ? 'draft' : 'empty';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* FORCE-WAKE: the last prompt box's draft as rendered, one entry per visual line (plain text, prompt
|
|
60
|
+
* stripped, trailing spaces dropped); [] for an empty box (or the faint placeholder); null for no box.
|
|
61
|
+
*/
|
|
62
|
+
export function draftLines(text) {
|
|
63
|
+
if (typeof text !== 'string' || !text) return null;
|
|
64
|
+
const raw = text.split('\n');
|
|
65
|
+
const lines = raw.map(plain);
|
|
66
|
+
for (let i = lines.length - 1; i > 0; i -= 1) {
|
|
67
|
+
if (!PROMPT_RE.test(lines[i]) || !RULE_RE.test(lines[i - 1])) continue;
|
|
68
|
+
if (PLACEHOLDER_RE.test(raw[i])) return [];
|
|
69
|
+
const body = [lines[i].replace(PROMPT_RE, '')];
|
|
70
|
+
for (let j = i + 1; j < lines.length && !RULE_RE.test(lines[j]); j += 1) body.push(lines[j]);
|
|
71
|
+
const out = body.map((l) => l.replace(/\u00a0/g, ' ').replace(/\s+$/, ''));
|
|
72
|
+
while (out.length && !out[out.length - 1]) out.pop();
|
|
73
|
+
return out.join('').trim() ? out : [];
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The screen down to the last prompt box's closing rule: the footer below it ticks (shell counts,
|
|
79
|
+
* agent timers) on an idle session, so it must not count as activity. No box = the whole screen. */
|
|
80
|
+
export function aboveFooter(text) {
|
|
81
|
+
if (typeof text !== 'string') return text;
|
|
82
|
+
const raw = text.split('\n');
|
|
83
|
+
const lines = raw.map(plain);
|
|
84
|
+
for (let i = lines.length - 1; i > 0; i -= 1) {
|
|
85
|
+
if (!PROMPT_RE.test(lines[i]) || !RULE_RE.test(lines[i - 1])) continue;
|
|
86
|
+
let j = i + 1;
|
|
87
|
+
while (j < lines.length && !RULE_RE.test(lines[j])) j += 1;
|
|
88
|
+
return raw.slice(0, Math.min(j + 1, raw.length)).join('\n');
|
|
89
|
+
}
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One wake attempt. before/after are two pane captures WAKE_SETTLE_MS apart (null = unreadable).
|
|
95
|
+
* force (FORCE-WAKE, human-requested rows only): a draft yields { action: 'force' } instead of a refusal.
|
|
96
|
+
* Returns { action: 'type' } | { action: 'force' } | { action: 'submit' } (our own stranded wake line) | { action: 'wait', reason } (leave the request pending) |
|
|
97
|
+
* { action: 'done' | 'fail', result }.
|
|
98
|
+
*/
|
|
99
|
+
export function decideWake({ slot, before, after, firstSeenAt, nowMs, lastWokeAt = null, force = false }) {
|
|
100
|
+
if (typeof before !== 'string' || typeof after !== 'string') return { action: 'fail', result: 'could not read the session pane' };
|
|
101
|
+
if (lastWokeAt != null && nowMs - lastWokeAt < WAKE_COALESCE_MS) return { action: 'done', result: `already woken ${Math.round((nowMs - lastWokeAt) / 1000)}s ago` };
|
|
102
|
+
const expired = nowMs - firstSeenAt > WAKE_TTL_MS;
|
|
103
|
+
if (aboveFooter(before) !== aboveFooter(after)) {
|
|
104
|
+
return expired ? { action: 'fail', result: `session still busy and the wake request is ${Math.floor((nowMs - firstSeenAt) / 60000)}m old (limit ${WAKE_TTL_MS / 60000}m); the page on ${slot} shows at its next prompt` } : { action: 'wait', reason: 'busy' };
|
|
105
|
+
}
|
|
106
|
+
const box = promptBoxState(after);
|
|
107
|
+
// WAKE-REAL: the box holds OUR wake line, typed earlier but never submitted (Claude Code dropped its Enter;
|
|
108
|
+
// QUOXON 2026-10-17 00:36:41, proven by sha c8d63692). It is not a human draft: submit it, never set it aside.
|
|
109
|
+
if (box === 'draft' && isWakeLine(draftLines(after), slot)) return { action: 'submit' };
|
|
110
|
+
if (box === 'draft' && force) {
|
|
111
|
+
// FORCE-WAKE: a human wake sets the draft aside and puts it back unsent; only if it can be put back exactly.
|
|
112
|
+
if (UNCAPTURABLE_RE.test(draftLines(after).join('\n'))) return { action: 'fail', result: 'not woken: the draft holds a collapsed pasted block or image, which cannot be set aside and put back exactly; the page shows at its next prompt' };
|
|
113
|
+
return { action: 'force' };
|
|
114
|
+
}
|
|
115
|
+
if (box === 'draft') return { action: 'fail', result: 'not woken: an unsent draft is in the prompt box (typing would send it); the page shows at its next prompt' };
|
|
116
|
+
if (box === 'unknown') {
|
|
117
|
+
return expired ? { action: 'fail', result: `no empty prompt and the wake request is ${Math.floor((nowMs - firstSeenAt) / 60000)}m old (a dialog or menu is open); the page shows at its next prompt` } : { action: 'wait', reason: 'no prompt box' };
|
|
118
|
+
}
|
|
119
|
+
return { action: 'type' };
|
|
120
|
+
}
|
|
@@ -278,18 +278,24 @@ export async function attemptSlotClaim({
|
|
|
278
278
|
return { claimed: false, reason: 'error', error: err };
|
|
279
279
|
}
|
|
280
280
|
claimState.held = false;
|
|
281
|
+
claimState.lapsed = true;
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
try {
|
|
285
|
+
const wasHeld = !!(claimState && claimState.everHeld);
|
|
284
286
|
const res = await checkoutSlot();
|
|
285
|
-
if (claimState) { claimState.held = true; claimState.claimedAt = now(); }
|
|
287
|
+
if (claimState) { claimState.held = true; claimState.everHeld = true; claimState.claimedAt = now(); }
|
|
286
288
|
const refreshed = !!(res && res.refreshed);
|
|
289
|
+
// GRIPE-797: log state transitions only. A periodic safety re-checkout
|
|
290
|
+
// of a hold this process already had is a renewal, not a new claim.
|
|
291
|
+
if (wasHeld && claimState && !claimState.lapsed) return { claimed: true, refreshed: true };
|
|
292
|
+
if (claimState) claimState.lapsed = false;
|
|
287
293
|
log('info', refreshed
|
|
288
294
|
? `slot ${slot}: already held by this resident, refreshed.`
|
|
289
295
|
: `slot ${slot}: claimed (was free), now present.`);
|
|
290
296
|
return refreshed ? { claimed: true, refreshed } : { claimed: true };
|
|
291
297
|
} catch (err) {
|
|
292
|
-
if (claimState) claimState.held = false;
|
|
298
|
+
if (claimState) { claimState.held = false; claimState.lapsed = true; }
|
|
293
299
|
if (err && err.status === 409) {
|
|
294
300
|
const holder = err.detail?.holder_session || null;
|
|
295
301
|
if (holder && holder !== selfIdentity) {
|
|
@@ -479,6 +485,40 @@ export function detectTurnError(result) {
|
|
|
479
485
|
return { type, message, category, signature };
|
|
480
486
|
}
|
|
481
487
|
|
|
488
|
+
/**
|
|
489
|
+
* GRIPE-797: an expired provider login (claude "Failed to authenticate:
|
|
490
|
+
* OAuth session expired and could not be refreshed", codex "not logged in"
|
|
491
|
+
* or a 401) is NOT a generic provider error: the fix is a human running a
|
|
492
|
+
* login command on this host. Returns {provider, command} or null.
|
|
493
|
+
*/
|
|
494
|
+
export function detectAuthExpiry(result) {
|
|
495
|
+
if (!result || result.timedOut || result.error) return null;
|
|
496
|
+
// Only a FAILED turn can be an expiry: a successful reply that merely
|
|
497
|
+
// talks about logins or 401s is a reply, never an alert.
|
|
498
|
+
if (!detectTurnError(result)) return null;
|
|
499
|
+
const parts = [result.text, result.stdout, result.stderr];
|
|
500
|
+
const raw = result.raw;
|
|
501
|
+
if (raw && typeof raw === 'object') {
|
|
502
|
+
parts.push(raw.result, raw.message, raw.error && (raw.error.message || raw.error));
|
|
503
|
+
}
|
|
504
|
+
const blob = parts.filter((x) => typeof x === 'string').join('\n');
|
|
505
|
+
if (!blob) return null;
|
|
506
|
+
if (/failed to authenticate|oauth session expired|could not be refreshed|invalid authentication credentials|please run \/login/i.test(blob)) {
|
|
507
|
+
return { provider: 'claude', command: 'claude /login' };
|
|
508
|
+
}
|
|
509
|
+
if (/not logged in|codex login|401 unauthorized|\b401\b[^\n]{0,40}(unauthorized|authentication)|token (has )?expired/i.test(blob)) {
|
|
510
|
+
const claudeish = /claude/i.test(blob) && !/codex/i.test(blob);
|
|
511
|
+
return claudeish ? { provider: 'claude', command: 'claude /login' } : { provider: 'codex', command: 'codex login' };
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function buildAuthExpiryNoticeText(hostId, auth) {
|
|
517
|
+
const cmd = auth && auth.provider === 'codex' ? 'codex login' : 'claude /login';
|
|
518
|
+
const alt = auth && auth.provider === 'codex' ? '(or `claude /login`)' : '(or `codex login`)';
|
|
519
|
+
return `My login on ${hostId} has expired; run \`${cmd}\` ${alt} on ${hostId}.`;
|
|
520
|
+
}
|
|
521
|
+
|
|
482
522
|
/**
|
|
483
523
|
* One short, plain-language, single-sentence notice: no stack, no JSON, no
|
|
484
524
|
* raw provider payload. This is the ONE honest signal an operator gets per
|
|
@@ -644,6 +684,7 @@ export async function runPollOnce({
|
|
|
644
684
|
claimState = null,
|
|
645
685
|
postHeartbeat = null,
|
|
646
686
|
postTurnReceipt = null,
|
|
687
|
+
alertOwner = null,
|
|
647
688
|
state,
|
|
648
689
|
log = () => {},
|
|
649
690
|
now = () => Date.now(),
|
|
@@ -786,6 +827,29 @@ export async function runPollOnce({
|
|
|
786
827
|
// resident's own reply. Log the full error, record it in state for
|
|
787
828
|
// `status` to surface, and post at most one short plain-language
|
|
788
829
|
// notice per distinct error signature per hour.
|
|
830
|
+
const authExpiry = detectAuthExpiry(result);
|
|
831
|
+
if (authExpiry) {
|
|
832
|
+
log('error', `turn auth expiry for message ${message.id}: provider=${authExpiry.provider} (login expired; never posted raw)`);
|
|
833
|
+
await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: `auth_expired: ${authExpiry.provider}` });
|
|
834
|
+
const noticeText = buildAuthExpiryNoticeText(hostId, authExpiry);
|
|
835
|
+
nextState = {
|
|
836
|
+
...nextState,
|
|
837
|
+
last_turn_error: { type: 'auth_expired', message: noticeText, category: 'auth expiry', signature: `auth_expired:${authExpiry.provider}`, at: new Date(now()).toISOString() },
|
|
838
|
+
};
|
|
839
|
+
// ONE slot reply + ONE owner alert per expiry episode; the flag is
|
|
840
|
+
// cleared only by a successful reply below.
|
|
841
|
+
if (!nextState.auth_expiry_alerted_at) {
|
|
842
|
+
await postReply(noticeText);
|
|
843
|
+
if (alertOwner) {
|
|
844
|
+
try { await alertOwner(noticeText); } catch (err) { log('warn', `auth-expiry owner alert failed: ${err.message}`); }
|
|
845
|
+
}
|
|
846
|
+
nextState = { ...nextState, auth_expiry_alerted_at: new Date(now()).toISOString() };
|
|
847
|
+
log('warn', `posted auth-expiry notice and owner alert (once per episode): ${noticeText}`);
|
|
848
|
+
} else {
|
|
849
|
+
log('warn', 'auth-expiry notice suppressed (already alerted this episode; clears on next successful turn).');
|
|
850
|
+
}
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
789
853
|
const turnError = detectTurnError(result);
|
|
790
854
|
if (turnError) {
|
|
791
855
|
log('error', `turn error for message ${message.id}: type=${turnError.type} message=${turnError.message} (never posted to the channel)`);
|
|
@@ -813,6 +877,11 @@ export async function runPollOnce({
|
|
|
813
877
|
continue;
|
|
814
878
|
}
|
|
815
879
|
|
|
880
|
+
if (nextState.auth_expiry_alerted_at) {
|
|
881
|
+
const { auth_expiry_alerted_at: _cleared, ...rest } = nextState;
|
|
882
|
+
nextState = rest;
|
|
883
|
+
log('info', 'auth recovered: successful turn, auth-expiry alert re-armed.');
|
|
884
|
+
}
|
|
816
885
|
await postReply(replyText);
|
|
817
886
|
log('info', `replied to ${message.id} (${replyText.length} chars, session_id=${result.session_id || 'n/a'}, cost_usd=${result.total_cost_usd ?? 'n/a'})`);
|
|
818
887
|
await receipt({
|
package/lib/core/version.js
CHANGED
|
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { dirname, resolve } from 'node:path';
|
|
20
20
|
|
|
21
|
-
export const BASE_VERSION = '0.
|
|
21
|
+
export const BASE_VERSION = '0.3.1';
|
|
22
22
|
|
|
23
23
|
// The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
|
|
24
24
|
// levels up is the tree whose HEAD identifies the running build.
|
package/lib/core/vm.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared engine behind `quox vm` and the QUOXON `/vm` command: drives the
|
|
3
|
+
* HYPERION (agent id atlas) Proxmox tools through the governed tool route
|
|
4
|
+
* (POST /api/v1/tools/execute). Pure functions over an injected `exec(tool, input)`
|
|
5
|
+
* so the CLI group and the TUI share one implementation and one set of tests.
|
|
6
|
+
*
|
|
7
|
+
* Tool names/inputs are the contract with the server side:
|
|
8
|
+
* proxmox_vms, proxmox_containers, proxmox_vm_resources, proxmox_vm_action,
|
|
9
|
+
* proxmox_vm_snapshot, proxmox_vm_resolve {name|vmid}, proxmox_pool_status,
|
|
10
|
+
* proxmox_pool_lease, proxmox_pool_release, proxmox_pool_configure,
|
|
11
|
+
* proxmox_node_power, proxmox_rehearse {vmid, node, command, checks?, keep_twin?} (starts, returns {rehearsal_id, state:'running'}),
|
|
12
|
+
* proxmox_rehearsal_status {rehearsal_id}.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
19
|
+
|
|
20
|
+
export const VM_ACTIONS = ['start', 'stop', 'reboot', 'shutdown'];
|
|
21
|
+
export const NODE_ACTIONS = ['reboot', 'shutdown'];
|
|
22
|
+
|
|
23
|
+
/** A write was held for a human. Callers print the hint and never re-submit. */
|
|
24
|
+
export class VmPendingError extends Error {
|
|
25
|
+
constructor(tool, data) {
|
|
26
|
+
super(`${tool} is awaiting approval`);
|
|
27
|
+
this.name = 'VmPendingError';
|
|
28
|
+
this.tool = tool;
|
|
29
|
+
this.approvalId = data?.approvalId || data?.approval_id || data?._approval?.id || null;
|
|
30
|
+
this.inboxItemId = data?.inbox_item_id || null;
|
|
31
|
+
this.rehearsalId = data?.rehearsal_id || null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function pendingHint(e) {
|
|
36
|
+
const id = e.approvalId || e.inboxItemId;
|
|
37
|
+
if (!id) return `${e.tool} is awaiting approval. Check: quox approvals`;
|
|
38
|
+
return `${e.tool} is awaiting approval (approval id ${id}). After a human approves, run: quox approvals result ${id}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* DEVFLEET-READ: HYPERION (atlas) is granted ONE read tool, proxmox_read, not the per-view read
|
|
43
|
+
* tools. Callers keep using the old names; the wire call is proxmox_read with the matching view.
|
|
44
|
+
* Writes (proxmox_vm_action, _snapshot, _rehearse, _vm_doctor, pool lease/release/config) pass through.
|
|
45
|
+
*/
|
|
46
|
+
export const PROXMOX_READ_VIEW_OF = {
|
|
47
|
+
proxmox_vms: 'vms', proxmox_containers: 'containers', proxmox_cluster_status: 'cluster',
|
|
48
|
+
proxmox_ha_status: 'ha', proxmox_templates_list: 'templates', proxmox_storage: 'storage',
|
|
49
|
+
proxmox_node_resources: 'node', proxmox_vm_resources: 'vm', proxmox_snapshots_list: 'snapshots',
|
|
50
|
+
proxmox_backup_status: 'backups', proxmox_vm_resolve: 'resolve', proxmox_pool_status: 'pool',
|
|
51
|
+
proxmox_rehearsal_status: 'rehearsal', proxmox_doctor_report: 'doctor_report',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Tools that fan out many Proxmox reads server-side and outlive the client's 30s default.
|
|
55
|
+
// VM Doctor runs 12 reads in parallel; over Bastion pvesh on a slow node that is ~30s.
|
|
56
|
+
export const SLOW_TOOL_TIMEOUT_MS = { proxmox_vm_doctor: 120000, proxmox_snapshots_list: 90000 };
|
|
57
|
+
|
|
58
|
+
/** Build an exec(tool, input) over an API client. Throws VmPendingError on a held call. */
|
|
59
|
+
export function makeExec({ client, orgId }) {
|
|
60
|
+
return async function exec(tool, input = {}) {
|
|
61
|
+
const ep = resolveEndpoint('v1.tools.execute');
|
|
62
|
+
// HYPERION (atlas) owns these tools; without agent_id the call lands on the
|
|
63
|
+
// orchestrator, which is not granted them. An attach token's agent claim still wins server-side.
|
|
64
|
+
const view = PROXMOX_READ_VIEW_OF[tool];
|
|
65
|
+
const body = view ? { tool_name: 'proxmox_read', input: { view, ...input }, agent_id: 'atlas' } : { tool_name: tool, input, agent_id: 'atlas' };
|
|
66
|
+
if (orgId) body.org_id = orgId;
|
|
67
|
+
const timeout = SLOW_TOOL_TIMEOUT_MS[tool];
|
|
68
|
+
let data = await client.post(ep.path, { service: ep.service, body, ...(timeout ? { timeout } : {}) });
|
|
69
|
+
// The caller's own token identity wins over agent_id server-side (e.g. an attached session runs
|
|
70
|
+
// as itself, which holds the legacy reads like proxmox_vms but not proxmox_read). When proxmox_read
|
|
71
|
+
// is not granted to that identity, retry once with the legacy per-view tool it does hold.
|
|
72
|
+
if (view && data && data.success === false && /not granted/i.test(String(data.error || ''))) {
|
|
73
|
+
const legacy = { tool_name: tool, input, agent_id: 'atlas' };
|
|
74
|
+
if (orgId) legacy.org_id = orgId;
|
|
75
|
+
data = await client.post(ep.path, { service: ep.service, body: legacy });
|
|
76
|
+
}
|
|
77
|
+
if (data && data.pending) throw new VmPendingError(tool, data);
|
|
78
|
+
if (data && data.success === false) {
|
|
79
|
+
const err = new Error(`${tool} via POST ${ep.path} returned success:false: ${data.error || 'unknown error'}`);
|
|
80
|
+
err.toolError = data.error || 'unknown error';
|
|
81
|
+
err.tool = tool;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
return data;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The tool's payload, whatever envelope it came wrapped in. */
|
|
89
|
+
export function payloadOf(data) {
|
|
90
|
+
let p = data;
|
|
91
|
+
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
|
92
|
+
for (const k of ['result', 'output', 'data']) {
|
|
93
|
+
if (p[k] !== undefined && p[k] !== null) { p = p[k]; break; }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (typeof p === 'string') { try { return JSON.parse(p); } catch { return p; } }
|
|
97
|
+
return p;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function arrayOf(p, ...keys) {
|
|
101
|
+
if (Array.isArray(p)) return p;
|
|
102
|
+
for (const k of [...keys, 'items', 'results', 'resources']) if (Array.isArray(p?.[k])) return p[k];
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const normVm = (v, fallbackType) => ({
|
|
107
|
+
vmid: Number(v.vmid ?? v.id),
|
|
108
|
+
name: v.name ?? v.hostname ?? '',
|
|
109
|
+
node: v.node ?? null,
|
|
110
|
+
type: v.type === 'lxc' || v.type === 'qemu' ? v.type : (fallbackType || 'qemu'),
|
|
111
|
+
status: v.status ?? v.state ?? '',
|
|
112
|
+
raw: v,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const isUnknownTool = (e) => e?.status === 404
|
|
116
|
+
|| /unknown tool|tool .*not (found|available|registered)|no such tool|unsupported tool|not implemented/i.test(e?.toolError || e?.message || '');
|
|
117
|
+
|
|
118
|
+
export async function listVms(exec, { node, type } = {}) {
|
|
119
|
+
if (type && !['qemu', 'lxc'].includes(type)) throw new Error('--type must be qemu or lxc');
|
|
120
|
+
const out = [];
|
|
121
|
+
if (type !== 'lxc') {
|
|
122
|
+
const p = payloadOf(await exec('proxmox_vms', node ? { node } : {}));
|
|
123
|
+
for (const v of arrayOf(p, 'vms')) out.push(normVm(v, 'qemu'));
|
|
124
|
+
}
|
|
125
|
+
if (type === 'lxc') {
|
|
126
|
+
const p = payloadOf(await exec('proxmox_containers', node ? { node } : {}));
|
|
127
|
+
for (const v of arrayOf(p, 'containers', 'lxc')) out.push(normVm(v, 'lxc'));
|
|
128
|
+
}
|
|
129
|
+
return node ? out.filter((v) => !v.node || v.node === node) : out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* name|vmid -> { vmid, node, type, name }. Tool proxmox_vm_resolve first; if the server
|
|
134
|
+
* does not know it, fall back to client-side exact match over proxmox_vms + proxmox_containers.
|
|
135
|
+
* Ambiguity is an error listing candidates; never a guess.
|
|
136
|
+
*/
|
|
137
|
+
export async function resolveTarget(exec, target) {
|
|
138
|
+
const t = String(target ?? '').trim();
|
|
139
|
+
if (!t) throw new Error('a VM name or vmid is required');
|
|
140
|
+
const numeric = /^\d+$/.test(t);
|
|
141
|
+
const input = numeric ? { vmid: Number(t) } : { name: t };
|
|
142
|
+
let matches = null;
|
|
143
|
+
try {
|
|
144
|
+
const p = payloadOf(await exec('proxmox_vm_resolve', input));
|
|
145
|
+
const list = Array.isArray(p) ? p : (p?.matches || p?.candidates || (p && (p.vmid !== undefined || p.id !== undefined) ? [p] : []));
|
|
146
|
+
matches = list.map((v) => normVm(v));
|
|
147
|
+
if (!matches.length) throw new Error(`no VM matches "${t}" (proxmox_vm_resolve)`);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof VmPendingError || !isUnknownTool(e)) throw e;
|
|
150
|
+
matches = null;
|
|
151
|
+
}
|
|
152
|
+
if (matches === null) {
|
|
153
|
+
const all = await listVms(exec, {});
|
|
154
|
+
try { all.push(...await listVms(exec, { type: 'lxc' })); } catch (e) { if (e instanceof VmPendingError) throw e; /* containers are optional */ }
|
|
155
|
+
matches = all.filter((v) => (numeric ? v.vmid === Number(t) : v.name === t));
|
|
156
|
+
if (!matches.length) throw new Error(`no VM matches "${t}" (checked proxmox_vms and proxmox_containers; exact name match only)`);
|
|
157
|
+
}
|
|
158
|
+
if (matches.length > 1) {
|
|
159
|
+
const lines = matches.map((v) => ` ${v.vmid} ${v.name} ${v.node || '?'} ${v.type}`).join('\n');
|
|
160
|
+
throw new Error(`"${t}" is ambiguous, ${matches.length} candidates (use the vmid):\n${lines}`);
|
|
161
|
+
}
|
|
162
|
+
const m = matches[0];
|
|
163
|
+
if (!m.node) throw new Error(`could not determine the node for "${t}" (vmid ${m.vmid})`);
|
|
164
|
+
return { vmid: m.vmid, node: m.node, type: m.type, name: m.name };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const withReason = (input, reason) => (reason ? { ...input, _reason: reason } : input);
|
|
168
|
+
|
|
169
|
+
export async function vmInfo(exec, target) {
|
|
170
|
+
const vm = await resolveTarget(exec, target);
|
|
171
|
+
const data = await exec('proxmox_vm_resources', { vmid: vm.vmid, node: vm.node, type: vm.type });
|
|
172
|
+
return { vm, resources: payloadOf(data) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function vmAction(exec, action, target, { reason } = {}) {
|
|
176
|
+
if (!VM_ACTIONS.includes(action)) throw new Error(`action must be one of ${VM_ACTIONS.join(', ')}`);
|
|
177
|
+
const vm = await resolveTarget(exec, target);
|
|
178
|
+
const data = await exec('proxmox_vm_action', withReason({ vmid: vm.vmid, node: vm.node, action, type: vm.type }, reason));
|
|
179
|
+
return { vm, result: payloadOf(data) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function vmSnapshot(exec, target, { name, reason } = {}) {
|
|
183
|
+
const vm = await resolveTarget(exec, target);
|
|
184
|
+
// proxmox_vm_snapshot requires snapname (Proxmox: letter first, then [A-Za-z0-9_-]).
|
|
185
|
+
const snapname = name || `quox-${new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)}`;
|
|
186
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,39}$/.test(snapname)) throw new Error('snapshot name must start with a letter and use only letters, digits, _ or -');
|
|
187
|
+
const input = { vmid: vm.vmid, node: vm.node, snapname };
|
|
188
|
+
const data = await exec('proxmox_vm_snapshot', withReason(input, reason));
|
|
189
|
+
return { vm, result: payloadOf(data) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `url:http://127.0.0.1:8080/health` | `unit:nginx` | `cmd:test -f /etc/x` -> a proxmox_rehearse check.
|
|
194
|
+
* Only the shape is checked here; the server validates the characters strictly.
|
|
195
|
+
*/
|
|
196
|
+
export function parseCheck(spec) {
|
|
197
|
+
const m = /^(url|unit|cmd):(.+)$/.exec(String(spec ?? '').trim());
|
|
198
|
+
if (!m) throw new Error(`--check must look like url:http://127.0.0.1:PORT/path, unit:NAME or cmd:COMMAND (got "${spec}")`);
|
|
199
|
+
if (m[1] === 'url') return { type: 'url', url: m[2] };
|
|
200
|
+
if (m[1] === 'unit') return { type: 'unit', name: m[2] };
|
|
201
|
+
return { type: 'cmd', run: m[2] };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Start a rehearsal on a network-isolated twin of the VM (HYPERION proxmox_rehearse). Returns at once; the run takes minutes (a full disk copy). */
|
|
205
|
+
export async function vmRehearse(exec, target, command, { checks = [], keepTwin = false } = {}) {
|
|
206
|
+
const c = String(command ?? '').trim();
|
|
207
|
+
if (!c) throw new Error('a command is required after --');
|
|
208
|
+
if (c.includes("'")) throw new Error('the command may not contain single quotes (it runs inside bash -c, use double quotes)');
|
|
209
|
+
const vm = await resolveTarget(exec, target);
|
|
210
|
+
const input = { vmid: vm.vmid, node: vm.node, command: c };
|
|
211
|
+
if (checks.length) input.checks = checks.map(parseCheck);
|
|
212
|
+
if (keepTwin) input.keep_twin = true;
|
|
213
|
+
let data;
|
|
214
|
+
try { data = await exec('proxmox_rehearse', input); } catch (e) {
|
|
215
|
+
// The rehearsal needs ONE human approval (the card lists every host command). The proposal exists and
|
|
216
|
+
// the run starts by itself once approved, so hand back the id instead of failing.
|
|
217
|
+
if (e instanceof VmPendingError && e.rehearsalId) {
|
|
218
|
+
return { vm, result: { rehearsal_id: e.rehearsalId, state: 'awaiting_approval', approval_id: e.approvalId, inbox_item_id: e.inboxItemId, hint: pendingHint(e) } };
|
|
219
|
+
}
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
return { vm, result: payloadOf(data) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const REHEARSAL_POLL_MS = 10000;
|
|
226
|
+
export const REHEARSAL_TIMEOUT_MS = 50 * 60 * 1000;
|
|
227
|
+
|
|
228
|
+
/** Poll proxmox_rehearsal_status until the rehearsal is done (awaiting_approval and running both keep waiting). Throws on timeout (the id stays valid for a later look). */
|
|
229
|
+
export async function waitRehearsal(exec, rehearsalId, { intervalMs = REHEARSAL_POLL_MS, timeoutMs = REHEARSAL_TIMEOUT_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), onTick } = {}) {
|
|
230
|
+
if (!/^RH_[0-9A-F]{16}$/.test(String(rehearsalId || ''))) throw new Error('the server did not return a rehearsal id');
|
|
231
|
+
const t0 = Date.now();
|
|
232
|
+
for (;;) {
|
|
233
|
+
const rec = payloadOf(await exec('proxmox_rehearsal_status', { rehearsal_id: rehearsalId }));
|
|
234
|
+
const r = rec?.rehearsal || rec;
|
|
235
|
+
if (r && r.state === 'done') return r;
|
|
236
|
+
if (Date.now() - t0 > timeoutMs) throw new Error(`rehearsal ${rehearsalId} is still running after ${Math.round(timeoutMs / 60000)} minutes; check later with the proxmox_rehearsal_status tool`);
|
|
237
|
+
if (onTick) onTick(r);
|
|
238
|
+
await sleep(intervalMs);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Human REST surface for the pool. */
|
|
243
|
+
export function makePoolRest({ client }) {
|
|
244
|
+
const call = (name, params, body, timeout) => {
|
|
245
|
+
const ep = resolveEndpoint(name, params);
|
|
246
|
+
const m = ep.method.toLowerCase();
|
|
247
|
+
const t = timeout ? { timeout } : {};
|
|
248
|
+
return m === 'get' ? client.get(ep.path, { service: ep.service, ...t }) : client[m](ep.path, { service: ep.service, body: body || {}, ...t });
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
status: () => call('v1.proxmox.pool'),
|
|
252
|
+
configure: (body) => call('v1.proxmox.pool.config.put', {}, body),
|
|
253
|
+
// A warm lease probes ssh, installs the key and refreshes the IP; a slow auth service stretches that past 30s.
|
|
254
|
+
lease: (body) => call('v1.proxmox.pool.lease', {}, body, 180000),
|
|
255
|
+
release: (vmid) => call('v1.proxmox.pool.release', { vmid }),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function poolStatus(rest) {
|
|
260
|
+
return rest.status();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function defaultLessee(env = process.env) {
|
|
264
|
+
return env.QUOX_SESSION || os.hostname();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** --pubkey-file, else ~/.ssh/id_ed25519.pub when present, else a clear error. Reads the PUBLIC key only. */
|
|
268
|
+
export function readPubkey(file, { home = os.homedir() } = {}) {
|
|
269
|
+
const f = file || path.join(home, '.ssh', 'id_ed25519.pub');
|
|
270
|
+
if (!file && !fs.existsSync(f)) throw new Error('no ssh public key: pass --pubkey-file <path> (default ~/.ssh/id_ed25519.pub was not found)');
|
|
271
|
+
let txt;
|
|
272
|
+
try { txt = fs.readFileSync(f, 'utf8').trim(); } catch (e) { throw new Error(`cannot read public key ${f}: ${e.message}`); }
|
|
273
|
+
if (!/^(ssh-|ecdsa-|sk-)/.test(txt)) throw new Error(`${f} does not look like an SSH public key (refusing to send it)`);
|
|
274
|
+
return txt;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function poolLease(rest, { lessee, pubkey, cores, memoryGb, diskGb, ttlH, purpose, avx, node } = {}) {
|
|
278
|
+
const input = { lessee: lessee || defaultLessee(), ssh_pubkey: pubkey };
|
|
279
|
+
if (cores != null) input.cores = Number(cores);
|
|
280
|
+
if (memoryGb != null) input.memory_gb = Number(memoryGb);
|
|
281
|
+
if (diskGb != null) input.disk_gb = Number(diskGb);
|
|
282
|
+
if (ttlH != null) input.ttl_h = Number(ttlH);
|
|
283
|
+
if (purpose) input.purpose = purpose;
|
|
284
|
+
if (avx) input.require_avx2 = true; // set when only some nodes have AVX2; the server errors if no lane can take it
|
|
285
|
+
if (node) input.node = node;
|
|
286
|
+
return rest.lease(input); // 201 {lease} when a warm VM fits, 202 {vmid,state:'building'} when built to order
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Host key lines for a returned lease: the fingerprint to compare, and a ready known_hosts line. Never invents a key. */
|
|
290
|
+
export function poolHostKeyLines(p) {
|
|
291
|
+
const l = p && p.lease;
|
|
292
|
+
if (!l || typeof l !== 'object') return [];
|
|
293
|
+
if (l.host_key_line && l.host_key_fingerprint) {
|
|
294
|
+
return [`Host key ${l.host_key_fingerprint}`, 'known_hosts line:', `${l.ip} ${l.host_key_line}`];
|
|
295
|
+
}
|
|
296
|
+
return [`host key not read: ${l.host_key_error || 'the server returned none'}`];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function poolDone(rest, vmid) {
|
|
300
|
+
if (!/^\d+$/.test(String(vmid ?? ''))) throw new Error('vmid must be a number');
|
|
301
|
+
return rest.release(Number(vmid));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function poolConfig(rest, input = {}) {
|
|
305
|
+
return rest.configure(input);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function nodePower(exec, action, node, { reason } = {}) {
|
|
309
|
+
if (!NODE_ACTIONS.includes(action)) throw new Error(`node action must be one of ${NODE_ACTIONS.join(', ')}`);
|
|
310
|
+
if (!node) throw new Error('a node name is required');
|
|
311
|
+
// The proxmox_node_power tool is not built yet. Fail loudly instead of calling a missing tool.
|
|
312
|
+
void exec; void reason;
|
|
313
|
+
throw new Error('node power (reboot, shutdown) is not built yet; there is no proxmox_node_power tool');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Plain-text lines for a VM list. */
|
|
317
|
+
export function vmListLines(vms) {
|
|
318
|
+
if (!vms.length) return ['No VMs found.'];
|
|
319
|
+
return vms.map((v) => `${String(v.vmid).padEnd(6)} ${String(v.name).padEnd(28)} ${String(v.node || '?').padEnd(8)} ${String(v.type).padEnd(5)} ${v.status}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function textOf(p) {
|
|
323
|
+
if (p === undefined || p === null) return 'done';
|
|
324
|
+
return typeof p === 'string' ? p : JSON.stringify(p, null, 2);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* VM Doctor (DEVFLEET-DOCTOR): read-only diagnosis. The server resolves the name or vmid itself
|
|
329
|
+
* (proxmox_vm_doctor), so the target goes straight through. -> the report object.
|
|
330
|
+
*/
|
|
331
|
+
export async function vmDoctor(exec, target) {
|
|
332
|
+
const t = String(target ?? '').trim();
|
|
333
|
+
if (!t) throw new Error('a VM name or vmid is required');
|
|
334
|
+
const p = payloadOf(await exec('proxmox_vm_doctor', { target: t }));
|
|
335
|
+
if (!p || typeof p !== 'object' || !Array.isArray(p.findings)) throw new Error('proxmox_vm_doctor returned no report');
|
|
336
|
+
return p;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const SEV_LABEL = { critical: 'CRITICAL', warning: 'WARNING', info: 'INFO' };
|
|
340
|
+
const showVal = (v) => (typeof v === 'number' ? String(Math.round(v * 1000) / 1000) : String(v));
|
|
341
|
+
|
|
342
|
+
/** Human lines for a doctor report: verdict, each finding (cause / evidence / fix), then gaps. */
|
|
343
|
+
export function doctorLines(report) {
|
|
344
|
+
const vm = report.vm || {};
|
|
345
|
+
const lines = [`VERDICT ${String(report.verdict || 'unknown').toUpperCase()} ${vm.name || vm.vmid} (vmid ${vm.vmid} on ${vm.node}, ${vm.status || 'unknown'})`];
|
|
346
|
+
if (!report.findings.length) lines.push('No problems found in what could be read.');
|
|
347
|
+
for (const f of report.findings) {
|
|
348
|
+
lines.push('', `${SEV_LABEL[f.severity] || String(f.severity).toUpperCase()} ${f.title}: ${f.cause}`);
|
|
349
|
+
for (const e of f.evidence || []) {
|
|
350
|
+
lines.push(` evidence: ${e.metric} = ${showVal(e.value)} (threshold ${showVal(e.threshold)}, ${e.window}, ${e.source})`);
|
|
351
|
+
}
|
|
352
|
+
lines.push(` fix: ${f.fix && f.fix.text}`);
|
|
353
|
+
if (f.fix && f.fix.action) lines.push(` gated action: ${f.fix.action.tool} ${JSON.stringify(f.fix.action.input)} (asks for approval; not run)`);
|
|
354
|
+
}
|
|
355
|
+
if (report.gaps && report.gaps.length) {
|
|
356
|
+
lines.push('', 'Could not check:');
|
|
357
|
+
for (const g of report.gaps) lines.push(` ${g}`);
|
|
358
|
+
}
|
|
359
|
+
if (report.report_id) lines.push('', `report ${report.report_id}`);
|
|
360
|
+
return lines;
|
|
361
|
+
}
|