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
|
@@ -12,7 +12,18 @@ import os from 'node:os';
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { spawnSync as realSpawnSync } from 'node:child_process';
|
|
15
|
-
import {
|
|
15
|
+
import { colorOn } from './banner.js';
|
|
16
|
+
import { qLines, qDims } from '../quoxon/qart.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The session banner's Q is quoxon's settled vector horned-Q (the face of the app, same mark as
|
|
20
|
+
* bare `quox` and cli.quox.ai), not the old half-block bitmap. 'small' for a narrow terminal, 'mid'
|
|
21
|
+
* otherwise. Returns the art rows and their visible width (used to pad the text column alignment).
|
|
22
|
+
*/
|
|
23
|
+
function introArt(color, cols) {
|
|
24
|
+
const size = cols && cols < 72 ? 'small' : 'mid';
|
|
25
|
+
return { lines: qLines(size, { color }), width: qDims(size).cols };
|
|
26
|
+
}
|
|
16
27
|
import { startHeartbeat as realStartHeartbeat } from './sessionHeartbeat.js';
|
|
17
28
|
|
|
18
29
|
const NAME_RE = /^[A-Za-z0-9._-]{1,48}$/;
|
|
@@ -37,8 +48,6 @@ export function autoName(now = new Date()) {
|
|
|
37
48
|
export const PROGRAMS = {
|
|
38
49
|
claude: { tag: '', bin: 'claude', resumeHint: (n) => `quox claude --resume ${n}` },
|
|
39
50
|
codex: { tag: 'codex-', bin: 'codex', resumeHint: (n) => `quox codex resume ${n}` },
|
|
40
|
-
// gemini resumes by index or "latest" (--resume latest | --resume N), not by name.
|
|
41
|
-
gemini: { tag: 'gemini-', bin: 'gemini', resumeHint: () => 'quox gemini --resume latest' },
|
|
42
51
|
tui: { tag: 'tui-', bin: 'quox', resumeHint: (n) => `quox tui --governed --name ${n}` },
|
|
43
52
|
};
|
|
44
53
|
|
|
@@ -162,10 +171,10 @@ export function howItWorksLines({ name, program = 'claude', bare }) {
|
|
|
162
171
|
];
|
|
163
172
|
}
|
|
164
173
|
|
|
165
|
-
export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false } = {}) {
|
|
174
|
+
export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false, cols = process.stdout?.columns } = {}) {
|
|
166
175
|
const b = (s) => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
167
176
|
const d = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
168
|
-
const art =
|
|
177
|
+
const { lines: art, width: artW } = introArt(color, cols);
|
|
169
178
|
const modeText = {
|
|
170
179
|
NEW: 'NEW',
|
|
171
180
|
REATTACH: 'REATTACH (same process, no context reload)',
|
|
@@ -184,7 +193,7 @@ export function renderIntro({ name, host, mode, tmuxSession, note, program = 'cl
|
|
|
184
193
|
b('How this works'),
|
|
185
194
|
...howItWorksLines({ name, program }).map(([k, v]) => `${d(k.padEnd(19))}${v}`),
|
|
186
195
|
];
|
|
187
|
-
const w = art[0] ?
|
|
196
|
+
const w = art[0] ? artW : 0;
|
|
188
197
|
const lines = [];
|
|
189
198
|
for (let i = 0; i < Math.max(art.length, text.length); i++) {
|
|
190
199
|
lines.push(` ${art[i] || ' '.repeat(w)} ${text[i] || ''}`);
|
|
@@ -256,7 +265,108 @@ export function ensureHelpBinding(d) {
|
|
|
256
265
|
return r && r.status === 0 ? 'bound' : 'failed';
|
|
257
266
|
}
|
|
258
267
|
|
|
259
|
-
|
|
268
|
+
/**
|
|
269
|
+
* SESSION-LAUNCH-DEDUPE: tmux session names are case-sensitive but conversation names are not, so
|
|
270
|
+
* `--resume quoxon` must find `quox-QUOXON`. Returns the real session name or null.
|
|
271
|
+
*/
|
|
272
|
+
export function findSessionCI(d, wanted) {
|
|
273
|
+
const r = d.spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' });
|
|
274
|
+
if (!r || r.error || r.status !== 0) return null;
|
|
275
|
+
const w = String(wanted).toLowerCase();
|
|
276
|
+
const names = String(r.stdout || '').split('\n').map((l) => l.split('\t')[0].trim()).filter(Boolean);
|
|
277
|
+
return names.find((n) => n.toLowerCase() === w) || null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const PS_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/;
|
|
281
|
+
const base = (t) => String(t || '').split('/').pop();
|
|
282
|
+
|
|
283
|
+
/** True when argv is a claude process resuming `name` (case-insensitive), also via `node .../claude`. */
|
|
284
|
+
export function resumesName(args, name) {
|
|
285
|
+
const t = String(args).trim().split(/\s+/);
|
|
286
|
+
let i = 0;
|
|
287
|
+
if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
|
|
288
|
+
else if (base(t[0]) !== 'claude') return false;
|
|
289
|
+
const want = String(name).toLowerCase();
|
|
290
|
+
for (let k = i + 1; k < t.length; k++) {
|
|
291
|
+
if ((t[k] === '--resume' || t[k] === '-r') && String(t[k + 1] || '').toLowerCase() === want) return true;
|
|
292
|
+
if (t[k].startsWith('--resume=') && t[k].slice(9).toLowerCase() === want) return true;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Live claude processes already holding conversation `name`: [{ pid, tty, tmux }]. */
|
|
298
|
+
export function findLiveHolders(d, name) {
|
|
299
|
+
const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,args='], { encoding: 'utf8' });
|
|
300
|
+
if (!ps || ps.error || ps.status !== 0) return [];
|
|
301
|
+
const procs = new Map();
|
|
302
|
+
for (const line of String(ps.stdout || '').split('\n')) {
|
|
303
|
+
const m = PS_RE.exec(line);
|
|
304
|
+
if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], args: m[4] });
|
|
305
|
+
}
|
|
306
|
+
const self = d.pid ?? process.pid;
|
|
307
|
+
const hits = [...procs.values()].filter((p) => p.pid !== self && p.pid !== process.ppid && resumesName(p.args, name));
|
|
308
|
+
if (!hits.length) return [];
|
|
309
|
+
const panes = new Map();
|
|
310
|
+
const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
|
|
311
|
+
if (lp && !lp.error && lp.status === 0) {
|
|
312
|
+
for (const l of String(lp.stdout || '').split('\n')) {
|
|
313
|
+
const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
|
|
314
|
+
if (m) panes.set(Number(m[1]), m[2]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return hits.map((p) => {
|
|
318
|
+
let tmux = null;
|
|
319
|
+
for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
|
|
320
|
+
return { pid: p.pid, tty: p.tty, tmux };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The conversation name a claude argv resumes (`--resume X`, `-r X`, `--resume=X`), or null. */
|
|
325
|
+
function resumedName(args) {
|
|
326
|
+
const t = String(args).trim().split(/\s+/);
|
|
327
|
+
let i = 0;
|
|
328
|
+
if (base(t[0]) === 'node' && base(t[1]) === 'claude') i = 1;
|
|
329
|
+
else if (base(t[0]) !== 'claude') return null;
|
|
330
|
+
for (let k = i + 1; k < t.length; k++) {
|
|
331
|
+
if ((t[k] === '--resume' || t[k] === '-r') && t[k + 1]) return t[k + 1];
|
|
332
|
+
if (t[k].startsWith('--resume=') && t[k].length > 9) return t[k].slice(9);
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const PS_AGE_RE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(.*)$/;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every live `claude --resume <name>` on this box (same argv detection as findLiveHolders):
|
|
341
|
+
* [{ name, pid, tty, tmux, ageSec }]. tmux is the enclosing tmux session or null. [] if ps fails.
|
|
342
|
+
*/
|
|
343
|
+
export function listLiveSessions(d) {
|
|
344
|
+
const ps = d.spawnSync('ps', ['-eo', 'pid=,ppid=,tty=,etimes=,args='], { encoding: 'utf8' });
|
|
345
|
+
if (!ps || ps.error || ps.status !== 0) return [];
|
|
346
|
+
const procs = new Map();
|
|
347
|
+
for (const line of String(ps.stdout || '').split('\n')) {
|
|
348
|
+
const m = PS_AGE_RE.exec(line);
|
|
349
|
+
if (m) procs.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], ageSec: Number(m[4]), args: m[5] });
|
|
350
|
+
}
|
|
351
|
+
const self = d.pid ?? process.pid;
|
|
352
|
+
const hits = [...procs.values()].filter((p) => p.pid !== self && resumedName(p.args));
|
|
353
|
+
if (!hits.length) return [];
|
|
354
|
+
const panes = new Map();
|
|
355
|
+
const lp = d.spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_pid} #{session_name}'], { encoding: 'utf8' });
|
|
356
|
+
if (lp && !lp.error && lp.status === 0) {
|
|
357
|
+
for (const l of String(lp.stdout || '').split('\n')) {
|
|
358
|
+
const m = /^(\d+)\s+(\S+)$/.exec(l.trim());
|
|
359
|
+
if (m) panes.set(Number(m[1]), m[2]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return hits.map((p) => {
|
|
363
|
+
let tmux = null;
|
|
364
|
+
for (let pid = p.pid, hops = 0; pid > 1 && hops < 32 && !tmux; hops++) { tmux = panes.get(pid) || null; pid = procs.get(pid)?.ppid ?? (hops === 0 ? p.ppid : 0); }
|
|
365
|
+
return { name: resumedName(p.args), pid: p.pid, tty: p.tty, tmux, ageSec: p.ageSec };
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function runGoverned({ program = 'claude', name, args = [], resume = false, noTmux = false, force = false, bin, preattach }, over) {
|
|
260
370
|
const d = defaults(over);
|
|
261
371
|
const n = name || autoName(d.now());
|
|
262
372
|
if (program === 'run') {
|
|
@@ -283,6 +393,24 @@ export function runGoverned({ program = 'claude', name, args = [], resume = fals
|
|
|
283
393
|
return 1;
|
|
284
394
|
}
|
|
285
395
|
alive = r.status === 0;
|
|
396
|
+
if (!alive && resume && program === 'claude') {
|
|
397
|
+
const real = findSessionCI(d, PREFIX + full);
|
|
398
|
+
if (real) {
|
|
399
|
+
// Same conversation, different case: attach, never start a second Claude.
|
|
400
|
+
const attach = `tmux attach -t ${real}`;
|
|
401
|
+
if (!(d.isTTY ?? process.stdout.isTTY)) { d.write(`${n} is already running in tmux ${real}. Attach: ${attach}\n`); return 0; }
|
|
402
|
+
const a = d.spawnSync('tmux', ['attach-session', '-t', real], { stdio: 'inherit' });
|
|
403
|
+
return a && typeof a.status === 'number' ? a.status : 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!alive && resume && program === 'claude' && !force) {
|
|
408
|
+
const [h] = findLiveHolders(d, n);
|
|
409
|
+
if (h) {
|
|
410
|
+
const where = h.tmux ? `tmux ${h.tmux}` : `not in tmux on ${h.tty}`;
|
|
411
|
+
d.write(`${n} is already running (pid ${h.pid}, ${where}). ${h.tmux ? `Attach: tmux attach -t ${h.tmux}. ` : ''}To start a second copy anyway: --force.\n`);
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
286
414
|
}
|
|
287
415
|
const plan = planLaunch({ name: n, args, env: d.env, alive, resume, noTmux, program, bin, preattach });
|
|
288
416
|
const shown = program === 'run' ? bin : program;
|
|
@@ -335,7 +463,7 @@ export function runLs(over) {
|
|
|
335
463
|
return 1;
|
|
336
464
|
}
|
|
337
465
|
if (!s.length) {
|
|
338
|
-
d.write('No Quox sessions running. Start one with: quox claude (or quox codex, quox
|
|
466
|
+
d.write('No Quox sessions running. Start one with: quox claude (or quox codex, quox wrap <program>)\n');
|
|
339
467
|
return 0;
|
|
340
468
|
}
|
|
341
469
|
const pad = (v, n) => String(v).padEnd(n);
|
|
@@ -41,13 +41,14 @@ export function humanUtc(iso) {
|
|
|
41
41
|
export function resolutionFromItem(item, requestedAction) {
|
|
42
42
|
if (!item || !item.id) return null;
|
|
43
43
|
const response = item.response || null;
|
|
44
|
-
const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : (outcomeOf(response) || 'resolved');
|
|
44
|
+
const outcome = item.status === 'expired' ? 'expired' : item.status === 'cancelled' ? 'cancelled' : item.status === 'withdrawn' ? 'withdrawn' : (outcomeOf(response) || 'resolved');
|
|
45
45
|
const name = item.resolved_by_name || response?.responded_by_name || null;
|
|
46
46
|
const at = toIso(item.resolved_at) || toIso(response?.responded_at) || null;
|
|
47
47
|
const requested = String(requestedAction || '').trim().toLowerCase() || null;
|
|
48
|
-
const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && normalise(requested) === outcome;
|
|
48
|
+
const matches = Boolean(requested) && outcome !== 'expired' && outcome !== 'cancelled' && outcome !== 'withdrawn' && normalise(requested) === outcome;
|
|
49
49
|
let message;
|
|
50
50
|
if (outcome === 'expired') message = `This approval expired at ${humanUtc(at)}; nothing was approved. Ask for it again.`;
|
|
51
|
+
else if (outcome === 'withdrawn') message = `This request was withdrawn at ${humanUtc(at)}${name ? ` by ${name}` : ''}; nothing was decided and nothing ran.`;
|
|
51
52
|
else if (outcome === 'cancelled') message = `This item was cancelled at ${humanUtc(at)}${name ? ` by ${name}` : ''}; there is nothing to respond to.`;
|
|
52
53
|
else {
|
|
53
54
|
const head = `Already ${outcome}${name ? ` by ${name}` : ''} at ${humanUtc(at)}`;
|
|
@@ -10,7 +10,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
10
10
|
|
|
11
11
|
export const TMUX_FORMAT = '#{session_name}\t#{session_created}\t#{session_attached}\t#{pane_current_command}\t#{pane_pid}\t#{session_activity}';
|
|
12
12
|
|
|
13
|
-
const AGENT_PROGRAMS = new Set(['claude', 'codex'
|
|
13
|
+
const AGENT_PROGRAMS = new Set(['claude', 'codex']);
|
|
14
14
|
|
|
15
15
|
/** Parse `tmux list-panes -a -F TMUX_FORMAT` output; one entry per session (first pane wins). */
|
|
16
16
|
export function parseTmuxPanes(out) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login guards shared by `quox login` and the TUI /login.
|
|
3
|
+
* - prefill comes ONLY from the active profile's own last_email
|
|
4
|
+
* - a non-default profile signing in as an email another profile already holds gets a y/N warning
|
|
5
|
+
* - a 429 says how long to wait (or that retrying may extend the limit)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The active profile's own last_email, or ''. Never another profile's, never a global. */
|
|
9
|
+
export function loginPrefillEmail(config) {
|
|
10
|
+
try { return String(config.getActiveProfile?.()?.last_email || '').trim(); } catch { return ''; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A server URL reduced to scheme://host:port so https://H, https://h/ and https://h:443 compare equal. '' when not a URL. */
|
|
14
|
+
export function normalizeServerUrl(u) {
|
|
15
|
+
try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname.toLowerCase()}:${x.port || (x.protocol === 'https:' ? '443' : '80')}`; } catch { return ''; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Name of ANOTHER profile currently signed in as `email` (case-insensitive), else null.
|
|
20
|
+
* H2: the same email on a DIFFERENT server is a different account. When `url` (the server this sign-in targets) is
|
|
21
|
+
* given and the other profile has its own server URL that differs, it does not count. A profile with no URL of its
|
|
22
|
+
* own is unknown, so it still counts (the safe side for an identity guard).
|
|
23
|
+
*/
|
|
24
|
+
export function profileHoldingEmail(config, email, url = null) {
|
|
25
|
+
const want = String(email || '').trim().toLowerCase();
|
|
26
|
+
if (!want) return null;
|
|
27
|
+
const me = config.getActiveProfileName?.();
|
|
28
|
+
for (const [name, p] of Object.entries(config._data?.profiles || {})) {
|
|
29
|
+
if (name === me) continue;
|
|
30
|
+
if (String(p?.auth?.email || '').trim().toLowerCase() !== want) continue;
|
|
31
|
+
const mine = normalizeServerUrl(url), theirs = normalizeServerUrl(p?.url);
|
|
32
|
+
if (mine && theirs && mine !== theirs) continue;
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only a non-default profile needs the warning: it is how agent-only profiles become the owner. */
|
|
39
|
+
export function isNonDefaultProfile(config) {
|
|
40
|
+
return Boolean(config._data?.activeProfile) && config.getActiveProfileName?.() !== config._data.activeProfile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function sameIdentityWarning(email, holder, profile) {
|
|
44
|
+
return `${email} is already signed in as profile '${holder}'. Sign profile '${profile}' in as the same user? y/N`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Plain-language wait guidance for a rate-limited sign-in. */
|
|
48
|
+
export function rateLimitHint({ retryAfterMs } = {}) {
|
|
49
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
|
50
|
+
const min = Math.max(1, Math.ceil(retryAfterMs / 60000));
|
|
51
|
+
return `Too many authentication attempts: try again in ~${min} min; each retry may extend the limit.`;
|
|
52
|
+
}
|
|
53
|
+
return 'Too many authentication attempts: wait a few minutes; retrying now may extend the limit.';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Retry-After (seconds or HTTP date) header and common body fields -> ms, else null. */
|
|
57
|
+
export function retryAfterMsFrom(res, data) {
|
|
58
|
+
const h = res?.headers?.get?.('retry-after');
|
|
59
|
+
if (h != null && h !== '') {
|
|
60
|
+
const n = Number(h);
|
|
61
|
+
if (Number.isFinite(n)) return n * 1000;
|
|
62
|
+
const t = Date.parse(h);
|
|
63
|
+
if (Number.isFinite(t)) return Math.max(0, t - Date.now());
|
|
64
|
+
}
|
|
65
|
+
const s = data?.retry_after ?? data?.retryAfter ?? data?.retry_after_seconds;
|
|
66
|
+
if (Number.isFinite(Number(s)) && s != null) return Number(s) * 1000;
|
|
67
|
+
const reset = data?.reset_at ?? data?.resetAt ?? data?.reset;
|
|
68
|
+
if (reset != null) {
|
|
69
|
+
const t = typeof reset === 'number' ? (reset < 1e12 ? reset * 1000 : reset) : Date.parse(reset);
|
|
70
|
+
if (Number.isFinite(t)) return Math.max(0, t - Date.now());
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QLOOP-LIVE: render loop events as QuoxGlyph lines (`quox loop watch`).
|
|
3
|
+
* ∞ QLOOP │ turn 3/10 │ wrote report.md · 41s ✔
|
|
4
|
+
*
|
|
5
|
+
* Pure: event in, string out, so the watch loop, the tests and anything else that wants the same
|
|
6
|
+
* voice (the TUI card's observe lines) share one renderer. The ∞ icon is claimed here by extending
|
|
7
|
+
* the exported registry at import time (the vendored quoxglyph.js is source-locked to the package).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ICONS, STATUS, brandName } from './quoxglyph.js';
|
|
11
|
+
|
|
12
|
+
ICONS.qloop = '∞ QLOOP';
|
|
13
|
+
|
|
14
|
+
const R = '\x1b[0m', DIM = '\x1b[2m';
|
|
15
|
+
const colourOn = () => brandName('x y') !== 'x y';
|
|
16
|
+
|
|
17
|
+
function oneLine(s, n) {
|
|
18
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
19
|
+
return [...t].length > n ? `${[...t].slice(0, n - 1).join('')}…` : t;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** "41s", "1m12s", "2h03m" */
|
|
23
|
+
export function elapsedLabel(ms) {
|
|
24
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
25
|
+
if (s < 60) return `${s}s`;
|
|
26
|
+
if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
|
|
27
|
+
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One QLOOP line: icon+name, verb, optional result, optional status glyph. Result is NOT budget-truncated here (callers cap it). */
|
|
31
|
+
export function loopLine(verb, result, status) {
|
|
32
|
+
const col = colourOn();
|
|
33
|
+
const sep = col ? `${DIM}│${R}` : '│';
|
|
34
|
+
let out = `${brandName(ICONS.qloop)} ${sep} ${verb}`;
|
|
35
|
+
if (result) out += ` ${sep} ${result}`;
|
|
36
|
+
const s = status && STATUS[status];
|
|
37
|
+
if (s) out += ' ' + (col && s.rgb ? `\x1b[38;2;${s.rgb[0]};${s.rgb[1]};${s.rgb[2]}m${s.glyph}${R}` : s.glyph);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const OBSERVE_STATUS = { interval: 'busy', milestone: 'ok', error: 'fail', approval: 'warn', stall: 'warn' };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} e a loop event (design section 1)
|
|
45
|
+
* @param {object} ctx { title, maxTurns, startedMs, spool }
|
|
46
|
+
* @returns {string[]} lines to print (may be empty)
|
|
47
|
+
*/
|
|
48
|
+
export function renderEvent(e, ctx = {}) {
|
|
49
|
+
const max = ctx.maxTurns ? `/${ctx.maxTurns}` : '';
|
|
50
|
+
const since = ctx.startedMs ? ` · ${elapsedLabel(Date.parse(e.at) - ctx.startedMs)}` : '';
|
|
51
|
+
switch (e.type) {
|
|
52
|
+
case 'loop.started': {
|
|
53
|
+
const scope = Array.isArray(e.scope) && e.scope.length ? e.scope.join(',') : 'none';
|
|
54
|
+
return [loopLine('started', `host ${e.host} · ${e.backend} · scope ${oneLine(scope, 60)}${e.max_turns ? ` · ${e.max_turns} turns max` : ''}`, 'busy')];
|
|
55
|
+
}
|
|
56
|
+
case 'loop.turn.started':
|
|
57
|
+
return ctx.spool ? [loopLine(`turn ${e.turn}${max}`, 'working', 'busy')] : [];
|
|
58
|
+
case 'loop.turn.output':
|
|
59
|
+
return ctx.spool ? String(e.chunk).replace(/\n+$/, '').split('\n').map((l) => ` │ ${l}`) : [];
|
|
60
|
+
case 'loop.turn.tool': {
|
|
61
|
+
if (e.note) return [loopLine(`turn ${e.turn}`, `${e.tool} no result (${oneLine(e.note, 60)})${since}`, 'warn')];
|
|
62
|
+
if (e.denied) return [loopLine(`turn ${e.turn}`, `${e.tool} denied: ${oneLine(e.denied, 60)}${since}`, 'warn')];
|
|
63
|
+
return [loopLine(`turn ${e.turn}`, `${e.tool} ${e.ok ? 'ok' : 'failed'}${e.ms ? ` ${e.ms}ms` : ''}${since}`, e.ok ? 'ok' : 'fail')];
|
|
64
|
+
}
|
|
65
|
+
case 'loop.turn.completed':
|
|
66
|
+
return [loopLine(`turn ${e.turn}${max}`, `${oneLine(e.summary, 70)}${since}`, 'ok')];
|
|
67
|
+
case 'loop.observe':
|
|
68
|
+
if (e.kind === 'milestone') return [loopLine('milestone', oneLine(e.text, 100), 'ok')];
|
|
69
|
+
return [loopLine(e.kind === 'interval' ? 'status' : e.kind, oneLine(e.text, 110), OBSERVE_STATUS[e.kind] || 'busy')];
|
|
70
|
+
case 'loop.ward':
|
|
71
|
+
if (e.status === 'pending') return []; // announced, not yet witnessed: nothing to say yet
|
|
72
|
+
return [loopLine('receipt', `${String(e.envelope_id).slice(0, 12)} ${e.status}`, e.status === 'WITNESSED' ? 'ok' : 'warn')];
|
|
73
|
+
case 'loop.approval':
|
|
74
|
+
return [loopLine('approval', `${oneLine(e.what, 90)} (${e.checkpoint_id})`, 'warn')];
|
|
75
|
+
case 'loop.finished': {
|
|
76
|
+
// Only a clean completion is a tick. completed_with_errors and a halt / interruption warn; a failure fails. The reason is
|
|
77
|
+
// whole (a wrapped line beats a cut sentence) and loses the raw component prefix the spawner puts first.
|
|
78
|
+
const status = e.state === 'completed' ? 'ok' : e.state === 'failed' ? 'fail' : 'warn';
|
|
79
|
+
const wit = e.receipts != null ? `, ${e.witnessed}/${e.receipts} witnessed` : '';
|
|
80
|
+
const reason = String(e.reason || '').replace(/\s+/g, ' ').trim().replace(/^[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*:\s+/, '');
|
|
81
|
+
const why = e.state !== 'completed' && reason ? `: ${reason}` : '';
|
|
82
|
+
return [loopLine(oneLine(ctx.title || 'loop', 48), `${e.state}, ${e.turns} turn${e.turns === 1 ? '' : 's'}${wit}${why}`, status)];
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Exit code for a finished loop: 0 completed (also with errors), 1 failed, 2 halted by a human, 3 interrupted. */
|
|
90
|
+
export function exitCodeFor(state) {
|
|
91
|
+
switch (state) {
|
|
92
|
+
case 'completed': case 'completed_with_errors': return 0;
|
|
93
|
+
case 'halted': return 2;
|
|
94
|
+
case 'interrupted': return 3;
|
|
95
|
+
default: return 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/lib/core/offload.js
CHANGED
|
@@ -192,22 +192,32 @@ export async function connect(deps, w) {
|
|
|
192
192
|
export function setupScript(nodeMajor, { docker = false } = {}) {
|
|
193
193
|
return [
|
|
194
194
|
'set -e',
|
|
195
|
+
// set -e does not fire inside an && list or on the left of a pipe, so every install step below ends in || exit
|
|
196
|
+
'set -o pipefail',
|
|
195
197
|
'SUDO=""; [ "$(id -u)" = 0 ] || SUDO="sudo -n"',
|
|
196
198
|
`NEED=${Number(nodeMajor)}`,
|
|
197
199
|
'export DEBIAN_FRONTEND=noninteractive',
|
|
200
|
+
// A fresh pool VM runs apt-daily/unattended-upgrades at boot; wait for it (bounded) instead of failing on the dpkg lock
|
|
201
|
+
'for i in $(seq 1 120); do pgrep -x apt-get >/dev/null || pgrep -f unattended-upgr >/dev/null || pgrep -x dpkg >/dev/null || break; sleep 5; done',
|
|
202
|
+
'APT="$SUDO apt-get -o DPkg::Lock::Timeout=600"',
|
|
198
203
|
'MISSING=""',
|
|
199
204
|
'for p in git rsync build-essential python3 curl ca-certificates; do dpkg -s $p >/dev/null 2>&1 || MISSING="$MISSING $p"; done',
|
|
200
|
-
'if [ -n "$MISSING" ]; then $
|
|
205
|
+
'if [ -n "$MISSING" ]; then { $APT update -qq && $APT install -y -qq $MISSING; } || { echo "offload setup: apt install of$MISSING failed" >&2; exit 1; }; fi',
|
|
201
206
|
'HAVE=$(node -p "process.versions.node.split(\'.\')[0]" 2>/dev/null || echo none)',
|
|
202
|
-
|
|
207
|
+
// a worker already on a NEWER major is fine (callers on this box run 20 and 22); never flip it between majors
|
|
208
|
+
'case "$HAVE" in none|"") HAVE=0;; esac',
|
|
209
|
+
'if [ "$HAVE" -lt "$NEED" ]; then { curl -fsSL https://deb.nodesource.com/setup_$NEED.x | ${SUDO:+$SUDO -E} bash - && $APT install -y -qq nodejs; } || { echo "offload setup: node $NEED install failed" >&2; exit 1; }; fi',
|
|
203
210
|
...(docker ? [
|
|
204
211
|
// --docker: the daemon plus buildx (the sandbox Dockerfiles use COPY --chmod, which needs BuildKit)
|
|
205
212
|
'DMISSING=""',
|
|
206
213
|
'for p in docker.io docker-buildx; do dpkg -s $p >/dev/null 2>&1 || DMISSING="$DMISSING $p"; done',
|
|
207
|
-
'if [ -n "$DMISSING" ]; then $
|
|
214
|
+
'if [ -n "$DMISSING" ]; then { $APT update -qq && $APT install -y -qq $DMISSING; } || { echo "offload setup: docker install failed" >&2; exit 1; }; fi',
|
|
208
215
|
'$SUDO systemctl enable --now docker >/dev/null 2>&1 || true',
|
|
209
216
|
`id -nG ${REMOTE_USER} | grep -qw docker || $SUDO usermod -aG docker ${REMOTE_USER}`,
|
|
210
217
|
] : []),
|
|
218
|
+
// Never record a worker as ready unless the toolchain is really there (2026-10-20: workers marked ready with no npm).
|
|
219
|
+
'command -v npm >/dev/null || { echo "offload setup: npm missing after setup" >&2; exit 1; }',
|
|
220
|
+
'[ "$(node -p "process.versions.node.split(\'.\')[0]")" -ge "$NEED" ] || { echo "offload setup: node is older than $NEED" >&2; exit 1; }',
|
|
211
221
|
`mkdir -p ${REMOTE_ROOT}`,
|
|
212
222
|
].join('\n');
|
|
213
223
|
}
|
|
@@ -236,18 +246,21 @@ export function sandboxImagesScript(root, sudo = 'sudo -n') {
|
|
|
236
246
|
].join('\n');
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
export const SETUP_REV = 2;
|
|
250
|
+
|
|
239
251
|
export async function ensureTooling(deps, w, { docker = false } = {}) {
|
|
240
252
|
const st = loadState(deps);
|
|
241
253
|
const major = String(deps.nodeVersion).replace(/^v/, '').split('.')[0];
|
|
242
|
-
|
|
254
|
+
// SETUP_REV: bumped when the setup script's guarantees change, so a worker recorded under an older script redoes setup.
|
|
255
|
+
const done = String(st.worker?.setup || '').startsWith(`r${SETUP_REV}:`) ? String(st.worker.setup).slice(`r${SETUP_REV}:`.length) : '';
|
|
243
256
|
// setup is recorded as "<node major>" or "<node major>+docker"; a docker run needs the +docker form
|
|
244
|
-
if (st.worker?.vmid === w.vmid && done.split('+')[0]
|
|
257
|
+
if (st.worker?.vmid === w.vmid && Number(done.split('+')[0]) >= Number(major) && (!docker || done.endsWith('+docker'))) return false;
|
|
245
258
|
const withDocker = docker || done.endsWith('+docker');
|
|
246
259
|
deps.log(`preparing the worker (node, git, rsync, build tools${withDocker ? ', docker' : ''})...`);
|
|
247
260
|
const r = await ssh(deps, w.ip, `bash -c ${shq(setupScript(major, { docker: withDocker }))}`);
|
|
248
261
|
if (r.status !== 0) throw new OffloadError(`worker setup failed (exit ${r.status}): ${r.cmdline}\n${(r.stderr || '').trim()}`);
|
|
249
262
|
const st2 = loadState(deps);
|
|
250
|
-
if (st2.worker) { st2.worker.setup = withDocker ? `${major}+docker` : major; saveState(deps, st2); }
|
|
263
|
+
if (st2.worker) { st2.worker.setup = `r${SETUP_REV}:` + (withDocker ? `${major}+docker` : major); saveState(deps, st2); }
|
|
251
264
|
return true;
|
|
252
265
|
}
|
|
253
266
|
|
|
@@ -255,7 +268,13 @@ export async function ensureTooling(deps, w, { docker = false } = {}) {
|
|
|
255
268
|
// Before a sync, drop synced dirs idle longer than the limit (dir mtime = last use, touched at every sync), never the
|
|
256
269
|
// one being synced and never one a run is busy in (.offload-busy younger than 6h; an older one is a crashed run).
|
|
257
270
|
export const PRUNE_IDLE_MIN = 120;
|
|
258
|
-
|
|
271
|
+
// Under disk pressure (free below minFreePct) the least recently used synced dirs go too, oldest first, never the
|
|
272
|
+
// current one or a busy one, until free space reaches targetFreePct (2026-10-20: ~30 dirs all used within the hour
|
|
273
|
+
// filled the shared worker and the idle rule freed nothing).
|
|
274
|
+
export const PRUNE_MIN_FREE_PCT = 20;
|
|
275
|
+
export const PRUNE_TARGET_FREE_PCT = 35;
|
|
276
|
+
const DF_FREE_PCT = 'df -P . | awk \'NR==2 { gsub("%", "", $5); print 100 - $5 }\'';
|
|
277
|
+
export function pruneScript(root, keep, maxIdleMin = PRUNE_IDLE_MIN, { minFreePct = PRUNE_MIN_FREE_PCT, targetFreePct = PRUNE_TARGET_FREE_PCT, freePctCmd = DF_FREE_PCT } = {}) {
|
|
259
278
|
return [
|
|
260
279
|
`cd ${shq(root)} 2>/dev/null || exit 0`,
|
|
261
280
|
'for d in */; do d=${d%/}; [ -d "$d" ] || continue',
|
|
@@ -265,6 +284,16 @@ export function pruneScript(root, keep, maxIdleMin = PRUNE_IDLE_MIN) {
|
|
|
265
284
|
'done',
|
|
266
285
|
// cache entries nobody has used for a day go too (each worktree's copy is only hard links into these)
|
|
267
286
|
`find ${shq(`${root}/.nm-cache`)} -mindepth 1 -maxdepth 1 -mmin +1440 -exec rm -rf {} + 2>/dev/null; true`,
|
|
287
|
+
`free_pct() { ${freePctCmd}; }`,
|
|
288
|
+
`if [ "$(free_pct)" -lt ${Number(minFreePct)} ]; then`,
|
|
289
|
+
' for d in $(ls -1tr); do [ -d "$d" ] || continue',
|
|
290
|
+
` [ "$(free_pct)" -ge ${Number(targetFreePct)} ] && break`,
|
|
291
|
+
` [ "$d" = ${shq(keep)} ] && continue`,
|
|
292
|
+
' [ -e "$d/.offload-busy" ] && [ -n "$(find "$d/.offload-busy" -mmin -360 2>/dev/null)" ] && continue',
|
|
293
|
+
' rm -rf -- "$d"',
|
|
294
|
+
' done',
|
|
295
|
+
'fi',
|
|
296
|
+
'true',
|
|
268
297
|
].join('\n');
|
|
269
298
|
}
|
|
270
299
|
|
|
@@ -502,3 +531,40 @@ export function gitToplevel(cwd) {
|
|
|
502
531
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
503
532
|
return r.status === 0 ? r.stdout.trim() : cwd;
|
|
504
533
|
}
|
|
534
|
+
|
|
535
|
+
// #1485: the git repo root for cwd, or null when cwd is not inside a repo.
|
|
536
|
+
// Unlike gitToplevel (which falls back to cwd), this reports the no-repo case
|
|
537
|
+
// so resolveSyncDir can refuse rather than sync an unbounded tree.
|
|
538
|
+
export function gitRepoRoot(cwd, { run = (c, a, o) => spawnSync(c, a, o) } = {}) {
|
|
539
|
+
const r = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
540
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// #1485: resolve the ONE directory offload rsyncs to the shared worker, and
|
|
544
|
+
// refuse an unbounded one. `quox offload` from a non-repo dir used to fall
|
|
545
|
+
// back to cwd, so running it from the home directory rsynced all ~29G of $HOME
|
|
546
|
+
// (including a live token file) onto the worker and filled its disk. The sync
|
|
547
|
+
// must be a bounded git repo root, never $HOME and never an ancestor of it.
|
|
548
|
+
export function resolveSyncDir(cwd, optionDir, { home = os.homedir(), repoRoot = gitRepoRoot } = {}) {
|
|
549
|
+
const h = path.resolve(home);
|
|
550
|
+
const guardHome = (dir, what) => {
|
|
551
|
+
if (dir === h) {
|
|
552
|
+
throw new OffloadError(`offload refuses to sync your home directory (${dir}). cd into a project repo, or pass --dir <repo-root>.`);
|
|
553
|
+
}
|
|
554
|
+
if (h === dir || h.startsWith(dir + path.sep) || dir === path.parse(dir).root) {
|
|
555
|
+
throw new OffloadError(`offload refuses to sync ${what} (${dir}): it contains your home directory, so the whole tree would be copied to the shared worker. Pass --dir <repo-root>.`);
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
if (optionDir) {
|
|
559
|
+
const d = path.resolve(optionDir);
|
|
560
|
+
guardHome(d, 'the --dir path');
|
|
561
|
+
return d;
|
|
562
|
+
}
|
|
563
|
+
const root = repoRoot(cwd);
|
|
564
|
+
if (!root) {
|
|
565
|
+
throw new OffloadError(`offload must run from inside a git repository (cwd ${cwd} is not one). cd into a repo, or pass --dir <repo-root>. Refusing so your whole home directory is not synced to the shared worker.`);
|
|
566
|
+
}
|
|
567
|
+
const d = path.resolve(root);
|
|
568
|
+
guardHome(d, 'the resolved repo root');
|
|
569
|
+
return d;
|
|
570
|
+
}
|
package/lib/core/org-override.js
CHANGED
|
@@ -20,6 +20,10 @@ import { QuoxValidationError } from './errors.js';
|
|
|
20
20
|
// Commands where --org legitimately means something other than "act in".
|
|
21
21
|
export const EXEMPT = new Set(['login', 'logout', 'signup', 'org switch', 'invites list', 'invites accept']);
|
|
22
22
|
|
|
23
|
+
// Verbs that mint a same-user token for the named org themselves (core/crossOrgApprovals.clientForTarget), so --org
|
|
24
|
+
// naming another org is honoured on that org, not refused. Membership is enforced server-side by the mint.
|
|
25
|
+
export const CROSS_ORG = new Set(['approvals list', 'approvals approve', 'approvals deny', 'approvals walkthrough']);
|
|
26
|
+
|
|
23
27
|
export function commandPath(cmd) {
|
|
24
28
|
const parts = [];
|
|
25
29
|
for (let c = cmd; c && c.parent; c = c.parent) parts.unshift(c.name());
|
|
@@ -35,7 +39,7 @@ export function commandPath(cmd) {
|
|
|
35
39
|
* @throws {QuoxValidationError} when the override differs from the session org
|
|
36
40
|
*/
|
|
37
41
|
export function assertOrgOverrideSafe({ override, authData, hasUserToken, command = '' }) {
|
|
38
|
-
if (!override || !hasUserToken || EXEMPT.has(command)) return;
|
|
42
|
+
if (!override || !hasUserToken || EXEMPT.has(command) || CROSS_ORG.has(command)) return;
|
|
39
43
|
const want = String(override).toLowerCase();
|
|
40
44
|
const sid = authData?.org_id || null;
|
|
41
45
|
const known = [sid, authData?.org_slug, authData?.org_name]
|
package/lib/core/output.js
CHANGED
|
@@ -45,7 +45,7 @@ export function formatOutput(result, options = {}) {
|
|
|
45
45
|
formatCsv(rows, columns, headers);
|
|
46
46
|
break;
|
|
47
47
|
case 'quiet':
|
|
48
|
-
formatQuiet(rows, columns);
|
|
48
|
+
formatQuiet(rows, options.quietColumn ? [options.quietColumn] : columns);
|
|
49
49
|
break;
|
|
50
50
|
default:
|
|
51
51
|
formatTable(rows, columns, headers, options);
|
|
@@ -83,9 +83,9 @@ export function formatDetail(obj, options = {}) {
|
|
|
83
83
|
const label = (labels[field] || field).padEnd(maxLabel + 2);
|
|
84
84
|
const raw = obj[field];
|
|
85
85
|
if (raw !== null && typeof raw === 'object') {
|
|
86
|
-
console.log(` ${chalk.dim(label)}${formatNestedValue(raw)}`);
|
|
86
|
+
console.log(` ${chalk.dim(label)}${formatNestedValue(raw, 4, options.full === true)}`);
|
|
87
87
|
} else {
|
|
88
|
-
const value = formatValue(raw);
|
|
88
|
+
const value = formatValue(raw, options.full === true);
|
|
89
89
|
console.log(` ${chalk.dim(label)} ${value}`);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -97,15 +97,15 @@ export function formatDetail(obj, options = {}) {
|
|
|
97
97
|
* Kept local to formatDetail (not folded into formatValue) so formatTable /
|
|
98
98
|
* formatCsv output is unchanged.
|
|
99
99
|
*/
|
|
100
|
-
function formatNestedValue(value, indent = 4) {
|
|
100
|
+
function formatNestedValue(value, indent = 4, full = false) {
|
|
101
101
|
if (value == null) return ` ${chalk.dim('-')}`;
|
|
102
102
|
|
|
103
103
|
if (Array.isArray(value)) {
|
|
104
104
|
if (value.length === 0) return ` ${chalk.dim('[]')}`;
|
|
105
105
|
const allPrimitive = value.every(v => v === null || typeof v !== 'object');
|
|
106
|
-
if (allPrimitive) return ` ${value.map(v => formatValue(v)).join(', ')}`;
|
|
106
|
+
if (allPrimitive) return ` ${value.map(v => formatValue(v, full)).join(', ')}`;
|
|
107
107
|
return '\n' + value
|
|
108
|
-
.map(v => `${' '.repeat(indent)}- ${typeof v === 'object' && v !== null ? formatNestedValue(v, indent + 2).trimStart() : formatValue(v)}`)
|
|
108
|
+
.map(v => `${' '.repeat(indent)}- ${typeof v === 'object' && v !== null ? formatNestedValue(v, indent + 2, full).trimStart() : formatValue(v, full)}`)
|
|
109
109
|
.join('\n');
|
|
110
110
|
}
|
|
111
111
|
|
|
@@ -115,9 +115,9 @@ function formatNestedValue(value, indent = 4) {
|
|
|
115
115
|
.map(k => {
|
|
116
116
|
const v = value[k];
|
|
117
117
|
if (v !== null && typeof v === 'object') {
|
|
118
|
-
return `${' '.repeat(indent)}${chalk.dim(k + ':')}${formatNestedValue(v, indent + 2)}`;
|
|
118
|
+
return `${' '.repeat(indent)}${chalk.dim(k + ':')}${formatNestedValue(v, indent + 2, full)}`;
|
|
119
119
|
}
|
|
120
|
-
return `${' '.repeat(indent)}${chalk.dim(k + ':')} ${formatValue(v)}`;
|
|
120
|
+
return `${' '.repeat(indent)}${chalk.dim(k + ':')} ${formatValue(v, full)}`;
|
|
121
121
|
})
|
|
122
122
|
.join('\n');
|
|
123
123
|
}
|