quoxcli 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/README.md +144 -2
- package/bin/quox.js +265 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +151 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +63 -0
- package/lib/api/endpoints/assistants.js +62 -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/away.js +15 -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 +61 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +68 -0
- package/lib/api/endpoints/conversations.js +46 -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 +45 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +40 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +66 -0
- package/lib/api/endpoints/inbox.js +52 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +242 -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 +94 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +184 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +68 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +152 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/presence.js +33 -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 +121 -0
- package/lib/api/endpoints/quoxseo.js +114 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +53 -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 +200 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/stepup.js +20 -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 +96 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +585 -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 +47 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +169 -0
- package/lib/api/endpoints/x.js +32 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +375 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +337 -0
- package/lib/commands/agentAttach.js +1528 -0
- package/lib/commands/approvals.js +119 -0
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +169 -0
- package/lib/commands/auth.js +464 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +377 -0
- package/lib/commands/brain.js +1085 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +170 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +295 -0
- package/lib/commands/claude.js +380 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +1016 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +105 -0
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +96 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +326 -0
- package/lib/commands/devSupervisor.js +1062 -0
- package/lib/commands/discord.js +132 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +347 -0
- package/lib/commands/engagement.js +597 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +165 -0
- package/lib/commands/fleet.js +1189 -0
- package/lib/commands/governance.js +647 -0
- package/lib/commands/gripe.js +355 -0
- package/lib/commands/hat.js +374 -0
- package/lib/commands/helpers.js +96 -0
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +173 -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 +139 -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 +597 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +361 -0
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +581 -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 +123 -0
- package/lib/commands/objective.js +240 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +329 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +276 -0
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +944 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +649 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +706 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +275 -0
- package/lib/commands/quoxseo.js +378 -0
- package/lib/commands/reach.js +159 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +187 -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/server.js +292 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +569 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +687 -0
- package/lib/commands/slot.js +1451 -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 +142 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +304 -0
- package/lib/commands/trace.js +530 -0
- package/lib/commands/trigger.js +223 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +1447 -0
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +213 -0
- package/lib/commands/volt.js +570 -0
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +167 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +660 -0
- package/lib/commands/x.js +448 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/activeSummary.js +186 -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/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +501 -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/cliBuild.js +62 -0
- package/lib/core/client.js +693 -0
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +669 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/customHats.js +168 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +227 -0
- package/lib/core/errors.js +441 -0
- package/lib/core/global-flags.js +190 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +122 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +239 -0
- package/lib/core/peek.js +126 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/repoPath.js +11 -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 +106 -0
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +224 -0
- package/lib/core/slotKeeper.js +527 -0
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +933 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +92 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +116 -0
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +952 -0
- package/lib/quoxon/app.js +4032 -0
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +82 -0
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +479 -0
- package/lib/quoxon/connect.js +654 -0
- package/lib/quoxon/connectCard.js +45 -0
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +34 -0
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +76 -0
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +677 -0
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +237 -0
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +197 -0
- package/lib/quoxon/intent.js +52 -0
- package/lib/quoxon/keys.js +164 -0
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +176 -0
- package/lib/quoxon/markdown.js +341 -0
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +311 -0
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +70 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +182 -0
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +106 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +87 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +263 -0
- package/lib/quoxon/onboarding/credential.js +108 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +134 -0
- package/lib/quoxon/onboarding/index.js +94 -0
- package/lib/quoxon/onboarding/installRunner.js +284 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +156 -0
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +682 -0
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +98 -0
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +348 -0
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +132 -0
- package/lib/quoxon/render.js +1167 -0
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +307 -0
- package/lib/quoxon/route.js +39 -0
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +238 -0
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +88 -0
- package/lib/quoxon/slash/listView.js +30 -0
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +118 -0
- package/lib/quoxon/slash/paletteController.js +169 -0
- package/lib/quoxon/slash/panels.js +116 -0
- package/lib/quoxon/slash/picker.js +56 -0
- package/lib/quoxon/slash/restart.js +202 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +60 -0
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +120 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +141 -0
- package/lib/quoxon/textBuffer.js +416 -0
- package/lib/quoxon/theme.js +286 -0
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +188 -0
- package/lib/quoxon/web/qface.js +89 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/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/HyperionDeck.jsx +79 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +719 -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 +75 -4
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quox dev supervisor`, DEVSUITE-DEVMODE (quox#453 phase 15 follow-on).
|
|
3
|
+
*
|
|
4
|
+
* The headless runner that lets a dev host's resident agent answer its slot
|
|
5
|
+
* channel with no interactive session open. All of the actual picking,
|
|
6
|
+
* rate-limiting, prompt-building, turn-execution and state logic lives in
|
|
7
|
+
* `../core/supervisorEngine.js` and is unit tested there; this file is the
|
|
8
|
+
* thin wiring: resolve defaults from the resident bootstrap profile, build
|
|
9
|
+
* the injected fetch/post/spawn closures against the CLI's existing
|
|
10
|
+
* endpoint-resolution and client patterns, and drive the poll loop.
|
|
11
|
+
*
|
|
12
|
+
* Delivery: this needs no new delivery channel because the CLI is already
|
|
13
|
+
* on every dev-enabled host (/opt/quoxcli + /usr/local/bin/quox), and it
|
|
14
|
+
* already holds the resident's auth (see lib/core/residentAuth.js).
|
|
15
|
+
*
|
|
16
|
+
* A SEPARATE stream owns the server-side switch (writes to
|
|
17
|
+
* /api/v1/dev-servers/:hostId/supervisor). This module is read-only against
|
|
18
|
+
* it: it never writes to that endpoint.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
22
|
+
import { homedir, userInfo } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { spawn, execFile } from 'node:child_process';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
|
|
27
|
+
import { formatDetail, formatOutput, printSuccess, printWarning, printInfo } from '../core/output.js';
|
|
28
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
29
|
+
import { resolveOrgId } from './helpers.js';
|
|
30
|
+
import { loadResidentProfile, expandHome, deriveSupervisorEnv } from '../core/residentAuth.js';
|
|
31
|
+
import { createInitialPumpState, runPumpOnce } from '../core/residentPump.js';
|
|
32
|
+
import { createInitialAlarmState, runWardenPass, sendAlarm } from '../core/residentAlarm.js';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_SETTINGS,
|
|
35
|
+
DEFAULT_IDLE_RECHECK_SECONDS,
|
|
36
|
+
DEFAULT_WAIT_FAILURE_THRESHOLD,
|
|
37
|
+
DEFAULT_MAX_MESSAGE_AGE_MINUTES,
|
|
38
|
+
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
|
39
|
+
resolveSettings,
|
|
40
|
+
buildSelfIdentity,
|
|
41
|
+
runPollOnce,
|
|
42
|
+
runClaudeTurn,
|
|
43
|
+
createInitialState,
|
|
44
|
+
attemptSlotClaim,
|
|
45
|
+
createClaimState,
|
|
46
|
+
shouldPostErrorNotice,
|
|
47
|
+
startIndependentHeartbeat,
|
|
48
|
+
} from '../core/supervisorEngine.js';
|
|
49
|
+
import {
|
|
50
|
+
SERVICE_NAME,
|
|
51
|
+
DEFAULT_RESTART_SEC,
|
|
52
|
+
buildUnitFile,
|
|
53
|
+
defaultUnitDir,
|
|
54
|
+
defaultUnitPath,
|
|
55
|
+
installSupervisorService,
|
|
56
|
+
uninstallSupervisorService,
|
|
57
|
+
getServiceInstallStatus,
|
|
58
|
+
realExecFn,
|
|
59
|
+
} from '../core/systemdInstaller.js';
|
|
60
|
+
|
|
61
|
+
export function defaultLogPath() {
|
|
62
|
+
return join(homedir(), '.quox', 'supervisor.log');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function defaultStatePath() {
|
|
66
|
+
return join(homedir(), '.quox', 'supervisor-state.json');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// QUOXPERTICUS-REVISIT P4/P5: separate continuity files for the warden's
|
|
70
|
+
// pump (steer) and alarm (watchdog) state, so they persist across process
|
|
71
|
+
// restarts the same way supervisor-state.json does, without mixing shapes
|
|
72
|
+
// with the headless-answering state above.
|
|
73
|
+
export function defaultPumpStatePath() {
|
|
74
|
+
return join(homedir(), '.quox', 'warden-pump-state.json');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function defaultAlarmStatePath() {
|
|
78
|
+
return join(homedir(), '.quox', 'warden-alarm-state.json');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// #619: which `quox` binary to bake into the Restart=always unit's ExecStart.
|
|
82
|
+
// Baking process.argv[1] (the CLI doing the install) is the trap: if that CLI
|
|
83
|
+
// lives in a MUTABLE tree — the shared quox-dashboard checkout that
|
|
84
|
+
// sessions branch-switch, or a per-session …-wt-<name> worktree — then a
|
|
85
|
+
// later checkout/reset silently changes or removes the deployed supervisor
|
|
86
|
+
// code under the running unit. A resident crash-looped to StartLimit and sat
|
|
87
|
+
// dark ~15 days exactly this way ("long-lived units must NOT exec from a live
|
|
88
|
+
// working tree"). Prefer the STABLE detached CLI worktree the rest of the box
|
|
89
|
+
// already treats as canonical ($QUOX_CLI_DIR or ~/quox-dashboard-cli — the
|
|
90
|
+
// same one quox-cli-refresh/#607 maintains). If we can only fall back to a
|
|
91
|
+
// mutable path, return mutable:true so the caller warns loudly.
|
|
92
|
+
export function resolveSupervisorBin({ quoxBin, argv1, home, existsFn = existsSync, env = process.env } = {}) {
|
|
93
|
+
if (quoxBin) return { bin: quoxBin, source: 'explicit (--quox-bin)', mutable: false };
|
|
94
|
+
const stableDir = env.QUOX_CLI_DIR || join(home || homedir(), 'quox-dashboard-cli');
|
|
95
|
+
const stableBin = join(stableDir, 'cli', 'bin', 'quox.js');
|
|
96
|
+
if (existsFn(stableBin)) return { bin: stableBin, source: 'stable CLI worktree', mutable: false };
|
|
97
|
+
const fallback = argv1 || 'quox';
|
|
98
|
+
// A per-session worktree (…-wt-…) or the shared mutable dev tree
|
|
99
|
+
// (…/quox-dashboard/… but NOT …/quox-dashboard-cli/… or …-wt-…).
|
|
100
|
+
const mutable = /-wt-/.test(fallback) || /\/quox-dashboard\//.test(fallback);
|
|
101
|
+
return { bin: fallback, source: 'install-time process path', mutable };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function loadNamedState(path, factory) {
|
|
105
|
+
try {
|
|
106
|
+
if (!existsSync(path)) return factory();
|
|
107
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
108
|
+
return { ...factory(), ...parsed };
|
|
109
|
+
} catch {
|
|
110
|
+
// A corrupt or unreadable state file must never crash the warden; it
|
|
111
|
+
// just loses continuity and starts fresh.
|
|
112
|
+
return factory();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveNamedState(path, state) {
|
|
117
|
+
ensureDirFor(path);
|
|
118
|
+
writeFileSync(path, JSON.stringify(state, null, 2));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureDirFor(path) {
|
|
122
|
+
try { mkdirSync(dirname(path), { recursive: true }); } catch { /* already exists */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function loadState(path) {
|
|
126
|
+
try {
|
|
127
|
+
if (!existsSync(path)) return createInitialState();
|
|
128
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
129
|
+
return { ...createInitialState(), ...parsed };
|
|
130
|
+
} catch {
|
|
131
|
+
// A corrupt or unreadable state file must never crash the runner; it
|
|
132
|
+
// just loses continuity and starts a fresh first-run seed.
|
|
133
|
+
return createInitialState();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function saveState(path, state) {
|
|
138
|
+
ensureDirFor(path);
|
|
139
|
+
writeFileSync(path, JSON.stringify(state, null, 2));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function makeLogger(logPath) {
|
|
143
|
+
ensureDirFor(logPath);
|
|
144
|
+
return (level, message) => {
|
|
145
|
+
const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}`;
|
|
146
|
+
try { appendFileSync(logPath, `${line}\n`); } catch { /* logging must never crash the runner */ }
|
|
147
|
+
if (level === 'warn') printWarning(message);
|
|
148
|
+
else if (level === 'error') console.error(message);
|
|
149
|
+
else printInfo(message);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Read the timestamp off the last line of the log file, if any. */
|
|
154
|
+
function lastLogActivity(logPath) {
|
|
155
|
+
try {
|
|
156
|
+
if (!existsSync(logPath)) return null;
|
|
157
|
+
const lines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
|
|
158
|
+
if (lines.length === 0) return null;
|
|
159
|
+
const match = lines[lines.length - 1].match(/^\[([^\]]+)\]/);
|
|
160
|
+
if (!match) return null;
|
|
161
|
+
const ts = new Date(match[1]).getTime();
|
|
162
|
+
return Number.isFinite(ts) ? ts : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sleep(ms) {
|
|
169
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function collectRepeatable(value, previous) {
|
|
173
|
+
return (previous || []).concat([value]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve slot/host defaults from the resident bootstrap profile
|
|
178
|
+
* (~/.config/quox/resident-profile.json). Never throws for a missing
|
|
179
|
+
* profile: --host/--slot can supply both explicitly (e.g. an operator
|
|
180
|
+
* driving this from their own machine against a resident host).
|
|
181
|
+
*/
|
|
182
|
+
function loadResidentProfileSafely(explicitPath) {
|
|
183
|
+
try {
|
|
184
|
+
return loadResidentProfile(explicitPath);
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function registerDevSupervisorCommands(dev, ctx) {
|
|
191
|
+
const { client, auth, program } = ctx;
|
|
192
|
+
|
|
193
|
+
const supervisor = dev
|
|
194
|
+
.command('supervisor')
|
|
195
|
+
.description('Headless runner: answer a resident\'s slot channel with no interactive session open');
|
|
196
|
+
|
|
197
|
+
// Split from the combined resolveTargets below (item 1a/1c, found by the
|
|
198
|
+
// acceptance owner driving this on a real host, 2026-08-23) so `install`
|
|
199
|
+
// can refuse cleanly on a missing slot alone, and `run --service` can
|
|
200
|
+
// idle instead of crash-looping on one, without either needing a host id
|
|
201
|
+
// check to also participate in that branching.
|
|
202
|
+
function resolveHostId(options) {
|
|
203
|
+
const profile = loadResidentProfileSafely(options.residentProfile);
|
|
204
|
+
const hostId = options.host || profile?.host_id || null;
|
|
205
|
+
if (!hostId) {
|
|
206
|
+
throw new Error('No host id: pass --host <hostId>, or provision a resident bootstrap profile with host_id.');
|
|
207
|
+
}
|
|
208
|
+
return hostId;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveSlotName(options) {
|
|
212
|
+
const profile = loadResidentProfileSafely(options.residentProfile);
|
|
213
|
+
return options.slot || profile?.slot || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveTargets(options) {
|
|
217
|
+
const hostId = resolveHostId(options);
|
|
218
|
+
const slotName = resolveSlotName(options);
|
|
219
|
+
if (!slotName) {
|
|
220
|
+
throw new Error('No slot: pass --slot <name>, or add "slot" to the resident bootstrap profile.');
|
|
221
|
+
}
|
|
222
|
+
return { hostId, slotName };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
supervisor
|
|
226
|
+
.command('run')
|
|
227
|
+
.description('Poll the resident\'s slot channel and answer new owner messages headlessly')
|
|
228
|
+
.option('--slot <name>', 'slot to poll (default: resident bootstrap profile)')
|
|
229
|
+
.option('--host <hostId>', 'dev host_id (default: resident bootstrap profile)')
|
|
230
|
+
.option('--once', 'run a single poll pass and exit (what tests drive)')
|
|
231
|
+
.option('--service', 'Option A persistence: idle instead of exit when the kill switch is off, so a systemd unit never needs a restart to resume answering')
|
|
232
|
+
.option('--log <path>', 'log file path', defaultLogPath())
|
|
233
|
+
.option('--state <path>', 'continuity state file path', defaultStatePath())
|
|
234
|
+
.option('--resident-profile <path>', 'resident bootstrap profile path (default: ~/.config/quox/resident-profile.json)')
|
|
235
|
+
.option('--from <name>', 'accept messages only from this owner name (repeatable; default: anyone but this resident\'s own posts)', collectRepeatable, [])
|
|
236
|
+
.option('--history <n>', 'channel messages carried as continuity handover per turn', (v) => parseInt(v, 10), 10)
|
|
237
|
+
.option('--max-age-minutes <n>', 'never answer an owner message older than this, mark it seen instead (churn guard)', (v) => parseInt(v, 10), DEFAULT_MAX_MESSAGE_AGE_MINUTES)
|
|
238
|
+
.option('--pump-state <path>', 'warden pump continuity state file path (QUOXPERTICUS-REVISIT P4)', defaultPumpStatePath())
|
|
239
|
+
.option('--alarm-state <path>', 'warden alarm continuity state file path (QUOXPERTICUS-REVISIT P5)', defaultAlarmStatePath())
|
|
240
|
+
.action(async (options) => {
|
|
241
|
+
auth.requireAuth();
|
|
242
|
+
const hostId = resolveHostId(options);
|
|
243
|
+
let slotName = resolveSlotName(options);
|
|
244
|
+
const orgId = resolveOrgId(auth, program);
|
|
245
|
+
const selfIdentity = buildSelfIdentity(hostId);
|
|
246
|
+
const logPath = expandHome(options.log);
|
|
247
|
+
const statePath = expandHome(options.state);
|
|
248
|
+
const log = makeLogger(logPath);
|
|
249
|
+
|
|
250
|
+
let state = loadState(statePath);
|
|
251
|
+
|
|
252
|
+
const fetchSettings = async () => {
|
|
253
|
+
const ep = resolveEndpoint('v1.dev.supervisor.get', { hostId });
|
|
254
|
+
return client.get(ep.path, { service: ep.service });
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (!slotName) {
|
|
258
|
+
if (!options.service) {
|
|
259
|
+
throw new Error('No slot: pass --slot <name>, or add "slot" to the resident bootstrap profile.');
|
|
260
|
+
}
|
|
261
|
+
// LAUNCH BLOCKER FIX (item 1c, found by the acceptance owner driving
|
|
262
|
+
// this on a real host, 2026-08-23): before this fix, an unresolvable
|
|
263
|
+
// slot exited non-zero, and under systemd Restart=always that meant a
|
|
264
|
+
// busy-loop roughly every RestartSec forever. A --service runner
|
|
265
|
+
// with no slot yet has nothing to answer, but that is not a crash:
|
|
266
|
+
// idle on the same slow re-check cadence as the disabled-switch
|
|
267
|
+
// branch below, re-resolving from --slot/the bootstrap profile AND
|
|
268
|
+
// the server-side supervisor settings (whose `slot` field needs only
|
|
269
|
+
// hostId to fetch, set independently via `quox dev supervisor
|
|
270
|
+
// enable --slot`), until one appears.
|
|
271
|
+
log('warn', `startup: host=${hostId} no slot resolved (checked --slot and the resident bootstrap profile). service mode: idling (re-check every ${DEFAULT_IDLE_RECHECK_SECONDS}s) instead of exiting; no turns run with no slot to answer.`);
|
|
272
|
+
while (!slotName) {
|
|
273
|
+
if (options.once) {
|
|
274
|
+
log('info', 'shutdown');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await sleep(DEFAULT_IDLE_RECHECK_SECONDS * 1000);
|
|
278
|
+
slotName = resolveSlotName(options);
|
|
279
|
+
if (!slotName) {
|
|
280
|
+
try {
|
|
281
|
+
const settings = resolveSettings(await fetchSettings());
|
|
282
|
+
if (settings.slot) slotName = settings.slot;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
log('warn', `service mode: idle slot re-check failed: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
log('info', `service mode: slot resolved (${slotName}); resuming without a restart.`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
log('info', `startup: host=${hostId} slot=${slotName} log=${logPath} state=${statePath} once=${!!options.once} service=${!!options.service}`);
|
|
292
|
+
|
|
293
|
+
const fetchMessages = async () => {
|
|
294
|
+
const ep = resolveEndpoint('v1.slots.messages', { name: slotName });
|
|
295
|
+
const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId, limit: 50 } });
|
|
296
|
+
return data.messages || [];
|
|
297
|
+
};
|
|
298
|
+
// Long-poll primitive, same contract as `quox slot wait --once`: one
|
|
299
|
+
// ~50s round trip, returns whatever landed since `cursor` (or nothing).
|
|
300
|
+
// Reused here rather than shelling out to the CLI verb itself, but it
|
|
301
|
+
// hits the exact same collector endpoint slot.js's `wait` command does.
|
|
302
|
+
// holder_session/exclude_from mirror `slot wait --session`: this is how
|
|
303
|
+
// this codebase keeps a slot's hold warm on every round trip, so the
|
|
304
|
+
// resident stays visibly present (not just "answering") for as long as
|
|
305
|
+
// it is actively watching.
|
|
306
|
+
const fetchWait = async (cursor) => {
|
|
307
|
+
const ep = resolveEndpoint('v1.slots.wait', { name: slotName });
|
|
308
|
+
const query = { org_id: orgId, timeout_ms: 50000, holder_session: selfIdentity, exclude_from: selfIdentity };
|
|
309
|
+
if (cursor) query.since = cursor;
|
|
310
|
+
return client.get(ep.path, { service: ep.service, query, timeout: 65000 });
|
|
311
|
+
};
|
|
312
|
+
const postReply = async (text) => {
|
|
313
|
+
const ep = resolveEndpoint('v1.slots.say', { name: slotName });
|
|
314
|
+
await client.post(ep.path, { service: ep.service, body: { text, from: selfIdentity, org_id: orgId } });
|
|
315
|
+
};
|
|
316
|
+
// Present-but-silent seam for the idle branch below: keeps the hold
|
|
317
|
+
// alive with NO cursor movement and NO message read, so nothing can be
|
|
318
|
+
// lost or skipped across a disable/re-enable transition.
|
|
319
|
+
const postHeartbeat = async () => {
|
|
320
|
+
const ep = resolveEndpoint('v1.slots.heartbeat', { name: slotName });
|
|
321
|
+
await client.post(ep.path, { service: ep.service, body: { holder_session: selfIdentity, org_id: orgId } });
|
|
322
|
+
};
|
|
323
|
+
// PRESENCE FIX (item 2, live outage 2026-08-23): both residents' slots
|
|
324
|
+
// sat FREE while their supervisors ran, because nothing ever called
|
|
325
|
+
// checkout, only heartbeat. This is a plain (non-force) checkout as
|
|
326
|
+
// this resident's own identity: the collector's checkout is a
|
|
327
|
+
// compare-and-swap on state='free', so it is safe to attempt on every
|
|
328
|
+
// poll pass. See attemptSlotClaim in supervisorEngine.js for how a
|
|
329
|
+
// 409 (already held by us, or held by someone else) is handled.
|
|
330
|
+
const checkoutSlot = async () => {
|
|
331
|
+
const ep = resolveEndpoint('v1.slots.checkout', { name: slotName });
|
|
332
|
+
return client.post(ep.path, {
|
|
333
|
+
service: ep.service,
|
|
334
|
+
body: { holder_session: selfIdentity, holder_kind: 'resident', host_id: hostId, org_id: orgId, force: false },
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
const claimState = createClaimState();
|
|
338
|
+
const spawnFn = ctx.spawnFn || spawn;
|
|
339
|
+
const spawnTurn = (prompt, timeoutSeconds) => runClaudeTurn({ spawnFn, prompt, timeoutSeconds });
|
|
340
|
+
// QP-DASH P1: report every attempted turn as a receipt. The engine
|
|
341
|
+
// wraps this fail-soft, so a collector outage never breaks answering.
|
|
342
|
+
const postTurnReceipt = async (fields) => {
|
|
343
|
+
const ep = resolveEndpoint('v1.dev.supervisor.turns.record', { hostId });
|
|
344
|
+
await client.post(ep.path, { service: ep.service, body: { ...fields, slot_name: slotName, org_id: orgId } });
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// GRIPE-797: one owner alert per auth-expiry episode, same transport
|
|
348
|
+
// order as the warden alarm (tg-send.sh first, slot say fallback).
|
|
349
|
+
const alertOwner = async (text) => {
|
|
350
|
+
const script = ctx.tgSendScriptPath || join(homedir(), '.claude', 'skills', 'telegram-owner', 'tg-send.sh');
|
|
351
|
+
await sendAlarm({
|
|
352
|
+
tgSend: async (session, t) => { await (ctx.execFileAsync || promisify(execFile))('bash', [script, session, t]); },
|
|
353
|
+
postSlotNotice: postReply, text, sessionName: `SUPERVISOR-${hostId}`, log,
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
const commonPollArgs = {
|
|
357
|
+
alertOwner,
|
|
358
|
+
hostId,
|
|
359
|
+
slot: slotName,
|
|
360
|
+
selfIdentity,
|
|
361
|
+
fromFilters: options.from,
|
|
362
|
+
fetchSettings,
|
|
363
|
+
postReply,
|
|
364
|
+
spawnTurn,
|
|
365
|
+
checkoutSlot,
|
|
366
|
+
claimState,
|
|
367
|
+
postHeartbeat,
|
|
368
|
+
postTurnReceipt,
|
|
369
|
+
log,
|
|
370
|
+
historyWindow: options.history,
|
|
371
|
+
maxAgeMinutes: Number.isInteger(options.maxAgeMinutes) ? options.maxAgeMinutes : DEFAULT_MAX_MESSAGE_AGE_MINUTES,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// QUOXPERTICUS-REVISIT P4/P5: WARDEN MODE. When this host's slot is
|
|
375
|
+
// held by a FULL INTERACTIVE claude session on THIS host (the resident
|
|
376
|
+
// this process wards, deployed via `quox dev resident deploy`), the
|
|
377
|
+
// HOLD-IS-THE-VOICE gate inside runPollOnce already keeps this process
|
|
378
|
+
// structurally silent (see supervisorEngine.js). Rather than leave
|
|
379
|
+
// that silence unattended, this process becomes the WARDEN for that
|
|
380
|
+
// pass: it pumps new owner messages into the resident's live pane
|
|
381
|
+
// (residentPump.js) and watches the resident's health (residentAlarm.js)
|
|
382
|
+
// instead of running the normal answering pass at all.
|
|
383
|
+
const execFileAsync = ctx.execFileAsync || promisify(execFile);
|
|
384
|
+
const pumpStatePath = expandHome(options.pumpState || defaultPumpStatePath());
|
|
385
|
+
const alarmStatePath = expandHome(options.alarmState || defaultAlarmStatePath());
|
|
386
|
+
let pumpState = loadNamedState(pumpStatePath, createInitialPumpState);
|
|
387
|
+
let alarmState = loadNamedState(alarmStatePath, createInitialAlarmState);
|
|
388
|
+
|
|
389
|
+
const fetchSlotRow = async () => {
|
|
390
|
+
const ep = resolveEndpoint('v1.slots.list');
|
|
391
|
+
const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId } });
|
|
392
|
+
return ((data && data.slots) || []).find((s) => s.name === slotName) || null;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// Runs ONE warden cycle (pump + watchdog) when applicable. Returns
|
|
396
|
+
// {wasWarden:false} when the slot is not held by an interactive
|
|
397
|
+
// resident on this host, in which case the caller falls through to
|
|
398
|
+
// the normal headless answering pass, completely unchanged. Never
|
|
399
|
+
// throws: a slot lookup failure is logged and treated as "not warded
|
|
400
|
+
// this pass", never as a reason to stop the runner.
|
|
401
|
+
async function runWardenCycle() {
|
|
402
|
+
let slotRow;
|
|
403
|
+
try {
|
|
404
|
+
slotRow = await fetchSlotRow();
|
|
405
|
+
} catch (err) {
|
|
406
|
+
log('warn', `warden: could not read slot ${slotName} to check for an interactive hold: ${err.message}`);
|
|
407
|
+
return { wasWarden: false };
|
|
408
|
+
}
|
|
409
|
+
const isWardedResident = !!(
|
|
410
|
+
slotRow && slotRow.state === 'held' && slotRow.holder_kind === 'interactive' && slotRow.host_id === hostId
|
|
411
|
+
);
|
|
412
|
+
if (!isWardedResident) return { wasWarden: false };
|
|
413
|
+
|
|
414
|
+
const residentSessionId = slotRow.holder_session;
|
|
415
|
+
// Default naming convention from `quox dev resident deploy`
|
|
416
|
+
// (lib/commands/devResident.js defaultSessionName): a resident
|
|
417
|
+
// deployed with a custom --session-name is not matched by this.
|
|
418
|
+
const sessionName = `resident-${slotName}`;
|
|
419
|
+
|
|
420
|
+
const readPane = async () => {
|
|
421
|
+
const ep = resolveEndpoint('v1.dev.sessions.pane', { hostId, sessionId: residentSessionId });
|
|
422
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
423
|
+
return (data && data.pane) || '';
|
|
424
|
+
};
|
|
425
|
+
const injectKeys = async (line) => {
|
|
426
|
+
const ep = resolveEndpoint('v1.dev.sessions.keys', { hostId, sessionId: residentSessionId });
|
|
427
|
+
await client.post(ep.path, { service: ep.service, body: { keys: line } });
|
|
428
|
+
};
|
|
429
|
+
const heartbeatHold = async () => {
|
|
430
|
+
const ep = resolveEndpoint('v1.slots.heartbeat', { name: slotName });
|
|
431
|
+
await client.post(ep.path, { service: ep.service, body: { holder_session: residentSessionId, org_id: orgId } });
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const pumpResult = await runPumpOnce({
|
|
436
|
+
hostId, slot: slotName, selfIdentity, fromFilters: options.from,
|
|
437
|
+
fetchMessages, readPane, injectKeys, heartbeatHold, postReply,
|
|
438
|
+
state: pumpState, log,
|
|
439
|
+
});
|
|
440
|
+
pumpState = pumpResult.state;
|
|
441
|
+
saveNamedState(pumpStatePath, pumpState);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
log('warn', `warden: pump pass failed (never blocks the watchdog below): ${err.message}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const listSessions = async () => {
|
|
447
|
+
const ep = resolveEndpoint('v1.dev.sessions.list', { hostId });
|
|
448
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
449
|
+
return (data && data.sessions) || [];
|
|
450
|
+
};
|
|
451
|
+
const releaseSlot = async () => {
|
|
452
|
+
const ep = resolveEndpoint('v1.slots.release', { name: slotName });
|
|
453
|
+
await client.post(ep.path, { service: ep.service, body: { org_id: orgId, force: true } });
|
|
454
|
+
};
|
|
455
|
+
// Reuses `quox dev resident deploy` (lib/commands/devResident.js)
|
|
456
|
+
// via a child process rather than importing its internals: deploy's
|
|
457
|
+
// closures are private to registerDevResidentCommands, and shelling
|
|
458
|
+
// out exercises the exact same governed path a human would run.
|
|
459
|
+
const redeploy = async () => {
|
|
460
|
+
const quoxBin = ctx.quoxBin || process.argv[1] || 'quox';
|
|
461
|
+
await execFileAsync(quoxBin, ['dev', 'resident', 'deploy', '--host', hostId, '--slot', slotName, '--force']);
|
|
462
|
+
};
|
|
463
|
+
const claimFallback = async () => attemptSlotClaim({ checkoutSlot, selfIdentity, slot: slotName, log, announceForeignHold: false, claimState, postHeartbeat });
|
|
464
|
+
const tgSend = async (session, text) => {
|
|
465
|
+
const script = ctx.tgSendScriptPath || join(homedir(), '.claude', 'skills', 'telegram-owner', 'tg-send.sh');
|
|
466
|
+
await execFileAsync('bash', [script, session, text]);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const alarmResult = await runWardenPass({
|
|
471
|
+
hostId, slot: slotName, sessionName,
|
|
472
|
+
listSessions, readPane, releaseSlot, redeploy, claimFallback,
|
|
473
|
+
postSlotNotice: postReply, recordTurn: postTurnReceipt, tgSend,
|
|
474
|
+
stuckDialogSinceMs: pumpState.stuck_dialog_since,
|
|
475
|
+
state: alarmState, log,
|
|
476
|
+
});
|
|
477
|
+
alarmState = alarmResult.state;
|
|
478
|
+
saveNamedState(alarmStatePath, alarmState);
|
|
479
|
+
} catch (err) {
|
|
480
|
+
log('warn', `warden: watchdog pass failed: ${err.message}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { wasWarden: true };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Watching strategy for a continuous (non --once) run: long-poll via
|
|
487
|
+
// `wait` is the primary path (requirement: no sleep loop watching for
|
|
488
|
+
// messages). The very first pass of a continuous run still uses the
|
|
489
|
+
// classic /messages fetch, unchanged, so first-run backlog seeding
|
|
490
|
+
// stays exactly what it always was; every pass after that switches to
|
|
491
|
+
// waitMode and re-waits from the cursor the previous round returned.
|
|
492
|
+
// If wait fails DEFAULT_WAIT_FAILURE_THRESHOLD times in a row, this
|
|
493
|
+
// permanently falls back to the original interval-poll loop for the
|
|
494
|
+
// rest of THIS process's life: degrade, never die.
|
|
495
|
+
const continuous = !options.once;
|
|
496
|
+
let running = true;
|
|
497
|
+
let pollSeconds = DEFAULT_SETTINGS.poll_seconds;
|
|
498
|
+
let waitMode = false;
|
|
499
|
+
let waitDisabled = false;
|
|
500
|
+
let waitFailures = 0;
|
|
501
|
+
let cursor = null;
|
|
502
|
+
// Service mode idling: set when the server-side switch is off and
|
|
503
|
+
// --service was given.
|
|
504
|
+
//
|
|
505
|
+
// PRESENT BUT SILENT (deliberate, on the record): while idling, this
|
|
506
|
+
// resident keeps holding its slot and keeps heartbeating it, so it
|
|
507
|
+
// reads as a live host that is not currently permitted to act, never
|
|
508
|
+
// as an absent one. It answers NOTHING while idling: no message is
|
|
509
|
+
// fetched for processing here (only the heartbeat, which reads
|
|
510
|
+
// nothing) and no turn is ever spawned. Only the server-side switch
|
|
511
|
+
// itself is re-checked, slowly.
|
|
512
|
+
let idling = false;
|
|
513
|
+
|
|
514
|
+
// LOOP TRIAGE FIX (diagnosed live 2026-09-10, .131): before this, the
|
|
515
|
+
// ENABLED branch's only heartbeat was the `slot wait` long-poll's own
|
|
516
|
+
// server-side coupling (holder_session on every wait round trip). An
|
|
517
|
+
// intermittent hang on the authenticated slot path (observed:
|
|
518
|
+
// "checkout attempt failed: Request timed out after 30000ms" while
|
|
519
|
+
// /health stayed instant, so this is a server-side hang, not network)
|
|
520
|
+
// wedged the long-poll, and NO heartbeat fired until it recovered, so
|
|
521
|
+
// the hold lapsed stale (15m TTL) then free (30m) and re-claimed
|
|
522
|
+
// ~45 minutes later, ~193 times over 6 days. This independent
|
|
523
|
+
// heartbeat runs on its own fixed-interval timer (see
|
|
524
|
+
// startIndependentHeartbeat in supervisorEngine.js), decoupled from
|
|
525
|
+
// the wait long-poll, a turn spawn, or the idle branch's own recheck
|
|
526
|
+
// cadence, so it keeps the hold warm through all of them. It never
|
|
527
|
+
// seizes anything (postHeartbeat only warms a hold this resident
|
|
528
|
+
// already has; a 409 is logged and left alone, same rule as
|
|
529
|
+
// everywhere else in this file).
|
|
530
|
+
const independentHeartbeat = startIndependentHeartbeat({
|
|
531
|
+
postHeartbeat,
|
|
532
|
+
intervalMs: DEFAULT_HEARTBEAT_INTERVAL_SECONDS * 1000,
|
|
533
|
+
log,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
while (running) {
|
|
538
|
+
if (idling) {
|
|
539
|
+
// PRESENCE FIX (item 2, live outage 2026-08-23): claim the slot
|
|
540
|
+
// here too, before the heartbeat below, so idling reads as PRESENT
|
|
541
|
+
// rather than present-but-silent-and-not-even-holding-it. Never
|
|
542
|
+
// logs the foreign-hold case itself (announceForeignHold: false):
|
|
543
|
+
// the existing heartbeat 409 handler right below already reports
|
|
544
|
+
// that, unchanged, and would otherwise double-log the same event.
|
|
545
|
+
const idleClaim = await attemptSlotClaim({ checkoutSlot, selfIdentity, slot: slotName, log, announceForeignHold: false, claimState, postHeartbeat });
|
|
546
|
+
// HOLD-IS-THE-VOICE gate, mirrored (QUOXPERTICUS-REVISIT P2): this
|
|
547
|
+
// idle loop never fetches messages or spawns turns regardless (the
|
|
548
|
+
// kill switch is off here), but a foreign hold is still witnessed
|
|
549
|
+
// with the same once-per-holder-per-hour receipt the engine posts
|
|
550
|
+
// on an enabled pass, so "who held it and when" is legible even
|
|
551
|
+
// while service mode is idling on the disabled switch.
|
|
552
|
+
if (!idleClaim.claimed && idleClaim.reason === 'held_by_other') {
|
|
553
|
+
const holder = idleClaim.holder || 'unknown';
|
|
554
|
+
const notice = shouldPostErrorNotice(state.held_by_other_notices || [], holder, Date.now());
|
|
555
|
+
state = { ...state, held_by_other_notices: notice.notices };
|
|
556
|
+
if (notice.shouldPost) {
|
|
557
|
+
try {
|
|
558
|
+
await postTurnReceipt({ outcome: 'silent_held_by_other', held_by: holder });
|
|
559
|
+
} catch (err) {
|
|
560
|
+
log('warn', `silent-held receipt failed (never blocks idling): ${err.message}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
saveState(statePath, state);
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
await postHeartbeat();
|
|
567
|
+
} catch (err) {
|
|
568
|
+
// SLOT OWNERSHIP INTENT (item 3, found by the acceptance owner
|
|
569
|
+
// driving this on a real host, 2026-08-23): this heartbeat is
|
|
570
|
+
// read-only warmth on a hold this resident already has, never a
|
|
571
|
+
// seize. If another session (human or resident) already holds
|
|
572
|
+
// ${slotName}, the collector's heartbeatSlot() correctly refuses
|
|
573
|
+
// (409, "not held by this session") rather than handing the hold
|
|
574
|
+
// over silently. Reporting that as a generic failure buries
|
|
575
|
+
// exactly the information an operator needs; call it what it is.
|
|
576
|
+
// Silently seizing a hold someone else deliberately owns is the
|
|
577
|
+
// same failure class as the checkoutSlot assigned_to bug fixed
|
|
578
|
+
// earlier tonight (services/collector/lib/devSlotsStore.js),
|
|
579
|
+
// where an assignment was ignored and a slot could be taken out
|
|
580
|
+
// from under its owner — this is the CLI-side mirror of that
|
|
581
|
+
// rule: never force a hold this process does not already have.
|
|
582
|
+
if (err && err.status === 409) {
|
|
583
|
+
log('warn', `slot ${slotName}: idle heartbeat got 409 (not held by this resident) — leaving it alone rather than seizing it. Run \`quox dev supervisor status\` to see who holds it.`);
|
|
584
|
+
} else {
|
|
585
|
+
log('warn', `service mode: idle heartbeat failed: ${err.message}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
await sleep(DEFAULT_IDLE_RECHECK_SECONDS * 1000);
|
|
589
|
+
let settings;
|
|
590
|
+
try {
|
|
591
|
+
settings = resolveSettings(await fetchSettings());
|
|
592
|
+
} catch (err) {
|
|
593
|
+
log('warn', `service mode: idle re-check failed: ${err.message}`);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (!settings.enabled) continue;
|
|
597
|
+
idling = false;
|
|
598
|
+
log('info', 'service mode: supervisor re-enabled, resuming without a restart.');
|
|
599
|
+
// Falls through into a normal pass below, same iteration.
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// QUOXPERTICUS-REVISIT P4/P5: check for warden mode BEFORE the
|
|
603
|
+
// normal answering pass below. When the slot is held by an
|
|
604
|
+
// interactive resident on this host, this pass pumps + watches
|
|
605
|
+
// instead of running runPollOnce at all (which would find the same
|
|
606
|
+
// hold and just sit silent via the HOLD-IS-THE-VOICE gate). Skipped
|
|
607
|
+
// while idling above (that branch already has its own foreign-hold
|
|
608
|
+
// handling and re-checks the kill switch first).
|
|
609
|
+
if (!idling) {
|
|
610
|
+
let wardenOutcome = { wasWarden: false };
|
|
611
|
+
try {
|
|
612
|
+
wardenOutcome = await runWardenCycle();
|
|
613
|
+
} catch (err) {
|
|
614
|
+
log('warn', `warden cycle failed unexpectedly (falling through to a normal pass): ${err.message}`);
|
|
615
|
+
}
|
|
616
|
+
if (wardenOutcome.wasWarden) {
|
|
617
|
+
if (options.once) break;
|
|
618
|
+
await sleep(pollSeconds * 1000);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
let result = null;
|
|
624
|
+
try {
|
|
625
|
+
if (continuous && waitMode) {
|
|
626
|
+
const waitData = await fetchWait(cursor);
|
|
627
|
+
cursor = waitData.cursor || cursor;
|
|
628
|
+
waitFailures = 0;
|
|
629
|
+
const waitMessages = waitData.messages || [];
|
|
630
|
+
result = await runPollOnce({ ...commonPollArgs, state, fetchMessages: async () => waitMessages });
|
|
631
|
+
} else {
|
|
632
|
+
result = await runPollOnce({ ...commonPollArgs, state, fetchMessages });
|
|
633
|
+
// Only switch to wait-mode off a pass that actually ran enabled
|
|
634
|
+
// (result.stopped false): a classic pass returned early by the
|
|
635
|
+
// kill switch never touched state.bootstrapped or seen_ids, so
|
|
636
|
+
// treating it as the "first pass seeded the backlog" moment
|
|
637
|
+
// would hand wait a bare cursor and skip the real backlog seed
|
|
638
|
+
// the first ever enabled pass is required to do.
|
|
639
|
+
if (continuous && !waitDisabled && result && !result.stopped) {
|
|
640
|
+
cursor = new Date().toISOString();
|
|
641
|
+
waitMode = true;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch (err) {
|
|
645
|
+
log('error', `poll failed: ${err.message}`);
|
|
646
|
+
// #1018: a single pass that failed is a failed run, not a quiet success.
|
|
647
|
+
if (options.once) process.exitCode = 1;
|
|
648
|
+
if (continuous && waitMode) {
|
|
649
|
+
waitFailures += 1;
|
|
650
|
+
if (waitFailures >= DEFAULT_WAIT_FAILURE_THRESHOLD) {
|
|
651
|
+
waitMode = false;
|
|
652
|
+
waitDisabled = true;
|
|
653
|
+
waitFailures = 0;
|
|
654
|
+
log('warn', `wait failed ${DEFAULT_WAIT_FAILURE_THRESHOLD} times in a row; falling back to interval polling every ${pollSeconds}s.`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (result) {
|
|
660
|
+
state = result.state;
|
|
661
|
+
saveState(statePath, state);
|
|
662
|
+
pollSeconds = result.settings.poll_seconds;
|
|
663
|
+
if (result.stopped) {
|
|
664
|
+
if (options.service && !options.once) {
|
|
665
|
+
idling = true;
|
|
666
|
+
log('info', `service mode: idling (re-check every ${DEFAULT_IDLE_RECHECK_SECONDS}s) until re-enabled; no turns run while disabled.`);
|
|
667
|
+
} else {
|
|
668
|
+
running = false;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (options.once) break;
|
|
674
|
+
if (running && !idling && !waitMode) await sleep(pollSeconds * 1000);
|
|
675
|
+
}
|
|
676
|
+
} finally {
|
|
677
|
+
independentHeartbeat.stop();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
log('info', 'shutdown');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// quox#470: the switch had a dashboard toggle but no CLI verb, so an org
|
|
684
|
+
// owner at a terminal had no way to flip it and it read as unreachable.
|
|
685
|
+
// Server-side this is requireHumanAdmin, so an org owner/admin passes and
|
|
686
|
+
// a resident's own agent-scoped token is refused however it is called.
|
|
687
|
+
function registerSwitch(name, enabled, description) {
|
|
688
|
+
supervisor
|
|
689
|
+
.command(name)
|
|
690
|
+
.description(description)
|
|
691
|
+
.option('--host <hostId>', 'dev host_id (default: resident bootstrap profile)')
|
|
692
|
+
.option('--slot <name>', 'slot the runner should answer (default: resident bootstrap profile)')
|
|
693
|
+
.option('--poll-seconds <n>', 'seconds between polls (5-300)', (v) => parseInt(v, 10))
|
|
694
|
+
.option('--turn-timeout-seconds <n>', 'per-turn timeout (30-600)', (v) => parseInt(v, 10))
|
|
695
|
+
.option('--max-turns-per-hour <n>', 'rate cap (1-120)', (v) => parseInt(v, 10))
|
|
696
|
+
.option('--resident-profile <path>', 'resident bootstrap profile path')
|
|
697
|
+
.action(async (options) => {
|
|
698
|
+
auth.requireAuth();
|
|
699
|
+
const profile = loadResidentProfileSafely(options.residentProfile);
|
|
700
|
+
const hostId = options.host || profile?.host_id || null;
|
|
701
|
+
if (!hostId) {
|
|
702
|
+
throw new Error('No host id: pass --host <hostId>, or run where a resident bootstrap profile exists.');
|
|
703
|
+
}
|
|
704
|
+
const body = { enabled };
|
|
705
|
+
// Only send a slot when enabling: disabling should never need one,
|
|
706
|
+
// and sending undefined would clobber the stored value.
|
|
707
|
+
const slotName = options.slot || profile?.slot || null;
|
|
708
|
+
if (enabled && slotName) body.slot = slotName;
|
|
709
|
+
if (Number.isInteger(options.pollSeconds)) body.poll_seconds = options.pollSeconds;
|
|
710
|
+
if (Number.isInteger(options.turnTimeoutSeconds)) body.turn_timeout_seconds = options.turnTimeoutSeconds;
|
|
711
|
+
if (Number.isInteger(options.maxTurnsPerHour)) body.max_turns_per_hour = options.maxTurnsPerHour;
|
|
712
|
+
|
|
713
|
+
const ep = resolveEndpoint('v1.dev.supervisor.set', { hostId });
|
|
714
|
+
const data = await client.put(ep.path, { service: ep.service, body });
|
|
715
|
+
formatDetail(data, { format: program.opts().output, title: `Supervisor ${enabled ? 'enabled' : 'disabled'} for ${hostId}` });
|
|
716
|
+
printSuccess(
|
|
717
|
+
enabled
|
|
718
|
+
? `Supervisor enabled for ${hostId}. Start the runner on that host: quox dev supervisor run`
|
|
719
|
+
: `Supervisor disabled for ${hostId}. A running runner exits within one poll.`
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
registerSwitch('enable', true, 'Turn the headless supervisor ON for a host (human admin only)');
|
|
725
|
+
registerSwitch('disable', false, 'Turn the headless supervisor OFF for a host (kill switch)');
|
|
726
|
+
|
|
727
|
+
// Option A persistence, delivery half. Installing this unit is deliberately
|
|
728
|
+
// NOT the autonomy grant: see the governance note at the top of
|
|
729
|
+
// ../core/systemdInstaller.js. The unit only ever answers a message when
|
|
730
|
+
// the human-gated server-side switch above is on, so a resident installing
|
|
731
|
+
// its own service on its own host (the scoped self-service pattern) still
|
|
732
|
+
// leaves the "may it act" decision with a human.
|
|
733
|
+
supervisor
|
|
734
|
+
.command('install')
|
|
735
|
+
.description('Install a systemd --user unit running this supervisor headlessly (Restart=always; survives logout/reboot with linger)')
|
|
736
|
+
.option('--host <hostId>', 'baked into the unit only if given; otherwise the runner resolves it from the resident bootstrap profile at start')
|
|
737
|
+
.option('--slot <name>', 'baked into the unit only if given; otherwise resolved from the resident bootstrap profile at start')
|
|
738
|
+
.option('--resident-profile <path>', 'baked into the unit only if given (non-default profile path)')
|
|
739
|
+
.option('--quox-bin <path>', 'path to the quox executable the unit runs (default: the stable CLI worktree ~/quox-dashboard-cli, else $QUOX_CLI_DIR, else this process\'s path with a mutable-tree warning — #619)')
|
|
740
|
+
.option('--restart-sec <n>', 'systemd RestartSec for the unit', (v) => parseInt(v, 10), DEFAULT_RESTART_SEC)
|
|
741
|
+
.option('--activate-now', 'acknowledge that the switch is already ON, so installing starts an autonomous responder immediately')
|
|
742
|
+
.action(async (options) => {
|
|
743
|
+
auth.requireAuth();
|
|
744
|
+
|
|
745
|
+
// GOVERNANCE SEQUENCING (found by the acceptance owner driving this on
|
|
746
|
+
// a real host, 2026-08-23). "Install does not grant autonomy" is true
|
|
747
|
+
// only while the supervisor switch is OFF: installing then produces a
|
|
748
|
+
// present-but-silent unit. If the switch is already ON, installing
|
|
749
|
+
// brings an autonomous responder live in the same motion, so the act
|
|
750
|
+
// of installing IS the activation. Their safety classifier correctly
|
|
751
|
+
// refused to let them do that, and the boundary should live in the
|
|
752
|
+
// tool rather than only in a classifier that constrains AI callers.
|
|
753
|
+
//
|
|
754
|
+
// So: refuse a plain install while enabled, and name both safe
|
|
755
|
+
// sequences. --activate-now is the deliberate acknowledgement for a
|
|
756
|
+
// human who genuinely means "install and go live right now".
|
|
757
|
+
try {
|
|
758
|
+
const probeHost = options.host || loadResidentProfileSafely(options.residentProfile)?.host_id || null;
|
|
759
|
+
if (probeHost && !options.activateNow) {
|
|
760
|
+
const ep = resolveEndpoint('v1.dev.supervisor.get', { hostId: probeHost });
|
|
761
|
+
const current = resolveSettings(await client.get(ep.path, { service: ep.service }));
|
|
762
|
+
if (current.enabled) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`Supervisor is already ENABLED for ${probeHost}, so installing now would start an autonomous responder immediately, not a silent one.\n` +
|
|
765
|
+
`Either:\n` +
|
|
766
|
+
` disable it, install (present but silent), then have a human re-enable it, or\n` +
|
|
767
|
+
` have a human run this install with --activate-now to activate deliberately.\n` +
|
|
768
|
+
`Turning autonomy on is always a human action by design.`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
} catch (err) {
|
|
773
|
+
// A settings lookup that genuinely failed must not silently become
|
|
774
|
+
// permission to install into an unknown state.
|
|
775
|
+
if (/already ENABLED/.test(err.message)) throw err;
|
|
776
|
+
printWarning(`Could not confirm the supervisor switch state before installing: ${err.message}`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// LAUNCH BLOCKER FIX (item 1a, found by the acceptance owner driving
|
|
780
|
+
// this on a real host, 2026-08-23): a unit installed with no slot
|
|
781
|
+
// resolvable dies at startup ("No slot: pass --slot") and, under
|
|
782
|
+
// Restart=always, busy-loops roughly every RestartSec forever, which
|
|
783
|
+
// hits every freshly dev-enabled host until re-enable happens to
|
|
784
|
+
// carry a slot. Resolve the same way `run` does (--slot, else the
|
|
785
|
+
// resident bootstrap profile) and refuse to write anything at all if
|
|
786
|
+
// neither supplies one: never install a unit known to crash on start.
|
|
787
|
+
const resolvedInstallSlot = resolveSlotName(options);
|
|
788
|
+
if (!resolvedInstallSlot) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
'Cannot install: no slot resolved (checked --slot and the resident bootstrap profile).\n' +
|
|
791
|
+
'A unit with no slot dies at startup and busy-loops under systemd Restart=always.\n' +
|
|
792
|
+
'Either pass --slot <name>, or re-run `quox dev enable` once the resident bootstrap profile carries a slot.'
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const extraArgs = [];
|
|
797
|
+
if (options.host) extraArgs.push('--host', options.host);
|
|
798
|
+
if (options.slot) extraArgs.push('--slot', options.slot);
|
|
799
|
+
if (options.residentProfile) extraArgs.push('--resident-profile', options.residentProfile);
|
|
800
|
+
|
|
801
|
+
// #619: prefer the STABLE CLI worktree over the install-time process
|
|
802
|
+
// path. Baking process.argv[1] into a Restart=always unit means the unit
|
|
803
|
+
// execs from whatever tree happened to run the install — a mutable dev
|
|
804
|
+
// tree or session worktree there is a delayed bomb (a resident sat dark
|
|
805
|
+
// ~15 days after its tree branch-switched out from under it).
|
|
806
|
+
const { bin: quoxBin, source: binSource, mutable: binMutable } = resolveSupervisorBin({
|
|
807
|
+
quoxBin: options.quoxBin,
|
|
808
|
+
argv1: process.argv[1],
|
|
809
|
+
home: homedir(),
|
|
810
|
+
});
|
|
811
|
+
if (binMutable) {
|
|
812
|
+
printWarning(
|
|
813
|
+
`Unit ExecStart will point at a MUTABLE tree: ${quoxBin}. A long-lived Restart=always unit `
|
|
814
|
+
+ `must NOT exec from a live working tree — a branch switch or reset there silently changes or `
|
|
815
|
+
+ `removes the deployed supervisor code (#619: a resident crash-looped to dark ~15 days this way). `
|
|
816
|
+
+ `Install from the stable CLI worktree (~/quox-dashboard-cli), set $QUOX_CLI_DIR, or pass `
|
|
817
|
+
+ `--quox-bin <stable path>. quox-sentry now alarms if the unit later goes dark, but pinning is the fix.`
|
|
818
|
+
);
|
|
819
|
+
} else {
|
|
820
|
+
printInfo(`Unit ExecStart bin: ${quoxBin} (${binSource}).`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// BAKE THE ENVIRONMENT INTO THE UNIT (item 1, live outage 2026-08-23):
|
|
824
|
+
// a systemd --user unit inherits none of the shell environment that
|
|
825
|
+
// reach-back URL resolution (resolveResidentBaseUrl) leans on. On a
|
|
826
|
+
// LAN install the instance edge is self-signed, so every re-auth died
|
|
827
|
+
// with a bare "fetch failed", the supervisor logged "Session expired"
|
|
828
|
+
// every 30s, and both residents silently lost their slots. Derive the
|
|
829
|
+
// fix from the same bootstrap profile the resident already trusts for
|
|
830
|
+
// its own identity, so there is nothing new to keep in sync.
|
|
831
|
+
const installProfile = loadResidentProfileSafely(options.residentProfile);
|
|
832
|
+
const env = deriveSupervisorEnv(installProfile);
|
|
833
|
+
if (!installProfile) {
|
|
834
|
+
printWarning(
|
|
835
|
+
'No resident bootstrap profile found: the unit is being installed with no baked-in environment. '
|
|
836
|
+
+ 'If this host\'s instance edge is self-signed, re-auth may fail with "fetch failed". '
|
|
837
|
+
+ 'Provision a bootstrap profile (or pass --resident-profile) and re-run install to fix this.'
|
|
838
|
+
);
|
|
839
|
+
} else if (Object.keys(env).length === 0) {
|
|
840
|
+
printWarning(
|
|
841
|
+
`Resident bootstrap profile at ${installProfile._path} has no instance_url: the unit is being installed with no baked-in environment. `
|
|
842
|
+
+ 'If this host\'s instance edge is self-signed, re-auth may fail with "fetch failed".'
|
|
843
|
+
);
|
|
844
|
+
} else {
|
|
845
|
+
printInfo(`Unit will carry: ${Object.keys(env).join(', ')} (derived from the resident bootstrap profile).`);
|
|
846
|
+
}
|
|
847
|
+
const unitContent = buildUnitFile({ quoxBin, extraArgs, restartSec: options.restartSec, env });
|
|
848
|
+
const unitPath = ctx.unitPath || defaultUnitPath();
|
|
849
|
+
const unitDir = ctx.unitDir || defaultUnitDir();
|
|
850
|
+
const execFn = ctx.execFn || realExecFn;
|
|
851
|
+
const writeFileFn = ctx.writeFileFn || writeFileSync;
|
|
852
|
+
const mkdirFn = ctx.mkdirFn || mkdirSync;
|
|
853
|
+
const user = ctx.osUser || userInfo().username;
|
|
854
|
+
const sleepFn = ctx.sleepFn || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
855
|
+
|
|
856
|
+
const report = await installSupervisorService({ execFn, writeFileFn, mkdirFn, unitPath, unitDir, unitContent, user, sleepFn });
|
|
857
|
+
|
|
858
|
+
formatDetail({ ...report, unit_content: unitContent }, { format: program.opts().output, title: `Resident supervisor service (${SERVICE_NAME})` });
|
|
859
|
+
|
|
860
|
+
// ITEM 2 (found by the acceptance owner driving this on a real host,
|
|
861
|
+
// 2026-08-23): "enable --now" succeeding only proves systemd accepted
|
|
862
|
+
// the unit, never that it stayed up. A crash loop must be reported as
|
|
863
|
+
// a FAILURE, with the last journal lines when they can be read, never
|
|
864
|
+
// dressed up as the success block below.
|
|
865
|
+
if (report.crash_loop) {
|
|
866
|
+
printWarning(
|
|
867
|
+
`FAILURE: ${SERVICE_NAME} was enabled but is crash-looping ` +
|
|
868
|
+
`(active=${report.active}, restarts=${report.restart_count ?? 'unknown'}). It is answering nothing.`
|
|
869
|
+
);
|
|
870
|
+
if (report.crash_journal) {
|
|
871
|
+
printWarning(`Last journal lines:\n${report.crash_journal}`);
|
|
872
|
+
} else {
|
|
873
|
+
printWarning('Could not read journal lines (journalctl unavailable or restricted).');
|
|
874
|
+
}
|
|
875
|
+
} else if (report.enabled) {
|
|
876
|
+
printSuccess(`Installed and started ${SERVICE_NAME} (systemd --user, Restart=always). Verified it stayed up (active=${report.active}, restarts=${report.restart_count ?? 0}).`);
|
|
877
|
+
} else {
|
|
878
|
+
printWarning(`Unit written to ${unitPath} but \`systemctl --user enable --now\` failed: ${report.enable_error}`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (report.linger_enabled) {
|
|
882
|
+
printSuccess(`Linger enabled for ${user}: the service survives logout and reboot.`);
|
|
883
|
+
} else {
|
|
884
|
+
printWarning(
|
|
885
|
+
`Could not enable linger for ${user} (${report.linger_error}). Without linger, this unit STOPS on logout ` +
|
|
886
|
+
`and will NOT come back on its own after a reboot; it only resumes at the next interactive login. ` +
|
|
887
|
+
`Ask an administrator with sudo to run: loginctl enable-linger ${user}`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
supervisor
|
|
893
|
+
.command('uninstall')
|
|
894
|
+
.description('Disable, stop, and remove the systemd --user unit')
|
|
895
|
+
.action(async () => {
|
|
896
|
+
auth.requireAuth();
|
|
897
|
+
const unitPath = ctx.unitPath || defaultUnitPath();
|
|
898
|
+
const execFn = ctx.execFn || realExecFn;
|
|
899
|
+
const existsFn = ctx.existsFn || existsSync;
|
|
900
|
+
const unlinkFn = ctx.unlinkFn || unlinkSync;
|
|
901
|
+
|
|
902
|
+
const report = await uninstallSupervisorService({ execFn, existsFn, unlinkFn, unitPath });
|
|
903
|
+
formatDetail(report, { format: program.opts().output, title: `Resident supervisor service removed (${SERVICE_NAME})` });
|
|
904
|
+
if (report.disabled && report.unit_removed) {
|
|
905
|
+
printSuccess(`${SERVICE_NAME} disabled, stopped, and removed.`);
|
|
906
|
+
} else {
|
|
907
|
+
printWarning(`Uninstall incomplete: disabled=${report.disabled} (${report.disable_error || 'ok'}), unit_removed=${report.unit_removed}.`);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
supervisor
|
|
912
|
+
.command('turns')
|
|
913
|
+
.description('List per-turn receipts (what the headless resident spent, per message)')
|
|
914
|
+
.option('--host <hostId>', 'dev host_id (default: resident bootstrap profile)')
|
|
915
|
+
.option('--since <ts>', 'only receipts after this ISO-8601 timestamp')
|
|
916
|
+
.option('--limit <n>', 'max receipts (default 50)', (v) => parseInt(v, 10))
|
|
917
|
+
.action(async (options) => {
|
|
918
|
+
auth.requireAuth();
|
|
919
|
+
const hostId = resolveHostId(options);
|
|
920
|
+
const ep = resolveEndpoint('v1.dev.supervisor.turns.list', { hostId });
|
|
921
|
+
const query = {};
|
|
922
|
+
if (options.since) query.since = options.since;
|
|
923
|
+
if (options.limit) query.limit = options.limit;
|
|
924
|
+
const data = await client.get(ep.path, { service: ep.service, query });
|
|
925
|
+
const s = data.summary || {};
|
|
926
|
+
printInfo(`${hostId}: ${s.turns ?? 0} turn(s), ${s.replied ?? 0} replied, total $${(s.total_cost_usd ?? 0).toFixed(4)}`);
|
|
927
|
+
formatOutput((data.turns || []).map((t) => ({
|
|
928
|
+
TIME: t.created_at, OUTCOME: t.outcome, FROM: t.message_from || '-',
|
|
929
|
+
CHARS: t.reply_chars ?? '-', COST: t.cost_usd != null ? `$${t.cost_usd.toFixed(4)}` : '-',
|
|
930
|
+
MS: t.duration_ms ?? '-', NOTE: t.error_note || '',
|
|
931
|
+
})), {
|
|
932
|
+
format: program.opts().output,
|
|
933
|
+
columns: ['TIME', 'OUTCOME', 'FROM', 'CHARS', 'COST', 'MS', 'NOTE'],
|
|
934
|
+
emptyMessage: 'No turn receipts yet.',
|
|
935
|
+
raw: data.turns || [],
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
supervisor
|
|
940
|
+
.command('status')
|
|
941
|
+
.description('Show resolved supervisor settings and whether a runner appears live')
|
|
942
|
+
.option('--host <hostId>', 'dev host_id (default: resident bootstrap profile)')
|
|
943
|
+
.option('--slot <name>', 'slot (default: resident bootstrap profile)')
|
|
944
|
+
.option('--log <path>', 'log file path', defaultLogPath())
|
|
945
|
+
.option('--state <path>', 'continuity state file path', defaultStatePath())
|
|
946
|
+
.option('--resident-profile <path>', 'resident bootstrap profile path')
|
|
947
|
+
.action(async (options) => {
|
|
948
|
+
auth.requireAuth();
|
|
949
|
+
const { hostId, slotName } = resolveTargets(options);
|
|
950
|
+
const orgId = resolveOrgId(auth, program);
|
|
951
|
+
const selfIdentity = buildSelfIdentity(hostId);
|
|
952
|
+
const ep = resolveEndpoint('v1.dev.supervisor.get', { hostId });
|
|
953
|
+
const raw = await client.get(ep.path, { service: ep.service });
|
|
954
|
+
const settings = resolveSettings(raw);
|
|
955
|
+
|
|
956
|
+
// SLOT OWNERSHIP INTENT (item 3, found by the acceptance owner driving
|
|
957
|
+
// this on a real host, 2026-08-23): a foreign hold must be visible
|
|
958
|
+
// here, not just buried in the runner's log — this is how an operator
|
|
959
|
+
// learns why a running, correctly-installed supervisor is nonetheless
|
|
960
|
+
// not answering. Read-only lookup, best-effort: never let a slot-list
|
|
961
|
+
// failure break the rest of `status`. See the idle-heartbeat comment
|
|
962
|
+
// in `run` for the full rationale (never seize a hold another session
|
|
963
|
+
// deliberately owns; same failure class as the checkoutSlot
|
|
964
|
+
// assigned_to bug fixed earlier tonight).
|
|
965
|
+
// FIX 3 (presence must be legible, live outage 2026-08-23): status
|
|
966
|
+
// must say plainly whether THIS resident currently holds its slot,
|
|
967
|
+
// not just name a foreign holder when there is one. Same read-only,
|
|
968
|
+
// best-effort lookup as the foreign-hold check below.
|
|
969
|
+
let foreignHolder = null;
|
|
970
|
+
let heldBySelf = false;
|
|
971
|
+
try {
|
|
972
|
+
const listEp = resolveEndpoint('v1.slots.list');
|
|
973
|
+
const listData = await client.get(listEp.path, { service: listEp.service, query: { org_id: orgId } });
|
|
974
|
+
const slotRow = (listData.slots || []).find((s) => s.name === slotName);
|
|
975
|
+
if (slotRow && slotRow.state === 'held' && slotRow.holder_session) {
|
|
976
|
+
if (slotRow.holder_session === selfIdentity) heldBySelf = true;
|
|
977
|
+
else foreignHolder = slotRow.holder_session;
|
|
978
|
+
}
|
|
979
|
+
} catch (_err) {
|
|
980
|
+
// Best-effort; the rest of `status` still reports.
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const logPath = expandHome(options.log);
|
|
984
|
+
const statePath = expandHome(options.state);
|
|
985
|
+
const state = loadState(statePath);
|
|
986
|
+
const lastActivity = lastLogActivity(logPath);
|
|
987
|
+
const staleAfterMs = Math.max(settings.poll_seconds * 3, 120) * 1000;
|
|
988
|
+
const live = lastActivity != null && (Date.now() - lastActivity) < staleAfterMs;
|
|
989
|
+
|
|
990
|
+
// Option A persistence: whether the systemd --user unit itself is
|
|
991
|
+
// installed/enabled/active/lingering. Read-only, same as everything
|
|
992
|
+
// else in this command; installing/removing is `install`/`uninstall`.
|
|
993
|
+
let service = { installed: false, enabled: false, active: false, linger: false };
|
|
994
|
+
try {
|
|
995
|
+
const execFn = ctx.execFn || realExecFn;
|
|
996
|
+
const existsFn = ctx.existsFn || existsSync;
|
|
997
|
+
const unitPath = ctx.unitPath || defaultUnitPath();
|
|
998
|
+
const user = ctx.osUser || userInfo().username;
|
|
999
|
+
service = await getServiceInstallStatus({ execFn, existsFn, unitPath, user });
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
service = { installed: false, enabled: false, active: false, linger: false, error: err.message };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const report = {
|
|
1005
|
+
host_id: hostId,
|
|
1006
|
+
enabled: settings.enabled,
|
|
1007
|
+
slot: settings.slot,
|
|
1008
|
+
poll_seconds: settings.poll_seconds,
|
|
1009
|
+
turn_timeout_seconds: settings.turn_timeout_seconds,
|
|
1010
|
+
max_turns_per_hour: settings.max_turns_per_hour,
|
|
1011
|
+
updated_at: settings.updated_at,
|
|
1012
|
+
updated_by: settings.updated_by,
|
|
1013
|
+
runner_live: live,
|
|
1014
|
+
last_log_activity: lastActivity ? new Date(lastActivity).toISOString() : null,
|
|
1015
|
+
state_bootstrapped: state.bootstrapped,
|
|
1016
|
+
state_decisions_recorded: (state.decisions || []).length,
|
|
1017
|
+
state_updated_at: state.updated_at,
|
|
1018
|
+
// Defect 2 fix: the last error-shaped turn (never posted to the
|
|
1019
|
+
// channel), so an operator can see WHY nothing came through without
|
|
1020
|
+
// reading the log file.
|
|
1021
|
+
last_turn_error: state.last_turn_error
|
|
1022
|
+
? `${state.last_turn_error.category} (${state.last_turn_error.type}) at ${state.last_turn_error.at}`
|
|
1023
|
+
: null,
|
|
1024
|
+
service_installed: service.installed,
|
|
1025
|
+
service_enabled: service.enabled,
|
|
1026
|
+
service_active: service.active,
|
|
1027
|
+
service_linger: service.linger,
|
|
1028
|
+
slot_held_by_self: heldBySelf,
|
|
1029
|
+
slot_held_by_other: foreignHolder,
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
formatDetail(report, { format: program.opts().output, title: `Supervisor status: ${hostId}` });
|
|
1033
|
+
if (!settings.enabled) {
|
|
1034
|
+
printWarning(`Supervisor is disabled for ${hostId} (server-side switch). \`quox dev supervisor run\` will exit immediately.`);
|
|
1035
|
+
} else if (!live) {
|
|
1036
|
+
printWarning(`No recent runner activity in ${logPath}. Is \`quox dev supervisor run\` actually running?`);
|
|
1037
|
+
} else {
|
|
1038
|
+
printSuccess(`Runner appears live (last activity ${report.last_log_activity}).`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (service.installed) {
|
|
1042
|
+
printInfo(` service: installed, ${service.enabled ? 'enabled' : 'NOT enabled'}, ${service.active ? 'active' : 'not active'}, linger ${service.linger ? 'ON' : 'OFF'}.`);
|
|
1043
|
+
if (!service.linger) {
|
|
1044
|
+
printWarning(' linger is OFF: this unit will not survive a reboot until an admin runs `loginctl enable-linger <user>` (or re-run `quox dev supervisor install`).');
|
|
1045
|
+
}
|
|
1046
|
+
} else {
|
|
1047
|
+
printInfo(` service: not installed. Run \`quox dev supervisor install\` for Option A persistence (survives reboot with no session open).`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (heldBySelf) {
|
|
1051
|
+
printSuccess(` slot ${slotName}: held by this resident (present).`);
|
|
1052
|
+
} else if (foreignHolder) {
|
|
1053
|
+
printWarning(` slot ${slotName} is held by ${foreignHolder}: this supervisor is present but not holding it (it never seizes a hold another session owns).`);
|
|
1054
|
+
} else {
|
|
1055
|
+
printWarning(` slot ${slotName}: not held by this resident and not held by anyone else either (free). It should claim it on its next poll pass.`);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (report.last_turn_error) {
|
|
1059
|
+
printWarning(` last turn failed: ${report.last_turn_error}`);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
}
|