quoxcli 0.0.0-stage → 0.2.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/LICENSE +35 -0
- package/README.md +142 -2
- package/bin/quox.js +292 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +125 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agents.js +56 -0
- package/lib/api/endpoints/assistants.js +50 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +54 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +66 -0
- package/lib/api/endpoints/conversations.js +32 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +32 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +26 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +36 -0
- package/lib/api/endpoints/inbox.js +32 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +228 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +75 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mon.js +178 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +62 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +134 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +114 -0
- package/lib/api/endpoints/quoxseo.js +108 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +46 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +180 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +70 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +499 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +40 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +162 -0
- package/lib/api/endpoints/x.js +24 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/admin.js +359 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +201 -0
- package/lib/commands/agentAttach.js +1440 -0
- package/lib/commands/approvals.js +47 -0
- package/lib/commands/assistant.js +114 -0
- package/lib/commands/auth.js +458 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +373 -0
- package/lib/commands/brain.js +994 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +156 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +240 -0
- package/lib/commands/claude.js +354 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +967 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +74 -0
- package/lib/commands/department.js +75 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +314 -0
- package/lib/commands/devSupervisor.js +1050 -0
- package/lib/commands/discord.js +96 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +277 -0
- package/lib/commands/engagement.js +586 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/file.js +128 -0
- package/lib/commands/fleet.js +882 -0
- package/lib/commands/governance.js +638 -0
- package/lib/commands/gripe.js +324 -0
- package/lib/commands/hat.js +205 -0
- package/lib/commands/helpers.js +79 -0
- package/lib/commands/inbox.js +122 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +280 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +591 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +284 -0
- package/lib/commands/mon.js +514 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +92 -0
- package/lib/commands/objective.js +229 -0
- package/lib/commands/onboard.js +328 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +266 -0
- package/lib/commands/plan.js +906 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +539 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/qlarity.js +690 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +266 -0
- package/lib/commands/quoxseo.js +340 -0
- package/lib/commands/reach.js +158 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/run.js +132 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +543 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +644 -0
- package/lib/commands/slot.js +1198 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +140 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tool.js +216 -0
- package/lib/commands/trace.js +525 -0
- package/lib/commands/trigger.js +128 -0
- package/lib/commands/vault.js +1424 -0
- package/lib/commands/voice.js +182 -0
- package/lib/commands/volt.js +528 -0
- package/lib/commands/watch.js +115 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/workflow.js +503 -0
- package/lib/commands/x.js +439 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/auth.js +350 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/client.js +584 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +551 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +219 -0
- package/lib/core/errors.js +132 -0
- package/lib/core/global-flags.js +90 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/lazyCommands.js +119 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/output.js +239 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +104 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +160 -0
- package/lib/core/slotKeeper.js +282 -0
- package/lib/core/supervisorEngine.js +864 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/websiteSession.js +148 -0
- package/lib/lucid/LucidIntro.jsx +105 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +104 -0
- package/lib/quoxon/api.js +473 -0
- package/lib/quoxon/app.js +1262 -0
- package/lib/quoxon/capabilities.js +75 -0
- package/lib/quoxon/commands.js +154 -0
- package/lib/quoxon/connect.js +467 -0
- package/lib/quoxon/connectCard.js +28 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +33 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +35 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +458 -0
- package/lib/quoxon/gallery.js +223 -0
- package/lib/quoxon/indicator.js +193 -0
- package/lib/quoxon/intent.js +41 -0
- package/lib/quoxon/keys.js +121 -0
- package/lib/quoxon/main.js +244 -0
- package/lib/quoxon/markdown.js +317 -0
- package/lib/quoxon/model.js +205 -0
- package/lib/quoxon/motion/clock.js +69 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +170 -0
- package/lib/quoxon/objects/index.js +85 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +76 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +235 -0
- package/lib/quoxon/onboarding/credential.js +95 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/finish.js +119 -0
- package/lib/quoxon/onboarding/index.js +67 -0
- package/lib/quoxon/onboarding/installRunner.js +225 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +142 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +625 -0
- package/lib/quoxon/print.js +94 -0
- package/lib/quoxon/qart.js +379 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +111 -0
- package/lib/quoxon/render.js +648 -0
- package/lib/quoxon/roster.js +221 -0
- package/lib/quoxon/route.js +38 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/session.js +68 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/ledger.js +83 -0
- package/lib/quoxon/slash/listView.js +28 -0
- package/lib/quoxon/slash/palette.js +104 -0
- package/lib/quoxon/slash/paletteController.js +59 -0
- package/lib/quoxon/slash/panels.js +101 -0
- package/lib/quoxon/slash/picker.js +38 -0
- package/lib/quoxon/slash/restart.js +144 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/settings.js +50 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +119 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/term.js +106 -0
- package/lib/quoxon/textBuffer.js +242 -0
- package/lib/quoxon/theme.js +253 -0
- package/lib/quoxon/transcript.js +150 -0
- package/lib/quoxon/web/qface.js +81 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +727 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- package/package.json +72 -4
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed sessions: run any interactive program (claude, codex, the Quox TUI)
|
|
3
|
+
* inside a tmux session `quox-...` that survives sleep and wifi drops and
|
|
4
|
+
* reattaches with no context reload. argv arrays only; user input never reaches
|
|
5
|
+
* a shell string.
|
|
6
|
+
*
|
|
7
|
+
* Session naming: claude keeps the legacy untagged form `quox-NAME`; other
|
|
8
|
+
* programs carry a tag: `quox-codex-NAME`, `quox-tui-NAME`. `quox sessions ls`
|
|
9
|
+
* derives the PROGRAM column from that tag.
|
|
10
|
+
*/
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync as realSpawnSync } from 'node:child_process';
|
|
15
|
+
import { qArt, colorOn } from './banner.js';
|
|
16
|
+
import { startHeartbeat as realStartHeartbeat } from './sessionHeartbeat.js';
|
|
17
|
+
|
|
18
|
+
const NAME_RE = /^[A-Za-z0-9._-]{1,48}$/;
|
|
19
|
+
export const PREFIX = 'quox-';
|
|
20
|
+
export const HELP_KEY = 'h';
|
|
21
|
+
export const HELP_COMMAND = 'quox sessions help --wait';
|
|
22
|
+
|
|
23
|
+
export const validateName = (n) => typeof n === 'string' && NAME_RE.test(n);
|
|
24
|
+
|
|
25
|
+
export function autoName(now = new Date()) {
|
|
26
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
27
|
+
return `s-${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Program registry. One entry per known CLI; adding the next one is one entry.
|
|
32
|
+
* tag '' means legacy untagged (claude). resumeHint(bare, full) is the plain
|
|
33
|
+
* words command that gets you back after a reboot. Unknown programs (quox wrap)
|
|
34
|
+
* use GENERIC: the session carries the tag `run-<program>-` and the hint is
|
|
35
|
+
* `quox sessions attach FULLNAME`.
|
|
36
|
+
*/
|
|
37
|
+
export const PROGRAMS = {
|
|
38
|
+
claude: { tag: '', bin: 'claude', resumeHint: (n) => `quox claude --resume ${n}` },
|
|
39
|
+
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
|
+
tui: { tag: 'tui-', bin: 'quox', resumeHint: (n) => `quox tui --governed --name ${n}` },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const GENERIC_TAG = 'run-';
|
|
46
|
+
const PROGRAM_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,47}$/;
|
|
47
|
+
export const validateProgram = (p) => typeof p === 'string' && PROGRAM_RE.test(p);
|
|
48
|
+
/** Tag segment for a generic program: no hyphens so the name parses back. */
|
|
49
|
+
export const programSeg = (bin) => String(bin).replace(/-/g, '_');
|
|
50
|
+
|
|
51
|
+
const TAGGED = Object.entries(PROGRAMS).filter(([, v]) => v.tag);
|
|
52
|
+
|
|
53
|
+
/** Resume hint for any program name (registry entry or generic). */
|
|
54
|
+
export function resumeHintFor(program, bare, full) {
|
|
55
|
+
const e = PROGRAMS[program];
|
|
56
|
+
return e ? e.resumeHint(bare) : `quox sessions attach ${full || bare}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Find an executable on PATH without a shell. Returns path or null. */
|
|
60
|
+
export function whichSync(bin, envPath = process.env.PATH || '') {
|
|
61
|
+
if (!validateProgram(bin)) return null;
|
|
62
|
+
for (const dir of String(envPath).split(path.delimiter)) {
|
|
63
|
+
if (!dir) continue;
|
|
64
|
+
const f = path.join(dir, bin);
|
|
65
|
+
try {
|
|
66
|
+
if (!fs.statSync(f).isFile()) continue;
|
|
67
|
+
fs.accessSync(f, fs.constants.X_OK);
|
|
68
|
+
return f;
|
|
69
|
+
} catch { /* keep looking */ }
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Full session name (without the quox- prefix) for a program + user name. */
|
|
75
|
+
export const taggedName = (program, name, bin) =>
|
|
76
|
+
(program === 'run' ? `${GENERIC_TAG}${programSeg(bin)}-` : PROGRAMS[program]?.tag || '') + name;
|
|
77
|
+
|
|
78
|
+
/** Program for a session name (without prefix). Untagged means claude. */
|
|
79
|
+
export function programOf(name) {
|
|
80
|
+
const n = String(name);
|
|
81
|
+
for (const [p, v] of TAGGED) if (n.startsWith(v.tag)) return p;
|
|
82
|
+
const m = /^run-([^-]+)-/.exec(n);
|
|
83
|
+
if (m) return m[1];
|
|
84
|
+
return 'claude';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Name the user chose, with the program tag stripped. */
|
|
88
|
+
export const bareName = (name) => {
|
|
89
|
+
const n = String(name);
|
|
90
|
+
const p = programOf(n);
|
|
91
|
+
if (PROGRAMS[p]) return PROGRAMS[p].tag ? n.slice(PROGRAMS[p].tag.length) : n;
|
|
92
|
+
return n.replace(/^run-[^-]+-/, '');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** argv for the program inside the session. */
|
|
96
|
+
export function programCmd(program, args, bin) {
|
|
97
|
+
if (program === 'run') return [bin, ...args];
|
|
98
|
+
const p = PROGRAMS[program];
|
|
99
|
+
if (program === 'tui') return [p.bin, 'tui', ...args];
|
|
100
|
+
return [p.bin, ...args];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Env vars (QUOX_PREATTACH_*) the SessionStart hook reads to attach/check out a slot. Empty when none requested. */
|
|
104
|
+
export function preattachEnv(pre) {
|
|
105
|
+
const out = {};
|
|
106
|
+
if (pre?.agent) out.QUOX_PREATTACH_AGENT = pre.agent;
|
|
107
|
+
if (pre?.slot) out.QUOX_PREATTACH_SLOT = pre.slot;
|
|
108
|
+
if ((pre?.agent || pre?.slot) && pre.session) out.QUOX_PREATTACH_SESSION = pre.session;
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const tmuxEnvArgs = (penv) => Object.entries(penv).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
|
|
113
|
+
|
|
114
|
+
export function planLaunch({ name, args = [], env = {}, alive = false, resume = false, noTmux = false, program = 'claude', bin, preattach }) {
|
|
115
|
+
const penv = preattachEnv(preattach);
|
|
116
|
+
const tmuxSession = PREFIX + taggedName(program, name, bin);
|
|
117
|
+
const cmdArgv = programCmd(program, args, bin);
|
|
118
|
+
if (env.TMUX || noTmux) {
|
|
119
|
+
return { mode: resume ? 'RESUME' : 'NEW', tmuxSession, cmd: cmdArgv, ...(Object.keys(penv).length ? { env: penv } : {}) };
|
|
120
|
+
}
|
|
121
|
+
if (alive) {
|
|
122
|
+
return { mode: 'REATTACH', tmuxSession, cmd: ['tmux', 'attach-session', '-t', tmuxSession] };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
mode: resume ? 'RESUME' : 'NEW',
|
|
126
|
+
tmuxSession,
|
|
127
|
+
cmd: ['tmux', 'new-session', ...tmuxEnvArgs(penv), '-s', tmuxSession, '--', ...cmdArgv],
|
|
128
|
+
...(Object.keys(penv).length ? { env: penv } : {}),
|
|
129
|
+
...(resume ? { note: 'A resume reloads the conversation context (costs tokens). Reattach is free.' } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseSessions(out) {
|
|
134
|
+
return String(out || '')
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map((l) => l.split('\t'))
|
|
137
|
+
.filter((c) => c.length >= 4 && c[0].startsWith(PREFIX))
|
|
138
|
+
.map((c) => {
|
|
139
|
+
const name = c[0].slice(PREFIX.length);
|
|
140
|
+
return {
|
|
141
|
+
name,
|
|
142
|
+
program: programOf(name),
|
|
143
|
+
created: new Date(Number(c[1]) * 1000),
|
|
144
|
+
attached: Number(c[2]) > 0,
|
|
145
|
+
windows: Number(c[3]),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The plain-words cheat sheet, shared by the intro and the Ctrl-b h popup. */
|
|
151
|
+
export function howItWorksLines({ name, program = 'claude', bare }) {
|
|
152
|
+
const b = bare || bareName(name);
|
|
153
|
+
const full = name;
|
|
154
|
+
return [
|
|
155
|
+
['Leave it running:', 'press Ctrl-b, then d'],
|
|
156
|
+
['Come back later:', `quox sessions attach ${name}`],
|
|
157
|
+
['See your sessions:', 'quox sessions ls'],
|
|
158
|
+
['Scroll back:', 'press Ctrl-b, then [ (q to stop)'],
|
|
159
|
+
['After a reboot:', resumeHintFor(program, b, full)],
|
|
160
|
+
['End it:', `quox sessions kill ${name}`],
|
|
161
|
+
['Help any time:', 'press Ctrl-b, then h'],
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderIntro({ name, host, mode, tmuxSession, note, program = 'claude', preattach }, { color = false } = {}) {
|
|
166
|
+
const b = (s) => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
167
|
+
const d = (s) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
168
|
+
const art = qArt(color);
|
|
169
|
+
const modeText = {
|
|
170
|
+
NEW: 'NEW',
|
|
171
|
+
REATTACH: 'REATTACH (same process, no context reload)',
|
|
172
|
+
RESUME: 'RESUME',
|
|
173
|
+
}[mode] || mode;
|
|
174
|
+
const text = [
|
|
175
|
+
'',
|
|
176
|
+
b('QUOX GOVERNED SESSION'),
|
|
177
|
+
'',
|
|
178
|
+
`${d('session ')} ${name}`,
|
|
179
|
+
`${d('program ')} ${program}`,
|
|
180
|
+
`${d('host ')} ${host}`,
|
|
181
|
+
`${d('mode ')} ${modeText}`,
|
|
182
|
+
`${d('tmux session')} ${tmuxSession}`,
|
|
183
|
+
'',
|
|
184
|
+
b('How this works'),
|
|
185
|
+
...howItWorksLines({ name, program }).map(([k, v]) => `${d(k.padEnd(19))}${v}`),
|
|
186
|
+
];
|
|
187
|
+
const w = art[0] ? 14 : 0;
|
|
188
|
+
const lines = [];
|
|
189
|
+
for (let i = 0; i < Math.max(art.length, text.length); i++) {
|
|
190
|
+
lines.push(` ${art[i] || ' '.repeat(w)} ${text[i] || ''}`);
|
|
191
|
+
}
|
|
192
|
+
let out = '\n' + lines.join('\n') + '\n';
|
|
193
|
+
if (note) out += ` ${d(note)}\n`;
|
|
194
|
+
if (mode !== 'REATTACH' && (preattach?.agent || preattach?.slot)) {
|
|
195
|
+
const { agent, slot } = preattach;
|
|
196
|
+
const what = agent ? `Attaching as ${agent}${slot ? ` \u00b7 slot ${slot}` : ''}` : `Holding slot ${slot}`;
|
|
197
|
+
out += ` ${b(`${what} at session start`)}\n`;
|
|
198
|
+
if (slot) out += ` ${d(`Talk to it from Discord in #${slot}`)}\n`;
|
|
199
|
+
}
|
|
200
|
+
return out + '\n';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Plain text for `quox sessions help` (no colour, no art). */
|
|
204
|
+
export function renderHelp({ name, program } = {}) {
|
|
205
|
+
const n = name || '<name>';
|
|
206
|
+
const p = program || (name ? programOf(name) : 'claude');
|
|
207
|
+
const lines = howItWorksLines({ name: n, program: p, bare: name ? bareName(name) : '<name>' });
|
|
208
|
+
return ['QUOX GOVERNED SESSION: How this works', '', ...lines.map(([k, v]) => `${k.padEnd(19)}${v}`), ''].join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function defaults(over = {}) {
|
|
212
|
+
return {
|
|
213
|
+
spawnSync: realSpawnSync,
|
|
214
|
+
env: process.env,
|
|
215
|
+
hostname: () => os.hostname(),
|
|
216
|
+
now: () => new Date(),
|
|
217
|
+
color: colorOn(),
|
|
218
|
+
write: (s) => process.stdout.write(s),
|
|
219
|
+
// QUOXON J1: register + heartbeat each tmux session the wrapper starts. An
|
|
220
|
+
// injected spawnSync (tests) means no real child unless startHeartbeat is injected too.
|
|
221
|
+
startHeartbeat: over.spawnSync ? () => {} : realStartHeartbeat,
|
|
222
|
+
...over,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const tmuxMissing = (r) => r && r.error && r.error.code === 'ENOENT';
|
|
227
|
+
|
|
228
|
+
/** tmux -V output to [major, minor], or null if unknown. */
|
|
229
|
+
export function parseTmuxVersion(out) {
|
|
230
|
+
const m = /(\d+)\.(\d+)/.exec(String(out || ''));
|
|
231
|
+
return m ? [Number(m[1]), Number(m[2])] : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const supportsPopup = (v) => !!v && (v[0] > 3 || (v[0] === 3 && v[1] >= 2));
|
|
235
|
+
|
|
236
|
+
/** argv (after `tmux`) that binds Ctrl-b h. Contains no user data. */
|
|
237
|
+
export function helpBindArgv(version) {
|
|
238
|
+
if (supportsPopup(version)) {
|
|
239
|
+
return ['bind-key', HELP_KEY, 'display-popup', '-E', '-w', '80%', '-h', '60%', HELP_COMMAND];
|
|
240
|
+
}
|
|
241
|
+
return ['bind-key', HELP_KEY, 'display-message',
|
|
242
|
+
'Quox: Ctrl-b d leave it running | Ctrl-b [ scroll back | quox sessions ls | quox sessions help'];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Bind Ctrl-b h idempotently (bind-key is tmux-server wide). Returns
|
|
247
|
+
* 'cold' when no tmux server is running yet (nothing to bind into),
|
|
248
|
+
* 'present' when already bound to the quox help, 'bound' otherwise, 'failed'.
|
|
249
|
+
*/
|
|
250
|
+
export function ensureHelpBinding(d) {
|
|
251
|
+
const keys = d.spawnSync('tmux', ['list-keys', '-T', 'prefix'], { encoding: 'utf8' });
|
|
252
|
+
if (!keys || keys.error || keys.status !== 0) return 'cold';
|
|
253
|
+
if (String(keys.stdout || '').includes('quox sessions help')) return 'present';
|
|
254
|
+
const v = d.spawnSync('tmux', ['-V'], { encoding: 'utf8' });
|
|
255
|
+
const r = d.spawnSync('tmux', helpBindArgv(parseTmuxVersion(v && v.stdout)), { stdio: 'ignore' });
|
|
256
|
+
return r && r.status === 0 ? 'bound' : 'failed';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function runGoverned({ program = 'claude', name, args = [], resume = false, noTmux = false, bin, preattach }, over) {
|
|
260
|
+
const d = defaults(over);
|
|
261
|
+
const n = name || autoName(d.now());
|
|
262
|
+
if (program === 'run') {
|
|
263
|
+
if (!validateProgram(bin)) {
|
|
264
|
+
d.write(`Refusing program "${bin}": use a plain command name (letters, digits, dot, underscore, plus, hyphen), no paths or shell characters.\n`);
|
|
265
|
+
return 2;
|
|
266
|
+
}
|
|
267
|
+
const found = (d.which || whichSync)(bin, d.env.PATH);
|
|
268
|
+
if (!found) {
|
|
269
|
+
d.write(`${bin} was not found on PATH.\n`);
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const full = taggedName(program, n, bin);
|
|
274
|
+
if (!validateName(n) || !validateName(full)) {
|
|
275
|
+
d.write(`Invalid session name "${n}". Use 1-48 characters: letters, digits, dot, underscore, hyphen.\n`);
|
|
276
|
+
return 2;
|
|
277
|
+
}
|
|
278
|
+
let alive = false;
|
|
279
|
+
if (!noTmux && !d.env.TMUX) {
|
|
280
|
+
const r = d.spawnSync('tmux', ['has-session', '-t', '=' + PREFIX + full], { stdio: 'ignore' });
|
|
281
|
+
if (tmuxMissing(r)) {
|
|
282
|
+
d.write(`tmux is not installed. Install it (apt install tmux / brew install tmux), or run without it: quox ${program === 'tui' ? 'tui --governed' : program === 'run' ? `wrap ${bin}` : program} --no-tmux\n`);
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
alive = r.status === 0;
|
|
286
|
+
}
|
|
287
|
+
const plan = planLaunch({ name: n, args, env: d.env, alive, resume, noTmux, program, bin, preattach });
|
|
288
|
+
const shown = program === 'run' ? bin : program;
|
|
289
|
+
d.write(renderIntro({ name: full, host: d.hostname(), mode: plan.mode, tmuxSession: plan.tmuxSession, note: plan.note, program: shown, preattach }, { color: d.color }));
|
|
290
|
+
let cmd = plan.cmd;
|
|
291
|
+
const viaTmux = cmd[0] === 'tmux';
|
|
292
|
+
if (viaTmux) {
|
|
293
|
+
const state = ensureHelpBinding(d);
|
|
294
|
+
if (state === 'cold' && plan.mode !== 'REATTACH') {
|
|
295
|
+
// No tmux server yet: create the session detached so a server exists,
|
|
296
|
+
// bind the help key into it, then attach.
|
|
297
|
+
const created = d.spawnSync('tmux', ['new-session', '-d', ...tmuxEnvArgs(preattachEnv(preattach)), '-s', plan.tmuxSession, '--', ...programCmd(program, args, bin)], { stdio: 'ignore' });
|
|
298
|
+
if (created && created.status !== 0) {
|
|
299
|
+
d.write('Could not start the tmux session.\n');
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
ensureHelpBinding(d);
|
|
303
|
+
cmd = ['tmux', 'attach-session', '-t', plan.tmuxSession];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (viaTmux) {
|
|
307
|
+
try {
|
|
308
|
+
d.startHeartbeat({
|
|
309
|
+
tmuxSession: plan.tmuxSession, backend: program === 'run' ? bin : program,
|
|
310
|
+
agent: (preattach && preattach.agent) || null, slot: (preattach && preattach.slot) || null,
|
|
311
|
+
});
|
|
312
|
+
} catch { /* the index is best effort; never block the launch */ }
|
|
313
|
+
}
|
|
314
|
+
const [exe, ...exeArgs] = cmd;
|
|
315
|
+
// Direct run (--no-tmux, or already inside tmux): the env rides the spawn.
|
|
316
|
+
const r = d.spawnSync(exe, exeArgs, { stdio: 'inherit', ...(exe !== 'tmux' && plan.env ? { env: { ...d.env, ...plan.env } } : {}) });
|
|
317
|
+
if (r && r.error) {
|
|
318
|
+
d.write(`Could not run ${exe}: ${r.error.message || r.error.code}\n`);
|
|
319
|
+
return 1;
|
|
320
|
+
}
|
|
321
|
+
return r && typeof r.status === 'number' ? r.status : 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function listSessions(d) {
|
|
325
|
+
const r = d.spawnSync('tmux', ['list-sessions', '-F', '#{session_name}\t#{session_created}\t#{session_attached}\t#{session_windows}'], { encoding: 'utf8' });
|
|
326
|
+
if (tmuxMissing(r)) return null;
|
|
327
|
+
return r.status === 0 ? parseSessions(r.stdout) : [];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function runLs(over) {
|
|
331
|
+
const d = defaults(over);
|
|
332
|
+
const s = listSessions(d);
|
|
333
|
+
if (s === null) {
|
|
334
|
+
d.write('tmux is not installed (apt install tmux / brew install tmux).\n');
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
if (!s.length) {
|
|
338
|
+
d.write('No Quox sessions running. Start one with: quox claude (or quox codex, quox gemini, quox wrap <program>)\n');
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
const pad = (v, n) => String(v).padEnd(n);
|
|
342
|
+
d.write(`${pad('NAME', 24)}${pad('PROGRAM', 12)}${pad('CREATED', 18)}${pad('ATTACHED', 10)}WINDOWS\n`);
|
|
343
|
+
for (const x of s) {
|
|
344
|
+
const c = x.created.toISOString().slice(0, 16).replace('T', ' ');
|
|
345
|
+
d.write(`${pad(x.name, 24)}${pad(x.program, 12)}${pad(c, 18)}${pad(x.attached ? 'yes' : 'no', 10)}${x.windows}\n`);
|
|
346
|
+
}
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function runKill(name, over) {
|
|
351
|
+
const d = defaults(over);
|
|
352
|
+
if (!validateName(name)) {
|
|
353
|
+
d.write(`Invalid session name "${name}".\n`);
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
const has = d.spawnSync('tmux', ['has-session', '-t', '=' + PREFIX + name], { stdio: 'ignore' });
|
|
357
|
+
if (has.status !== 0) {
|
|
358
|
+
d.write(`No such session: ${name}. See: quox sessions ls\n`);
|
|
359
|
+
return 1;
|
|
360
|
+
}
|
|
361
|
+
const r = d.spawnSync('tmux', ['kill-session', '-t', PREFIX + name], { stdio: 'ignore' });
|
|
362
|
+
d.write(r.status === 0 ? `Killed ${name}.\n` : `Failed to kill ${name}.\n`);
|
|
363
|
+
return r.status === 0 ? 0 : 1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function runAttach(name, over) {
|
|
367
|
+
const d = defaults(over);
|
|
368
|
+
if (!validateName(name)) {
|
|
369
|
+
d.write(`Invalid session name "${name}".\n`);
|
|
370
|
+
return 2;
|
|
371
|
+
}
|
|
372
|
+
const has = d.spawnSync('tmux', ['has-session', '-t', '=' + PREFIX + name], { stdio: 'ignore' });
|
|
373
|
+
if (tmuxMissing(has)) {
|
|
374
|
+
d.write('tmux is not installed (apt install tmux / brew install tmux).\n');
|
|
375
|
+
return 1;
|
|
376
|
+
}
|
|
377
|
+
if (has.status !== 0) {
|
|
378
|
+
d.write(`No such session: ${name}. See: quox sessions ls\n`);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
const verb = d.env.TMUX ? 'switch-client' : 'attach-session';
|
|
382
|
+
const r = d.spawnSync('tmux', [verb, '-t', PREFIX + name], { stdio: 'inherit' });
|
|
383
|
+
return r && typeof r.status === 'number' ? r.status : 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** `quox sessions help`: print the cheat sheet for the current (or given) session. */
|
|
387
|
+
export function runHelp({ wait = false } = {}, over) {
|
|
388
|
+
const d = defaults(over);
|
|
389
|
+
let name;
|
|
390
|
+
if (d.env.TMUX) {
|
|
391
|
+
const r = d.spawnSync('tmux', ['display-message', '-p', '#{session_name}'], { encoding: 'utf8' });
|
|
392
|
+
const s = r && r.status === 0 ? String(r.stdout || '').trim() : '';
|
|
393
|
+
if (s.startsWith(PREFIX) && validateName(s.slice(PREFIX.length))) name = s.slice(PREFIX.length);
|
|
394
|
+
}
|
|
395
|
+
d.write(renderHelp({ name }) + '\n');
|
|
396
|
+
if (wait) {
|
|
397
|
+
d.write('Press any key to close.\n');
|
|
398
|
+
if (d.waitKey) d.waitKey();
|
|
399
|
+
else if (process.stdin.isTTY) d.spawnSync('sh', ['-c', 'stty raw -echo; dd bs=1 count=1 >/dev/null 2>&1; stty sane'], { stdio: 'inherit' });
|
|
400
|
+
}
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy command-group registration (CORTEX-BENCH, CLI startup latency).
|
|
3
|
+
*
|
|
4
|
+
* Importing every command module cost ~1s on every `quox` invocation. Here a
|
|
5
|
+
* cached map (top-level command name -> registrar indexes) lets a normal
|
|
6
|
+
* invocation import only the module(s) that define or extend its command (a
|
|
7
|
+
* group can be extended by several modules, e.g. `agent` by agent.js and
|
|
8
|
+
* agentAttach.js). Correctness rules:
|
|
9
|
+
* - no map, stale map, unknown command, help/completion/bare invocation,
|
|
10
|
+
* or ANY error in the lazy path -> full load (the old behaviour);
|
|
11
|
+
* - after a lazy load, the command must actually exist, else full load;
|
|
12
|
+
* - every full load rebuilds the map, so it self-heals.
|
|
13
|
+
* A wrong map can therefore only make a call slower, never break it.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const MAP_VERSION = 1;
|
|
21
|
+
// Global options that consume the next argv token (so it is not the command).
|
|
22
|
+
const VALUE_OPTS = new Set(['-o', '--output', '--org', '-p', '--profile']);
|
|
23
|
+
const FULL_LOAD_TOKENS = new Set(['help', 'completion', 'tui']);
|
|
24
|
+
|
|
25
|
+
/** First argv token that names a command, or null (help/bare/unknown shape). */
|
|
26
|
+
export function commandToken(argv) {
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a === '--') return null;
|
|
30
|
+
if (a === '-h' || a === '--help' || a === '-V' || a === '--version') return null;
|
|
31
|
+
if (a.startsWith('-')) {
|
|
32
|
+
if (VALUE_OPTS.has(a)) i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
return a;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mapPath() {
|
|
41
|
+
const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
|
|
42
|
+
return join(base, 'quox', 'cli-command-map.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Cache key: the command modules' file set and mtimes, plus the registrar list. */
|
|
46
|
+
export function mapKey(registrars, commandsDir) {
|
|
47
|
+
let sig = registrars.map(([p, n]) => `${p}#${n}`).join('|');
|
|
48
|
+
try {
|
|
49
|
+
for (const f of readdirSync(commandsDir).sort()) {
|
|
50
|
+
if (!f.endsWith('.js')) continue;
|
|
51
|
+
sig += `|${f}:${statSync(join(commandsDir, f)).mtimeMs}`;
|
|
52
|
+
}
|
|
53
|
+
} catch { return null; }
|
|
54
|
+
let h = 5381;
|
|
55
|
+
for (let i = 0; i < sig.length; i++) h = ((h << 5) + h + sig.charCodeAt(i)) | 0;
|
|
56
|
+
return `v${MAP_VERSION}:${h}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Size of a command subtree (every nested command and option), so a module
|
|
60
|
+
// that only extends a DEEP subcommand of an existing group is still recorded
|
|
61
|
+
// as touching that top-level group.
|
|
62
|
+
function subtreeSize(cmd) {
|
|
63
|
+
return 1 + cmd.options.length + cmd.commands.reduce((n, c) => n + subtreeSize(c), 0);
|
|
64
|
+
}
|
|
65
|
+
function topLevelSnapshot(program) {
|
|
66
|
+
return new Map(program.commands.map((c) => [c.name(), subtreeSize(c)]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fullLoad(program, ctx, registrars, baseUrl, key) {
|
|
70
|
+
const map = {};
|
|
71
|
+
for (let i = 0; i < registrars.length; i++) {
|
|
72
|
+
const [path, name] = registrars[i];
|
|
73
|
+
const before = topLevelSnapshot(program);
|
|
74
|
+
const mod = await import(new URL(path, baseUrl).href);
|
|
75
|
+
mod[name](program, ctx);
|
|
76
|
+
for (const [cmd, size] of topLevelSnapshot(program)) {
|
|
77
|
+
if (before.get(cmd) !== size) (map[cmd] ||= []).push(i);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (key) {
|
|
81
|
+
try {
|
|
82
|
+
const file = mapPath();
|
|
83
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
84
|
+
writeFileSync(file, JSON.stringify({ key, map }));
|
|
85
|
+
} catch { /* the cache is an optimisation only */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register command groups lazily when safe, fully otherwise.
|
|
91
|
+
* @param {import('commander').Command} program
|
|
92
|
+
* @param {object} ctx
|
|
93
|
+
* @param {Array<[string, string]>} registrars [modulePath relative to baseUrl, export name], in registration order
|
|
94
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
95
|
+
* @param {string} baseUrl import.meta.url of the caller
|
|
96
|
+
*/
|
|
97
|
+
export async function registerCommandGroups(program, ctx, registrars, argv, baseUrl) {
|
|
98
|
+
const commandsDir = join(dirname(fileURLToPath(baseUrl)), '..', 'lib', 'commands');
|
|
99
|
+
const key = process.env.QUOX_CLI_EAGER === '1' ? null : mapKey(registrars, commandsDir);
|
|
100
|
+
const token = commandToken(argv);
|
|
101
|
+
if (key && token && !FULL_LOAD_TOKENS.has(token)) {
|
|
102
|
+
try {
|
|
103
|
+
const cached = JSON.parse(readFileSync(mapPath(), 'utf-8'));
|
|
104
|
+
const idx = cached && cached.key === key ? cached.map[token] : null;
|
|
105
|
+
if (Array.isArray(idx) && idx.length > 0) {
|
|
106
|
+
for (const i of idx) {
|
|
107
|
+
const [path, name] = registrars[i];
|
|
108
|
+
const mod = await import(new URL(path, baseUrl).href);
|
|
109
|
+
mod[name](program, ctx);
|
|
110
|
+
}
|
|
111
|
+
if (program.commands.some((c) => c.name() === token || c.aliases().includes(token))) return 'lazy';
|
|
112
|
+
}
|
|
113
|
+
} catch { /* fall through to a full load */ }
|
|
114
|
+
// Partial registration (if any) must not double-register: start clean.
|
|
115
|
+
program.commands.splice(0, program.commands.length);
|
|
116
|
+
}
|
|
117
|
+
await fullLoad(program, ctx, registrars, baseUrl, key);
|
|
118
|
+
return 'full';
|
|
119
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEVSUITE phase 19: gather this host's tmux sessions into the payload for
|
|
3
|
+
* POST /api/v1/live-sessions/report. Pure parse/map functions take injected
|
|
4
|
+
* tmux output and held-slots JSON so they unit-test without tmux.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { homedir, hostname } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
export const TMUX_FORMAT = '#{session_name}\t#{session_created}\t#{session_attached}\t#{pane_current_command}\t#{pane_pid}\t#{session_activity}';
|
|
12
|
+
|
|
13
|
+
const AGENT_PROGRAMS = new Set(['claude', 'codex', 'gemini']);
|
|
14
|
+
|
|
15
|
+
/** Parse `tmux list-panes -a -F TMUX_FORMAT` output; one entry per session (first pane wins). */
|
|
16
|
+
export function parseTmuxPanes(out) {
|
|
17
|
+
const seen = new Map();
|
|
18
|
+
for (const line of String(out || '').split('\n')) {
|
|
19
|
+
const c = line.split('\t');
|
|
20
|
+
if (c.length < 5 || !c[0]) continue;
|
|
21
|
+
if (seen.has(c[0])) continue;
|
|
22
|
+
const created = Number(c[1]);
|
|
23
|
+
const pid = Number.parseInt(c[4], 10);
|
|
24
|
+
const activity = Number(c[5]);
|
|
25
|
+
seen.set(c[0], {
|
|
26
|
+
name: c[0],
|
|
27
|
+
tmux: true,
|
|
28
|
+
attached: Number(c[2]) > 0,
|
|
29
|
+
program: c[3] || null,
|
|
30
|
+
pid: Number.isInteger(pid) ? pid : null,
|
|
31
|
+
started_at: Number.isFinite(created) && created > 0 ? new Date(created * 1000).toISOString() : null,
|
|
32
|
+
last_active: Number.isFinite(activity) && activity > 0 ? new Date(activity * 1000).toISOString() : null,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return [...seen.values()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** held-slots.json is { slotName: { session, pid, ... } }; return { holderSession: { slot, pid } }. */
|
|
39
|
+
export function slotsBySession(held) {
|
|
40
|
+
const map = {};
|
|
41
|
+
if (!held || typeof held !== 'object') return map;
|
|
42
|
+
for (const [slot, e] of Object.entries(held)) {
|
|
43
|
+
if (e && typeof e.session === 'string' && e.session && !(e.session in map)) {
|
|
44
|
+
map[e.session] = { slot, pid: Number.isInteger(e.pid) ? e.pid : null };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Attach kits live at ~/.quox/attach/sessions/claude-<pid>/<agentId>/, where
|
|
52
|
+
* <pid> is the claude process the slot entry records. Read ONLY the agent dir
|
|
53
|
+
* name and the plain `splinter` label file (e.g. "S2 1"); never the token.
|
|
54
|
+
*/
|
|
55
|
+
export function readAttachSafe(pid, base = join(homedir(), '.quox', 'attach', 'sessions')) {
|
|
56
|
+
if (!Number.isInteger(pid)) return null;
|
|
57
|
+
try {
|
|
58
|
+
const dir = join(base, `claude-${pid}`);
|
|
59
|
+
if (!existsSync(dir)) return null;
|
|
60
|
+
const agent = readdirSync(dir).find((d) => /^[a-z0-9_-]{1,40}$/.test(d));
|
|
61
|
+
if (!agent) return null;
|
|
62
|
+
let splinter = null;
|
|
63
|
+
const sp = join(dir, agent, 'splinter');
|
|
64
|
+
if (existsSync(sp)) {
|
|
65
|
+
const m = /^(S\d{1,3})\b/.exec(readFileSync(sp, 'utf-8').trim());
|
|
66
|
+
if (m) splinter = m[1];
|
|
67
|
+
}
|
|
68
|
+
return { agent, splinter };
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildReport({ tmuxOut, held, env = {}, host, attachLookup = readAttachSafe }) {
|
|
75
|
+
const bySession = slotsBySession(held);
|
|
76
|
+
const sessions = parseTmuxPanes(tmuxOut).slice(0, 50).map((s) => {
|
|
77
|
+
// `quox claude --resume NAME` runs NAME in tmux session quox-NAME, while
|
|
78
|
+
// the slot entry records the bare NAME.
|
|
79
|
+
const bare = s.name.replace(/^quox-/, '');
|
|
80
|
+
const hit = bySession[s.name] || bySession[bare] || null;
|
|
81
|
+
const { last_active: lastActive, ...rest } = s;
|
|
82
|
+
const out = { ...rest, slot: hit ? hit.slot : null };
|
|
83
|
+
const att = hit ? attachLookup(hit.pid) : null;
|
|
84
|
+
if (att) {
|
|
85
|
+
out.agent = att.agent;
|
|
86
|
+
if (att.splinter) out.splinter = att.splinter;
|
|
87
|
+
}
|
|
88
|
+
// No stream from env: QUOX_STREAM goes stale in inherited shells (house
|
|
89
|
+
// rule, and it mislabelled VULCAN as QUOX-SECURITY on the first live run).
|
|
90
|
+
// The column stays for a reliable source later (slot or attach metadata).
|
|
91
|
+
// last_active (tmux session_activity) feeds the J1 index's working/resting state.
|
|
92
|
+
out.meta = { agent_program: AGENT_PROGRAMS.has(s.program), ...(lastActive ? { last_active: lastActive } : {}) };
|
|
93
|
+
return out;
|
|
94
|
+
});
|
|
95
|
+
return { host: host || hostname(), sessions };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readHeldSlotsSafe(path = join(homedir(), '.quox', 'held-slots.json')) {
|
|
99
|
+
try {
|
|
100
|
+
if (!existsSync(path)) return {};
|
|
101
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Live gather. Returns null when tmux is missing; an empty server yields an empty list. */
|
|
108
|
+
export function gatherReport({ spawn = spawnSync, env = process.env, held = readHeldSlotsSafe() } = {}) {
|
|
109
|
+
const r = spawn('tmux', ['list-panes', '-a', '-F', TMUX_FORMAT], { encoding: 'utf8' });
|
|
110
|
+
if (r.error) return null;
|
|
111
|
+
return buildReport({ tmuxOut: r.status === 0 ? r.stdout : '', held, env });
|
|
112
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* org-override.js — what `--org` is allowed to mean (GRIPE-709).
|
|
3
|
+
*
|
|
4
|
+
* A user access token is bound to ONE org (its org_id claim). `--org` used to
|
|
5
|
+
* be read only by verbs that build /api/orgs/<id>/... paths; every other verb
|
|
6
|
+
* (notes, whoami, ...) ignored it and answered from the session org, so
|
|
7
|
+
* `quox --org B notes ls` silently returned org A's data. The server offers no
|
|
8
|
+
* per-request org header, only a governed switch (`quox org switch`), so the
|
|
9
|
+
* safe contract is:
|
|
10
|
+
*
|
|
11
|
+
* - flag absent -> fine
|
|
12
|
+
* - flag names the session org -> fine (id, slug or name)
|
|
13
|
+
* - flag names ANY OTHER org while a
|
|
14
|
+
* user session is active -> REFUSE, never fall back
|
|
15
|
+
* - service-key-only invocation -> allowed (no session org to disagree
|
|
16
|
+
* with; the key's verbs take the org explicitly)
|
|
17
|
+
*/
|
|
18
|
+
import { QuoxValidationError } from './errors.js';
|
|
19
|
+
|
|
20
|
+
// Commands where --org legitimately means something other than "act in".
|
|
21
|
+
const EXEMPT = new Set(['login', 'logout', 'signup', 'org switch', 'invites list', 'invites accept']);
|
|
22
|
+
|
|
23
|
+
export function commandPath(cmd) {
|
|
24
|
+
const parts = [];
|
|
25
|
+
for (let c = cmd; c && c.parent; c = c.parent) parts.unshift(c.name());
|
|
26
|
+
return parts.join(' ');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} args
|
|
31
|
+
* @param {string|undefined} args.override value of --org
|
|
32
|
+
* @param {object|null} args.authData stored auth (org_id/org_slug/org_name)
|
|
33
|
+
* @param {boolean} args.hasUserToken
|
|
34
|
+
* @param {string} [args.command] e.g. 'notes ls'
|
|
35
|
+
* @throws {QuoxValidationError} when the override differs from the session org
|
|
36
|
+
*/
|
|
37
|
+
export function assertOrgOverrideSafe({ override, authData, hasUserToken, command = '' }) {
|
|
38
|
+
if (!override || !hasUserToken || EXEMPT.has(command)) return;
|
|
39
|
+
const want = String(override).toLowerCase();
|
|
40
|
+
const sid = authData?.org_id || null;
|
|
41
|
+
const known = [sid, authData?.org_slug, authData?.org_name]
|
|
42
|
+
.filter(Boolean).map(v => String(v).toLowerCase());
|
|
43
|
+
if (known.includes(want)) return;
|
|
44
|
+
const current = sid
|
|
45
|
+
? `${authData.org_slug || authData.org_name || sid}${authData.org_slug || authData.org_name ? ` (${sid})` : ''}`
|
|
46
|
+
: 'none';
|
|
47
|
+
throw new QuoxValidationError(
|
|
48
|
+
`--org ${override} differs from your session org ${current}; `
|
|
49
|
+
+ `run \`quox org switch ${override}\` or use a profile for that org. `
|
|
50
|
+
+ 'Refusing to act in a different org than the one named.'
|
|
51
|
+
);
|
|
52
|
+
}
|