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,933 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quox dev supervisor` engine, 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. Everything here is pure or takes
|
|
6
|
+
* its side effects (HTTP, child-process spawn, the clock) as injected
|
|
7
|
+
* functions, so the CLI command layer (`lib/commands/devSupervisor.js`) is a
|
|
8
|
+
* thin wrapper and the logic below can be driven directly from tests without
|
|
9
|
+
* spawning a real `claude` process or hitting a real collector.
|
|
10
|
+
*
|
|
11
|
+
* Contract this reads (a separate agent owns the write side):
|
|
12
|
+
* GET {collector}/api/v1/dev-servers/:hostId/supervisor
|
|
13
|
+
* -> {host_id, enabled, slot, poll_seconds, turn_timeout_seconds,
|
|
14
|
+
* max_turns_per_hour, updated_at, updated_by}
|
|
15
|
+
* Always returns a shape (enabled false + defaults) even for an unknown
|
|
16
|
+
* host. This module never writes to that endpoint.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// CLI-side fallback only. The collector's default is authoritative; these
|
|
20
|
+
// are used only if a field is missing or malformed in the response, so a
|
|
21
|
+
// half-formed settings payload never turns into "spawn as fast as possible".
|
|
22
|
+
export const DEFAULT_SETTINGS = {
|
|
23
|
+
enabled: false,
|
|
24
|
+
poll_seconds: 30,
|
|
25
|
+
turn_timeout_seconds: 120,
|
|
26
|
+
max_turns_per_hour: 6,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
// DEFECT FIX (2026-08-23, live incident: ~6 USD of paid turns burned working
|
|
32
|
+
// through hours-old backlog after a disabled -> enabled transition). Any
|
|
33
|
+
// owner message older than this is never eligible for a turn, however it
|
|
34
|
+
// became eligible. Belt to the disabled->enabled reseed's braces below:
|
|
35
|
+
// bounds paid churn even if some future code path reintroduces an unseeded
|
|
36
|
+
// cursor. Overridable via `--max-age-minutes` in the CLI command layer.
|
|
37
|
+
export const DEFAULT_MAX_MESSAGE_AGE_MINUTES = 15;
|
|
38
|
+
|
|
39
|
+
// How often the same distinct turn-error signature is allowed to post its
|
|
40
|
+
// one honest "I can't answer right now" notice into the channel. Silence
|
|
41
|
+
// forever is also a failure mode; this caps it to one signal per hour per
|
|
42
|
+
// distinct error, not spam and not nothing.
|
|
43
|
+
export const ERROR_NOTICE_WINDOW_MS = ONE_HOUR_MS;
|
|
44
|
+
|
|
45
|
+
// Option A persistence (service mode, DEVSUITE-DEVMODE follow-on).
|
|
46
|
+
//
|
|
47
|
+
// How long a `--service` runner idles between re-checks of the server-side
|
|
48
|
+
// switch once it finds itself disabled. Slow on purpose: this is a "did a
|
|
49
|
+
// human turn it back on" poll, not the message-latency path, so it costs
|
|
50
|
+
// nothing to keep long.
|
|
51
|
+
export const DEFAULT_IDLE_RECHECK_SECONDS = 60;
|
|
52
|
+
|
|
53
|
+
// How many consecutive long-poll (`slot wait`) failures the continuous run
|
|
54
|
+
// loop tolerates before it gives up on waiting and falls back to the
|
|
55
|
+
// original interval-poll loop for the rest of this process's life. This is
|
|
56
|
+
// the degrade-not-die seam: a collector that stops answering `/wait` must
|
|
57
|
+
// not take the runner down with it.
|
|
58
|
+
export const DEFAULT_WAIT_FAILURE_THRESHOLD = 3;
|
|
59
|
+
|
|
60
|
+
// LOOP TRIAGE FIX (diagnosed live 2026-09-10, .131): the resident supervisor
|
|
61
|
+
// held its QuoxSlot only via the `slot wait` long-poll's server-side
|
|
62
|
+
// heartbeat coupling, so an intermittent hang on the authenticated slot path
|
|
63
|
+
// (observed: "checkout attempt failed: Request timed out after 30000ms"
|
|
64
|
+
// while /health stayed instant, so this is a server-side hang, not a network
|
|
65
|
+
// one) wedged the long-poll and heartbeated nothing until it recovered. The
|
|
66
|
+
// hold then lapsed stale (15m TTL) then free (30m), and re-claimed roughly
|
|
67
|
+
// 45 minutes later: ~193 times over 6 days. This is the interval this
|
|
68
|
+
// module's independent heartbeat (see startIndependentHeartbeat below) ticks
|
|
69
|
+
// on: well under the 15m held-TTL so even a long stall is covered by many
|
|
70
|
+
// ticks, and independent of any single request's lifecycle.
|
|
71
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 20;
|
|
72
|
+
|
|
73
|
+
// How often the SAME independent-heartbeat failure signature (a 409, or a
|
|
74
|
+
// given error message) is allowed to log again. Same shape as
|
|
75
|
+
// ERROR_NOTICE_WINDOW_MS above but a much shorter window: this is a local
|
|
76
|
+
// log line, not an owner-facing channel notice, so it can afford to remind
|
|
77
|
+
// more often without being spam, while never logging once per 20s tick for
|
|
78
|
+
// the whole length of an outage.
|
|
79
|
+
export const HEARTBEAT_FAILURE_LOG_WINDOW_MS = 5 * 60 * 1000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Start an independent, self-scheduling heartbeat that keeps a slot hold
|
|
83
|
+
* warm on its OWN timer, decoupled from every other loop in this runner
|
|
84
|
+
* (the `slot wait` long-poll, a turn spawn, an idle re-check). Every tick is
|
|
85
|
+
* its own function call scheduled directly by the interval, not chained
|
|
86
|
+
* after the previous tick's promise settles, so a hung or slow
|
|
87
|
+
* postHeartbeat call on one tick can never block or cancel the next tick.
|
|
88
|
+
*
|
|
89
|
+
* 409 semantics are preserved exactly as elsewhere in this module (see
|
|
90
|
+
* attemptSlotClaim above and the idle-heartbeat handling in
|
|
91
|
+
* devSupervisor.js): a 409 means "not held by this resident" and is logged,
|
|
92
|
+
* never seized. This function never calls checkoutSlot or anything else
|
|
93
|
+
* that could take a hold from another session; it only ever heartbeats a
|
|
94
|
+
* hold this resident already has.
|
|
95
|
+
*
|
|
96
|
+
* Failure accounting follows the loop triad (see CLAUDE.md Loop & Restart
|
|
97
|
+
* Hygiene): successes and failures are counted distinctly (getCounts()),
|
|
98
|
+
* and a repeated identical failure logs at most once per
|
|
99
|
+
* HEARTBEAT_FAILURE_LOG_WINDOW_MS, never once per tick, so a long outage
|
|
100
|
+
* cannot spam the log forever while still never going silent for its whole
|
|
101
|
+
* duration.
|
|
102
|
+
*
|
|
103
|
+
* setIntervalFn/clearIntervalFn/now are injected so tests can drive and
|
|
104
|
+
* verify this with no real timer and full control over tick timing.
|
|
105
|
+
*/
|
|
106
|
+
export function startIndependentHeartbeat({
|
|
107
|
+
postHeartbeat,
|
|
108
|
+
intervalMs = DEFAULT_HEARTBEAT_INTERVAL_SECONDS * 1000,
|
|
109
|
+
log = () => {},
|
|
110
|
+
now = () => Date.now(),
|
|
111
|
+
setIntervalFn = setInterval,
|
|
112
|
+
clearIntervalFn = clearInterval,
|
|
113
|
+
}) {
|
|
114
|
+
let successCount = 0;
|
|
115
|
+
let failureCount = 0;
|
|
116
|
+
let notices = [];
|
|
117
|
+
|
|
118
|
+
const tick = async () => {
|
|
119
|
+
try {
|
|
120
|
+
await postHeartbeat();
|
|
121
|
+
successCount += 1;
|
|
122
|
+
if (notices.length > 0) {
|
|
123
|
+
log('info', 'independent heartbeat: recovered, ticking cleanly again.');
|
|
124
|
+
notices = [];
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
failureCount += 1;
|
|
128
|
+
const is409 = !!(err && err.status === 409);
|
|
129
|
+
const signature = is409 ? 'heartbeat_409' : `heartbeat_error:${err && err.message}`;
|
|
130
|
+
const notice = shouldPostErrorNotice(notices, signature, now(), HEARTBEAT_FAILURE_LOG_WINDOW_MS);
|
|
131
|
+
notices = notice.notices;
|
|
132
|
+
if (notice.shouldPost) {
|
|
133
|
+
if (is409) {
|
|
134
|
+
log('warn', 'independent heartbeat: 409 (not held by this resident), leaving it alone rather than seizing it.');
|
|
135
|
+
} else {
|
|
136
|
+
log('warn', `independent heartbeat failed, never stops ticking (will keep retrying every ${Math.round(intervalMs / 1000)}s): ${err && err.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Each tick is its own call, scheduled by the interval itself: a hung
|
|
143
|
+
// tick's promise is never awaited here, so the interval keeps firing on
|
|
144
|
+
// schedule regardless of how long, or whether, any single tick settles.
|
|
145
|
+
const timer = setIntervalFn(() => tick(), intervalMs);
|
|
146
|
+
if (timer && typeof timer.unref === 'function') {
|
|
147
|
+
try { timer.unref(); } catch { /* fake timers may not implement unref; harmless either way */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
stop: () => clearIntervalFn(timer),
|
|
152
|
+
getCounts: () => ({ successCount, failureCount }),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function numberOr(value, fallback) {
|
|
157
|
+
return Number.isFinite(value) ? value : fallback;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolve settings with the CLI-side fallback applied per-field. */
|
|
161
|
+
export function resolveSettings(raw) {
|
|
162
|
+
const s = raw || {};
|
|
163
|
+
return {
|
|
164
|
+
host_id: s.host_id ?? null,
|
|
165
|
+
enabled: s.enabled === true,
|
|
166
|
+
slot: s.slot ?? null,
|
|
167
|
+
poll_seconds: numberOr(s.poll_seconds, DEFAULT_SETTINGS.poll_seconds),
|
|
168
|
+
turn_timeout_seconds: numberOr(s.turn_timeout_seconds, DEFAULT_SETTINGS.turn_timeout_seconds),
|
|
169
|
+
max_turns_per_hour: numberOr(s.max_turns_per_hour, DEFAULT_SETTINGS.max_turns_per_hour),
|
|
170
|
+
updated_at: s.updated_at ?? null,
|
|
171
|
+
updated_by: s.updated_by ?? null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Owner-match rule (final, per spec, read this before touching the
|
|
177
|
+
* filtering below):
|
|
178
|
+
*
|
|
179
|
+
* 1. Any message with source === 'say' is NEVER a candidate. That source
|
|
180
|
+
* means it was posted through the slot `say` seam, which is exactly
|
|
181
|
+
* the seam this runner posts its own replies through.
|
|
182
|
+
* 2. If one or more --from names were given, a message is a candidate
|
|
183
|
+
* only when its `from` case-insensitively matches one of them. This is
|
|
184
|
+
* the configurable "who counts as the owner" allowlist.
|
|
185
|
+
* 3. With no --from given, a message is a candidate only when it fails
|
|
186
|
+
* BOTH self-checks: its `author_identity` (server-derived, unforgeable
|
|
187
|
+
* — see devSlotMessagesStore.deriveCallerIdentity) does not equal
|
|
188
|
+
* `selfIdentity`, AND its `from` label does not equal `selfIdentity`
|
|
189
|
+
* either. `author_identity` is the primary, trustworthy check: `from`
|
|
190
|
+
* is caller-controlled free text with no server-side check, so a
|
|
191
|
+
* message can claim to be from anyone while its author_identity still
|
|
192
|
+
* names this resident. That is the impersonation case this must catch
|
|
193
|
+
* to avoid a self-loop (a session's own posts rendering under a
|
|
194
|
+
* different session's name, found live 2026-08-23). The `from` check
|
|
195
|
+
* stays as an additional filter, not the removed primary one, since a
|
|
196
|
+
* message with no author_identity at all (rows written before
|
|
197
|
+
* migrations/aee/022, or by callers that predate identity resolution)
|
|
198
|
+
* still needs a check to fall back on.
|
|
199
|
+
*
|
|
200
|
+
* The invariant that must hold across both branches: this can never answer
|
|
201
|
+
* its own post, and (enforced by the caller, pickNewMessages) never
|
|
202
|
+
* re-answers a message id it has already seen.
|
|
203
|
+
*/
|
|
204
|
+
export function isOwnerMessage(message, { selfIdentity, fromFilters = [] } = {}) {
|
|
205
|
+
if (!message) return false;
|
|
206
|
+
// `source === 'say'` used to be excluded outright, because that is the seam
|
|
207
|
+
// the runner posts its own replies through and there was no trustworthy way
|
|
208
|
+
// to tell its posts from anyone else's. The cost, found live 2026-08-23:
|
|
209
|
+
// a HUMAN using `quox slot say` could never reach a resident at all, which
|
|
210
|
+
// makes the CLI useless for talking to one. Now that author_identity is
|
|
211
|
+
// server-stamped and unforgeable (mig 022), exclude the actual thing that
|
|
212
|
+
// must be excluded (posts THIS resident wrote) rather than everything that
|
|
213
|
+
// used the CLI. Legacy rows carry no author_identity, so for those the old
|
|
214
|
+
// blunt rule still applies: with no verifiable author there is no safe way
|
|
215
|
+
// to tell a self-post from a human's, and a self-loop is the worse failure.
|
|
216
|
+
if (message.source === 'say' && message.author_identity == null) return false;
|
|
217
|
+
if (fromFilters && fromFilters.length > 0) {
|
|
218
|
+
const from = String(message.from || '').toLowerCase();
|
|
219
|
+
return fromFilters.some((f) => String(f).toLowerCase() === from);
|
|
220
|
+
}
|
|
221
|
+
const self = String(selfIdentity || '');
|
|
222
|
+
const isSelfByIdentity = message.author_identity != null && String(message.author_identity) === self;
|
|
223
|
+
const isSelfByLabel = String(message.from || '') === self;
|
|
224
|
+
return !isSelfByIdentity && !isSelfByLabel;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The identity this runner posts its own replies as. See isOwnerMessage. */
|
|
228
|
+
export function buildSelfIdentity(hostId) {
|
|
229
|
+
return `resident:${hostId}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const CLAIM_SAFETY_INTERVAL_MS = 10 * 60 * 1000;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Local "I hold this slot" memory shared across poll passes (HOT LOOP FIX,
|
|
236
|
+
* 2026-10-11: the collector returns success for a same-holder checkout, so
|
|
237
|
+
* calling checkout on every 15 s pass cost a full checkout each time).
|
|
238
|
+
* After a successful claim, attemptSlotClaim only heartbeats; it re-checks
|
|
239
|
+
* out when the heartbeat says 409/404 or after CLAIM_SAFETY_INTERVAL_MS.
|
|
240
|
+
*/
|
|
241
|
+
export function createClaimState() {
|
|
242
|
+
return { held: false, claimedAt: 0 };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* PRESENCE FIX (item 2, live outage 2026-08-23): both residents' slots sat
|
|
247
|
+
* FREE the whole time their supervisors were running, because nothing in
|
|
248
|
+
* this runner ever called checkout, only heartbeat, and heartbeat requires
|
|
249
|
+
* an existing hold and does nothing when the slot is free. Presence is half
|
|
250
|
+
* the point of a resident; a supervisor that runs but never holds its slot
|
|
251
|
+
* reads as absent to anyone watching.
|
|
252
|
+
*
|
|
253
|
+
* Attempts to check the slot out as `selfIdentity`. The collector's
|
|
254
|
+
* checkoutSlot is itself a compare-and-swap on `state = 'free'`, so this is
|
|
255
|
+
* safe to call unconditionally on every poll pass: a slot that is already
|
|
256
|
+
* free gets claimed, a slot this resident already holds 409s harmlessly
|
|
257
|
+
* (the row is `held`, not `free`, whoever holds it), and a slot held by a
|
|
258
|
+
* DIFFERENT session also 409s, never seized, exactly the rule already in
|
|
259
|
+
* force for the idle heartbeat 409 below. `checkoutSlot` is optional so
|
|
260
|
+
* every existing caller that does not pass one keeps working unchanged.
|
|
261
|
+
*
|
|
262
|
+
* Never throws: any failure (409 or otherwise) is caught, logged, and
|
|
263
|
+
* treated as "did not claim this pass", so a claim attempt can never take
|
|
264
|
+
* the runner down.
|
|
265
|
+
*/
|
|
266
|
+
export async function attemptSlotClaim({
|
|
267
|
+
checkoutSlot, selfIdentity, slot, log = () => {}, announceForeignHold = true,
|
|
268
|
+
claimState = null, postHeartbeat = null, now = () => Date.now(), safetyIntervalMs = CLAIM_SAFETY_INTERVAL_MS,
|
|
269
|
+
}) {
|
|
270
|
+
if (!checkoutSlot) return { claimed: false, reason: 'no_checkout_fn' };
|
|
271
|
+
if (claimState && claimState.held && postHeartbeat && now() - claimState.claimedAt < safetyIntervalMs) {
|
|
272
|
+
try {
|
|
273
|
+
await postHeartbeat();
|
|
274
|
+
return { claimed: false, reason: 'already_held' };
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if (!(err && (err.status === 409 || err.status === 404))) {
|
|
277
|
+
log('warn', `slot ${slot}: claim-pass heartbeat failed: ${err && err.message}`);
|
|
278
|
+
return { claimed: false, reason: 'error', error: err };
|
|
279
|
+
}
|
|
280
|
+
claimState.held = false;
|
|
281
|
+
claimState.lapsed = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const wasHeld = !!(claimState && claimState.everHeld);
|
|
286
|
+
const res = await checkoutSlot();
|
|
287
|
+
if (claimState) { claimState.held = true; claimState.everHeld = true; claimState.claimedAt = now(); }
|
|
288
|
+
const refreshed = !!(res && res.refreshed);
|
|
289
|
+
// GRIPE-797: log state transitions only. A periodic safety re-checkout
|
|
290
|
+
// of a hold this process already had is a renewal, not a new claim.
|
|
291
|
+
if (wasHeld && claimState && !claimState.lapsed) return { claimed: true, refreshed: true };
|
|
292
|
+
if (claimState) claimState.lapsed = false;
|
|
293
|
+
log('info', refreshed
|
|
294
|
+
? `slot ${slot}: already held by this resident, refreshed.`
|
|
295
|
+
: `slot ${slot}: claimed (was free), now present.`);
|
|
296
|
+
return refreshed ? { claimed: true, refreshed } : { claimed: true };
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (claimState) { claimState.held = false; claimState.lapsed = true; }
|
|
299
|
+
if (err && err.status === 409) {
|
|
300
|
+
const holder = err.detail?.holder_session || null;
|
|
301
|
+
if (holder && holder !== selfIdentity) {
|
|
302
|
+
if (announceForeignHold) {
|
|
303
|
+
log('warn', `slot ${slot}: checkout refused (409), held by ${holder}, leaving it alone rather than seizing it. Run \`quox dev supervisor status\` to see who holds it.`);
|
|
304
|
+
}
|
|
305
|
+
return { claimed: false, reason: 'held_by_other', holder };
|
|
306
|
+
}
|
|
307
|
+
// Held by us already (older collectors 409 a same-holder checkout).
|
|
308
|
+
if (claimState) { claimState.held = true; claimState.claimedAt = now(); }
|
|
309
|
+
return { claimed: false, reason: 'already_held' };
|
|
310
|
+
}
|
|
311
|
+
log('warn', `slot ${slot}: checkout attempt failed: ${err.message}`);
|
|
312
|
+
return { claimed: false, reason: 'error', error: err };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Pick which messages are new answerable owner turns.
|
|
318
|
+
*
|
|
319
|
+
* On the very first run for a fresh state file (firstRun: true), every
|
|
320
|
+
* currently-visible message id is seeded into the seen-set and NOTHING is
|
|
321
|
+
* answered: a resident that just came under supervision must not suddenly
|
|
322
|
+
* reply to a channel's entire backlog.
|
|
323
|
+
*/
|
|
324
|
+
export function pickNewMessages({ messages, seenIds, selfIdentity, fromFilters = [], firstRun }) {
|
|
325
|
+
const seen = new Set(seenIds || []);
|
|
326
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
327
|
+
const allIds = list.map((m) => m.id).filter((id) => id != null);
|
|
328
|
+
|
|
329
|
+
if (firstRun) {
|
|
330
|
+
return {
|
|
331
|
+
toAnswer: [],
|
|
332
|
+
seenIds: Array.from(new Set([...seen, ...allIds])),
|
|
333
|
+
allSeen: allIds,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const toAnswer = [];
|
|
338
|
+
for (const m of list) {
|
|
339
|
+
if (m.id == null || seen.has(m.id)) continue;
|
|
340
|
+
if (isOwnerMessage(m, { selfIdentity, fromFilters })) toAnswer.push(m);
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
toAnswer,
|
|
344
|
+
seenIds: Array.from(new Set([...seen, ...allIds])),
|
|
345
|
+
allSeen: allIds,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Bounding helpers. Keep the continuity state file flat over a resident's
|
|
350
|
+
// whole life so per-turn cost (prompt size) stays constant.
|
|
351
|
+
|
|
352
|
+
export function boundSeenIds(ids, max = 500) {
|
|
353
|
+
const arr = Array.from(new Set(ids || []));
|
|
354
|
+
return arr.length > max ? arr.slice(arr.length - max) : arr;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function boundDecisions(list, max = 20) {
|
|
358
|
+
const arr = list || [];
|
|
359
|
+
return arr.length > max ? arr.slice(arr.length - max) : arr;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function boundExchanges(list, max = 10) {
|
|
363
|
+
const arr = list || [];
|
|
364
|
+
return arr.length > max ? arr.slice(arr.length - max) : arr;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Rate limiting.
|
|
368
|
+
|
|
369
|
+
export function pruneOldTurns(timestamps, now, windowMs = ONE_HOUR_MS) {
|
|
370
|
+
return (timestamps || []).filter((t) => now - t < windowMs);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Age of a message in ms, or null when it carries no usable timestamp.
|
|
375
|
+
* Never treats "no timestamp" as stale: that would silently swallow
|
|
376
|
+
* messages from a source that just doesn't send one, which is a worse
|
|
377
|
+
* failure than the churn this guard exists to prevent.
|
|
378
|
+
*/
|
|
379
|
+
export function messageAgeMs(message, now) {
|
|
380
|
+
const ts = message?.created_at ?? message?.timestamp ?? message?.ts ?? null;
|
|
381
|
+
if (!ts) return null;
|
|
382
|
+
const t = new Date(ts).getTime();
|
|
383
|
+
return Number.isFinite(t) ? now - t : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Distinct-signature dedup for the turn-error notice (defect 2). Same shape
|
|
387
|
+
// as pruneOldTurns but keyed on {signature, at} pairs rather than bare
|
|
388
|
+
// timestamps, since dedup is per-signature, not a flat rate cap.
|
|
389
|
+
export function pruneErrorNotices(notices, now, windowMs = ERROR_NOTICE_WINDOW_MS) {
|
|
390
|
+
return (notices || []).filter((n) => n && Number.isFinite(n.at) && now - n.at < windowMs);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Decide whether a turn-error notice for `signature` may post right now.
|
|
395
|
+
* Returns the pruned+updated notices list either way, so the caller always
|
|
396
|
+
* persists a clean list (this is also where the "pruned" half of "tracked
|
|
397
|
+
* in state and pruned" happens, on every error, not just on dedup hits).
|
|
398
|
+
*/
|
|
399
|
+
export function shouldPostErrorNotice(notices, signature, now, windowMs = ERROR_NOTICE_WINDOW_MS) {
|
|
400
|
+
const pruned = pruneErrorNotices(notices, now, windowMs);
|
|
401
|
+
const alreadyNotified = pruned.some((n) => n.signature === signature);
|
|
402
|
+
if (alreadyNotified) return { shouldPost: false, notices: pruned };
|
|
403
|
+
return { shouldPost: true, notices: [...pruned, { signature, at: now }] };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Turn prompt.
|
|
407
|
+
//
|
|
408
|
+
// SUPERVISOR_TURN_PROMPT is built by buildTurnPrompt below and is the exact
|
|
409
|
+
// text sent to every headless `claude -p` turn. Kept as one clearly-marked,
|
|
410
|
+
// reviewable function: every safeguard promised for this runner (proof
|
|
411
|
+
// before claim, no fabrication, no em dashes, reply text only) is enforced
|
|
412
|
+
// here or nowhere.
|
|
413
|
+
export function buildTurnPrompt({ hostId, slot, message, history, state }) {
|
|
414
|
+
const historyBlock = (history || [])
|
|
415
|
+
.map((h) => `${h.from}: ${h.text}${h.reply ? `\n (you replied: ${h.reply})` : ''}`)
|
|
416
|
+
.join('\n') || '(no prior channel history)';
|
|
417
|
+
const decisionsBlock = (state?.decisions || [])
|
|
418
|
+
.slice(-5)
|
|
419
|
+
.map((d) => `- ${d.at}: replied to ${d.from} (${d.reply_excerpt})`)
|
|
420
|
+
.join('\n') || '(none recorded yet)';
|
|
421
|
+
|
|
422
|
+
return [
|
|
423
|
+
`You are the resident agent for dev host "${hostId}", answering its slot channel "${slot}" with no interactive session open.`,
|
|
424
|
+
'',
|
|
425
|
+
'Recent channel history (most recent last):',
|
|
426
|
+
historyBlock,
|
|
427
|
+
'',
|
|
428
|
+
'Recent decisions/focus you recorded:',
|
|
429
|
+
decisionsBlock,
|
|
430
|
+
'',
|
|
431
|
+
'The owner just said:',
|
|
432
|
+
`${message.from}: ${message.text}`,
|
|
433
|
+
'',
|
|
434
|
+
'Rules:',
|
|
435
|
+
'- Prove any claim before making it. State what you actually checked, never assume or fabricate a result.',
|
|
436
|
+
'- Never use em dashes or double hyphens in your reply.',
|
|
437
|
+
'- Reply with the message text only: no preamble, no headers, no code fences unless the reply itself is code.',
|
|
438
|
+
'- If you are not sure, say so plainly rather than guessing.',
|
|
439
|
+
].join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* DEFECT FIX (2026-08-23, live incident: a raw provider error, e.g.
|
|
444
|
+
* `{"type":"not_found_error","message":"model: fable"}` from a
|
|
445
|
+
* misconfigured headless model, got posted verbatim into the slot channel
|
|
446
|
+
* as the resident's reply).
|
|
447
|
+
*
|
|
448
|
+
* Detects a failed or error-shaped turn by three independent signals (any
|
|
449
|
+
* one is enough): a non-zero exit code, an `is_error`/`error` field in the
|
|
450
|
+
* parsed JSON output, or a body that parses (directly, or embedded in a
|
|
451
|
+
* non-JSON stdout such as a "API Error: 404 {...}" preamble the CLI can
|
|
452
|
+
* print on error paths) as a provider error object shaped
|
|
453
|
+
* `{type: "..._error", message: "..."}`. Returns null for a clean turn.
|
|
454
|
+
*/
|
|
455
|
+
export function detectTurnError(result) {
|
|
456
|
+
if (!result || result.timedOut || result.error) return null;
|
|
457
|
+
|
|
458
|
+
const raw = result.raw;
|
|
459
|
+
let errorObj = null;
|
|
460
|
+
if (raw && typeof raw === 'object') {
|
|
461
|
+
if (raw.is_error === true) errorObj = (raw.error && typeof raw.error === 'object') ? raw.error : raw;
|
|
462
|
+
else if (raw.error && typeof raw.error === 'object') errorObj = raw.error;
|
|
463
|
+
else if (typeof raw.type === 'string' && /_error$/i.test(raw.type)) errorObj = raw;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (!errorObj) {
|
|
467
|
+
// Fallback: the provider error object may be embedded in non-JSON
|
|
468
|
+
// stdout (a preamble broke JSON.parse), in either field order.
|
|
469
|
+
const candidate = String(result.text || result.stdout || '');
|
|
470
|
+
const m = candidate.match(/"type"\s*:\s*"([a-z_]+_error)"[^{}]*"message"\s*:\s*"([^"]*)"/i)
|
|
471
|
+
|| candidate.match(/"message"\s*:\s*"([^"]*)"[^{}]*"type"\s*:\s*"([a-z_]+_error)"/i);
|
|
472
|
+
if (m) {
|
|
473
|
+
const [, a, b] = m;
|
|
474
|
+
errorObj = /_error$/i.test(a) ? { type: a, message: b } : { type: b, message: a };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const nonZeroExit = Number.isInteger(result.exitCode) && result.exitCode !== 0;
|
|
479
|
+
if (!errorObj && !nonZeroExit) return null;
|
|
480
|
+
|
|
481
|
+
const type = errorObj?.type || 'nonzero_exit';
|
|
482
|
+
const message = errorObj?.message || `claude exited with code ${result.exitCode}`;
|
|
483
|
+
const category = /model/i.test(`${type} ${message}`) ? 'model configuration problem' : 'provider error';
|
|
484
|
+
const signature = `${type}:${message}`.slice(0, 200);
|
|
485
|
+
return { type, message, category, signature };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* GRIPE-797: an expired provider login (claude "Failed to authenticate:
|
|
490
|
+
* OAuth session expired and could not be refreshed", codex "not logged in"
|
|
491
|
+
* or a 401) is NOT a generic provider error: the fix is a human running a
|
|
492
|
+
* login command on this host. Returns {provider, command} or null.
|
|
493
|
+
*/
|
|
494
|
+
export function detectAuthExpiry(result) {
|
|
495
|
+
if (!result || result.timedOut || result.error) return null;
|
|
496
|
+
// Only a FAILED turn can be an expiry: a successful reply that merely
|
|
497
|
+
// talks about logins or 401s is a reply, never an alert.
|
|
498
|
+
if (!detectTurnError(result)) return null;
|
|
499
|
+
const parts = [result.text, result.stdout, result.stderr];
|
|
500
|
+
const raw = result.raw;
|
|
501
|
+
if (raw && typeof raw === 'object') {
|
|
502
|
+
parts.push(raw.result, raw.message, raw.error && (raw.error.message || raw.error));
|
|
503
|
+
}
|
|
504
|
+
const blob = parts.filter((x) => typeof x === 'string').join('\n');
|
|
505
|
+
if (!blob) return null;
|
|
506
|
+
if (/failed to authenticate|oauth session expired|could not be refreshed|invalid authentication credentials|please run \/login/i.test(blob)) {
|
|
507
|
+
return { provider: 'claude', command: 'claude /login' };
|
|
508
|
+
}
|
|
509
|
+
if (/not logged in|codex login|401 unauthorized|\b401\b[^\n]{0,40}(unauthorized|authentication)|token (has )?expired/i.test(blob)) {
|
|
510
|
+
const claudeish = /claude/i.test(blob) && !/codex/i.test(blob);
|
|
511
|
+
return claudeish ? { provider: 'claude', command: 'claude /login' } : { provider: 'codex', command: 'codex login' };
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function buildAuthExpiryNoticeText(hostId, auth) {
|
|
517
|
+
const cmd = auth && auth.provider === 'codex' ? 'codex login' : 'claude /login';
|
|
518
|
+
const alt = auth && auth.provider === 'codex' ? '(or `claude /login`)' : '(or `codex login`)';
|
|
519
|
+
return `My login on ${hostId} has expired; run \`${cmd}\` ${alt} on ${hostId}.`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* One short, plain-language, single-sentence notice: no stack, no JSON, no
|
|
524
|
+
* raw provider payload. This is the ONE honest signal an operator gets per
|
|
525
|
+
* distinct error signature per hour; silence forever is also wrong.
|
|
526
|
+
*/
|
|
527
|
+
export function buildErrorNoticeText(category) {
|
|
528
|
+
return `I cannot answer right now because of a ${category}; a human should check this host's configuration.`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Turn execution.
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Parse the output of `claude -p ... --output-format json`. Falls back to
|
|
535
|
+
* treating raw stdout as the reply text when it is not valid JSON.
|
|
536
|
+
*/
|
|
537
|
+
export function parseTurnOutput(stdout) {
|
|
538
|
+
const trimmed = (stdout || '').trim();
|
|
539
|
+
if (!trimmed) return { text: '', session_id: null, total_cost_usd: null, raw: null };
|
|
540
|
+
try {
|
|
541
|
+
const json = JSON.parse(trimmed);
|
|
542
|
+
const text = typeof json.result === 'string' ? json.result
|
|
543
|
+
: typeof json.response === 'string' ? json.response
|
|
544
|
+
: typeof json.text === 'string' ? json.text
|
|
545
|
+
: '';
|
|
546
|
+
return {
|
|
547
|
+
text,
|
|
548
|
+
session_id: json.session_id || null,
|
|
549
|
+
total_cost_usd: typeof json.total_cost_usd === 'number' ? json.total_cost_usd : null,
|
|
550
|
+
raw: json,
|
|
551
|
+
};
|
|
552
|
+
} catch {
|
|
553
|
+
return { text: trimmed, session_id: null, total_cost_usd: null, raw: null };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Run ONE bounded headless turn via `claude -p <prompt> --output-format
|
|
559
|
+
* json`, killed at timeoutSeconds. spawnFn is injected (defaults to
|
|
560
|
+
* node:child_process spawn in the command layer) so tests never execute a
|
|
561
|
+
* real claude process.
|
|
562
|
+
*
|
|
563
|
+
* NEVER sets ANTHROPIC_API_KEY or reads it: the spawned process inherits
|
|
564
|
+
* whatever the deploying user has already configured for the claude CLI
|
|
565
|
+
* (subscription/OAuth by default).
|
|
566
|
+
*/
|
|
567
|
+
export function runClaudeTurn({ spawnFn, prompt, timeoutSeconds = DEFAULT_SETTINGS.turn_timeout_seconds }) {
|
|
568
|
+
return new Promise((resolve) => {
|
|
569
|
+
let child;
|
|
570
|
+
try {
|
|
571
|
+
// QUOX_SUPERVISOR_TURN marks this claude process as a headless
|
|
572
|
+
// supervisor turn, so user-level SessionStart hooks can stay silent
|
|
573
|
+
// (2026-08-23 incident: a wake hook on .131 fired on every turn and
|
|
574
|
+
// posted its "awake" announcement per inbound message all morning).
|
|
575
|
+
child = spawnFn('claude', ['-p', prompt, '--output-format', 'json'], {
|
|
576
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
577
|
+
env: { ...process.env, QUOX_SUPERVISOR_TURN: '1' },
|
|
578
|
+
});
|
|
579
|
+
} catch (err) {
|
|
580
|
+
resolve({ timedOut: false, error: err, text: '', session_id: null, total_cost_usd: null, raw: null });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
let stdout = '';
|
|
585
|
+
let stderr = '';
|
|
586
|
+
let settled = false;
|
|
587
|
+
|
|
588
|
+
const timer = setTimeout(() => {
|
|
589
|
+
if (settled) return;
|
|
590
|
+
settled = true;
|
|
591
|
+
try { child.kill('SIGKILL'); } catch { /* already gone, nothing to clean up */ }
|
|
592
|
+
resolve({ timedOut: true, stdout, stderr, text: '', session_id: null, total_cost_usd: null, raw: null });
|
|
593
|
+
}, Math.max(1, timeoutSeconds) * 1000);
|
|
594
|
+
|
|
595
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
596
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
597
|
+
|
|
598
|
+
child.on('error', (err) => {
|
|
599
|
+
if (settled) return;
|
|
600
|
+
settled = true;
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
resolve({ timedOut: false, error: err, stdout, stderr, text: '', session_id: null, total_cost_usd: null, raw: null });
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
child.on('close', (code) => {
|
|
606
|
+
if (settled) return;
|
|
607
|
+
settled = true;
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
resolve({ timedOut: false, exitCode: code, stdout, stderr, ...parseTurnOutput(stdout) });
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// State.
|
|
615
|
+
|
|
616
|
+
export function createInitialState() {
|
|
617
|
+
return {
|
|
618
|
+
bootstrapped: false,
|
|
619
|
+
seen_ids: [],
|
|
620
|
+
turn_timestamps: [],
|
|
621
|
+
decisions: [],
|
|
622
|
+
recent_exchanges: [],
|
|
623
|
+
host_id: null,
|
|
624
|
+
slot: null,
|
|
625
|
+
updated_at: null,
|
|
626
|
+
// last_enabled_state (defect 1 fix): null until the first pass ever
|
|
627
|
+
// observes the switch. Recognises a disabled -> enabled transition
|
|
628
|
+
// across process restarts (persisted, so a --service unit stopped or
|
|
629
|
+
// rebooted while disabled still reseeds on its very next enabled pass,
|
|
630
|
+
// rather than replaying the whole backlog it was away for).
|
|
631
|
+
last_enabled_state: null,
|
|
632
|
+
// last_turn_error / error_notices (defect 2 fix): the most recent
|
|
633
|
+
// error-shaped turn (for `status` to surface "last turn failed: ..."),
|
|
634
|
+
// and the per-signature once-per-hour notice dedup list.
|
|
635
|
+
last_turn_error: null,
|
|
636
|
+
error_notices: [],
|
|
637
|
+
// held_by_other_notices (QUOXPERTICUS-REVISIT P2, HOLD-IS-THE-VOICE
|
|
638
|
+
// gate): same per-signature once-per-hour dedup shape as error_notices
|
|
639
|
+
// above, keyed on the foreign holder's session id, so a supervisor
|
|
640
|
+
// sitting silent while another session holds its slot posts one
|
|
641
|
+
// witnessed receipt per holder-change or per hour, never one per poll.
|
|
642
|
+
held_by_other_notices: [],
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Apply every bounding rule at once. Call before persisting state. */
|
|
647
|
+
export function boundState(state, { maxSeenIds = 500, maxDecisions = 20, maxExchanges = 10 } = {}) {
|
|
648
|
+
return {
|
|
649
|
+
...state,
|
|
650
|
+
seen_ids: boundSeenIds(state.seen_ids, maxSeenIds),
|
|
651
|
+
decisions: boundDecisions(state.decisions, maxDecisions),
|
|
652
|
+
recent_exchanges: boundExchanges(state.recent_exchanges, maxExchanges),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function truncate(text, max = 120) {
|
|
657
|
+
const s = String(text || '');
|
|
658
|
+
return s.length > max ? `${s.slice(0, max)}...` : s;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Run one full poll pass: fetch settings (kill-switch check), fetch
|
|
663
|
+
* messages, pick new owner turns, spawn one bounded headless turn per
|
|
664
|
+
* message (sequential, never concurrent), post replies, and update state.
|
|
665
|
+
*
|
|
666
|
+
* Every I/O dependency is injected so this can run under vitest with no
|
|
667
|
+
* real network, no real collector, and no real claude process:
|
|
668
|
+
* fetchSettings() -> raw settings object (per the contract)
|
|
669
|
+
* fetchMessages() -> array of slot messages
|
|
670
|
+
* postReply(text) -> posts the reply via slot say
|
|
671
|
+
* spawnTurn(prompt, s) -> runs one headless turn, returns runClaudeTurn's shape
|
|
672
|
+
* log(level, message) -> logger (startup/inbound/replies/skips/rate-limit/shutdown)
|
|
673
|
+
*/
|
|
674
|
+
export async function runPollOnce({
|
|
675
|
+
hostId,
|
|
676
|
+
slot,
|
|
677
|
+
selfIdentity,
|
|
678
|
+
fromFilters = [],
|
|
679
|
+
fetchSettings,
|
|
680
|
+
fetchMessages,
|
|
681
|
+
postReply,
|
|
682
|
+
spawnTurn,
|
|
683
|
+
checkoutSlot = null,
|
|
684
|
+
claimState = null,
|
|
685
|
+
postHeartbeat = null,
|
|
686
|
+
postTurnReceipt = null,
|
|
687
|
+
alertOwner = null,
|
|
688
|
+
state,
|
|
689
|
+
log = () => {},
|
|
690
|
+
now = () => Date.now(),
|
|
691
|
+
historyWindow = 10,
|
|
692
|
+
maxDecisions = 20,
|
|
693
|
+
maxSeenIds = 500,
|
|
694
|
+
maxAgeMinutes = DEFAULT_MAX_MESSAGE_AGE_MINUTES,
|
|
695
|
+
}) {
|
|
696
|
+
// PRESENCE FIX (item 2, live outage 2026-08-23): attempted on every poll
|
|
697
|
+
// pass, enabled or disabled, so a free slot is claimed the moment it is
|
|
698
|
+
// seen free rather than staying present-but-never-holding. See
|
|
699
|
+
// attemptSlotClaim above for the full rationale; this never throws.
|
|
700
|
+
const claimResult = await attemptSlotClaim({ checkoutSlot, selfIdentity, slot, log, claimState, postHeartbeat });
|
|
701
|
+
|
|
702
|
+
// QP-DASH P1: every ATTEMPTED turn posts a receipt (outcome + cost +
|
|
703
|
+
// duration) so spend is legible server-side; the 2026-08-23 backlog churn
|
|
704
|
+
// (~$6) was invisible without this. Strictly fail-soft: a receipts outage
|
|
705
|
+
// must never break answering.
|
|
706
|
+
const receipt = async (fields) => {
|
|
707
|
+
if (!postTurnReceipt) return;
|
|
708
|
+
try {
|
|
709
|
+
await postTurnReceipt(fields);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
log('warn', `turn receipt failed (never blocks the turn): ${err.message}`);
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
const rawSettings = await fetchSettings();
|
|
715
|
+
const settings = resolveSettings(rawSettings);
|
|
716
|
+
|
|
717
|
+
if (!settings.enabled) {
|
|
718
|
+
// This IS the kill switch. Honour it promptly: no messages are fetched,
|
|
719
|
+
// no turn is spawned, and the caller (the CLI's `run` loop) is told to
|
|
720
|
+
// stop rather than sleep-and-retry. Record last_enabled_state:false so
|
|
721
|
+
// the NEXT enabled pass (even after a process restart while disabled;
|
|
722
|
+
// this is persisted state, not in-memory) recognises itself as a
|
|
723
|
+
// disabled -> enabled transition and reseeds instead of replaying the
|
|
724
|
+
// backlog it was away for (defect 1).
|
|
725
|
+
log('info', `kill switch: supervisor disabled for ${hostId} (enabled=false). Exiting cleanly.`);
|
|
726
|
+
return { state: { ...state, last_enabled_state: false }, settings, stopped: true, turnsRun: 0 };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// HOLD-IS-THE-VOICE gate (QUOXPERTICUS-REVISIT P2): the supervisor must be
|
|
730
|
+
// structurally SILENT while any OTHER session holds its slot; it answers
|
|
731
|
+
// only while it holds the slot itself (claimed just now, or already_held
|
|
732
|
+
// from a prior pass). attemptSlotClaim above already never seizes a
|
|
733
|
+
// foreign hold; this is what makes that refusal ALSO mean "never fetch,
|
|
734
|
+
// never spawn, never reply", not just "never checkout". Placed after the
|
|
735
|
+
// kill-switch check above (so a disabled pass still returns there,
|
|
736
|
+
// unchanged) and strictly before fetchMessages: no message is ever read
|
|
737
|
+
// while another session holds the slot, so nothing can be answered by
|
|
738
|
+
// accident. Deduped per foreign holder, once per hour (reuses the
|
|
739
|
+
// error-notice dedup shape), so a poll every 30s does not spam a receipt
|
|
740
|
+
// every pass while the same other session sits on the slot for hours.
|
|
741
|
+
if (!claimResult.claimed && claimResult.reason === 'held_by_other') {
|
|
742
|
+
const holder = claimResult.holder || 'unknown';
|
|
743
|
+
const notice = shouldPostErrorNotice(state.held_by_other_notices || [], holder, now());
|
|
744
|
+
const heldState = { ...state, held_by_other_notices: notice.notices };
|
|
745
|
+
if (notice.shouldPost) {
|
|
746
|
+
log('info', `slot ${slot}: held by ${holder}, staying silent (no messages fetched, no turns run) until this resident holds it.`);
|
|
747
|
+
await receipt({ outcome: 'silent_held_by_other', held_by: holder });
|
|
748
|
+
}
|
|
749
|
+
return { state: heldState, settings, stopped: false, turnsRun: 0 };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Defect 1 fix, part (a): a pass that finds itself going disabled ->
|
|
753
|
+
// enabled must reseed exactly like a first run (seed the seen-set from
|
|
754
|
+
// whatever is currently visible, answer none of it), never treat the
|
|
755
|
+
// whole backlog as fresh. wasDisabled is read from state BEFORE any
|
|
756
|
+
// mutation below, and survives a process restart because it was
|
|
757
|
+
// persisted on the disabled pass above.
|
|
758
|
+
const firstRun = state.bootstrapped !== true;
|
|
759
|
+
const wasDisabled = state.last_enabled_state === false;
|
|
760
|
+
const reenableReseed = !firstRun && wasDisabled;
|
|
761
|
+
const seedPass = firstRun || reenableReseed;
|
|
762
|
+
|
|
763
|
+
const messages = await fetchMessages();
|
|
764
|
+
const picked = pickNewMessages({
|
|
765
|
+
messages,
|
|
766
|
+
seenIds: state.seen_ids,
|
|
767
|
+
selfIdentity,
|
|
768
|
+
fromFilters,
|
|
769
|
+
firstRun: seedPass,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
let nextState = { ...state, seen_ids: boundSeenIds(picked.seenIds, maxSeenIds) };
|
|
773
|
+
|
|
774
|
+
if (firstRun) {
|
|
775
|
+
nextState = { ...nextState, bootstrapped: true };
|
|
776
|
+
log('info', `first run: seeded seen-set with ${picked.allSeen.length} existing message id(s), answering none.`);
|
|
777
|
+
} else if (reenableReseed) {
|
|
778
|
+
log('info', `re-enabled after being disabled: seeded seen-set with ${picked.allSeen.length} message id(s) visible right now, answering none (stale-backlog churn guard, defect fixed 2026-08-23).`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
let turnsRun = 0;
|
|
782
|
+
let staleSkipped = 0;
|
|
783
|
+
for (const message of picked.toAnswer) {
|
|
784
|
+
// Defect 1 fix, part (b): the absolute max-age guard. Belt to (a)'s
|
|
785
|
+
// braces, independent of how the message became eligible: a message
|
|
786
|
+
// older than maxAgeMinutes is marked seen (already folded into
|
|
787
|
+
// seen_ids above) and skipped, never answered, regardless of the
|
|
788
|
+
// disabled/enabled transition logic.
|
|
789
|
+
const ageMs = messageAgeMs(message, now());
|
|
790
|
+
if (ageMs != null && ageMs > maxAgeMinutes * 60 * 1000) {
|
|
791
|
+
staleSkipped += 1;
|
|
792
|
+
log('warn', `message ${message.id} from ${message.from} skipped as stale (age ${Math.round(ageMs / 60000)}m, max ${maxAgeMinutes}m): marked seen, never answered.`);
|
|
793
|
+
await receipt({ outcome: 'stale_skipped', message_id: message.id, message_from: message.from });
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
let timestamps = pruneOldTurns(nextState.turn_timestamps, now());
|
|
798
|
+
if (timestamps.length >= settings.max_turns_per_hour) {
|
|
799
|
+
log('warn', `rate limit hit: ${settings.max_turns_per_hour} turn(s)/hour reached, skipping message ${message.id} without spawning.`);
|
|
800
|
+
nextState = { ...nextState, turn_timestamps: timestamps };
|
|
801
|
+
await receipt({ outcome: 'rate_limited', message_id: message.id, message_from: message.from });
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
log('info', `inbound message ${message.id} from ${message.from}: ${truncate(message.text)}`);
|
|
806
|
+
|
|
807
|
+
const prompt = buildTurnPrompt({ hostId, slot, message, history: nextState.recent_exchanges.slice(-historyWindow), state: nextState });
|
|
808
|
+
const turnStartedMs = Date.now();
|
|
809
|
+
const result = await spawnTurn(prompt, settings.turn_timeout_seconds);
|
|
810
|
+
const durationMs = Date.now() - turnStartedMs;
|
|
811
|
+
timestamps = [...timestamps, now()];
|
|
812
|
+
nextState = { ...nextState, turn_timestamps: timestamps };
|
|
813
|
+
turnsRun += 1;
|
|
814
|
+
|
|
815
|
+
if (result.timedOut) {
|
|
816
|
+
log('warn', `turn timed out after ${settings.turn_timeout_seconds}s for message ${message.id}; skipping reply (never posted).`);
|
|
817
|
+
await receipt({ outcome: 'timeout', message_id: message.id, message_from: message.from, duration_ms: durationMs });
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (result.error) {
|
|
821
|
+
log('warn', `turn failed to spawn for message ${message.id}: ${result.error.message}; skipping reply.`);
|
|
822
|
+
await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: `spawn: ${result.error.message}` });
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Defect 2 fix: never post an error-shaped turn as if it were the
|
|
827
|
+
// resident's own reply. Log the full error, record it in state for
|
|
828
|
+
// `status` to surface, and post at most one short plain-language
|
|
829
|
+
// notice per distinct error signature per hour.
|
|
830
|
+
const authExpiry = detectAuthExpiry(result);
|
|
831
|
+
if (authExpiry) {
|
|
832
|
+
log('error', `turn auth expiry for message ${message.id}: provider=${authExpiry.provider} (login expired; never posted raw)`);
|
|
833
|
+
await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: `auth_expired: ${authExpiry.provider}` });
|
|
834
|
+
const noticeText = buildAuthExpiryNoticeText(hostId, authExpiry);
|
|
835
|
+
nextState = {
|
|
836
|
+
...nextState,
|
|
837
|
+
last_turn_error: { type: 'auth_expired', message: noticeText, category: 'auth expiry', signature: `auth_expired:${authExpiry.provider}`, at: new Date(now()).toISOString() },
|
|
838
|
+
};
|
|
839
|
+
// ONE slot reply + ONE owner alert per expiry episode; the flag is
|
|
840
|
+
// cleared only by a successful reply below.
|
|
841
|
+
if (!nextState.auth_expiry_alerted_at) {
|
|
842
|
+
await postReply(noticeText);
|
|
843
|
+
if (alertOwner) {
|
|
844
|
+
try { await alertOwner(noticeText); } catch (err) { log('warn', `auth-expiry owner alert failed: ${err.message}`); }
|
|
845
|
+
}
|
|
846
|
+
nextState = { ...nextState, auth_expiry_alerted_at: new Date(now()).toISOString() };
|
|
847
|
+
log('warn', `posted auth-expiry notice and owner alert (once per episode): ${noticeText}`);
|
|
848
|
+
} else {
|
|
849
|
+
log('warn', 'auth-expiry notice suppressed (already alerted this episode; clears on next successful turn).');
|
|
850
|
+
}
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
const turnError = detectTurnError(result);
|
|
854
|
+
if (turnError) {
|
|
855
|
+
log('error', `turn error for message ${message.id}: type=${turnError.type} message=${turnError.message} (never posted to the channel)`);
|
|
856
|
+
await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: `${turnError.type}: ${turnError.message}`, cost_usd: result.total_cost_usd ?? undefined, session_id: result.session_id || undefined });
|
|
857
|
+
nextState = {
|
|
858
|
+
...nextState,
|
|
859
|
+
last_turn_error: { type: turnError.type, message: turnError.message, category: turnError.category, signature: turnError.signature, at: new Date(now()).toISOString() },
|
|
860
|
+
};
|
|
861
|
+
const notice = shouldPostErrorNotice(nextState.error_notices, turnError.signature, now());
|
|
862
|
+
nextState = { ...nextState, error_notices: notice.notices };
|
|
863
|
+
if (notice.shouldPost) {
|
|
864
|
+
const noticeText = buildErrorNoticeText(turnError.category);
|
|
865
|
+
await postReply(noticeText);
|
|
866
|
+
log('warn', `posted one-per-hour error notice for signature "${turnError.signature}": ${noticeText}`);
|
|
867
|
+
} else {
|
|
868
|
+
log('warn', `error notice suppressed for signature "${turnError.signature}" (already notified within the last hour).`);
|
|
869
|
+
}
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const replyText = (result.text || '').trim();
|
|
874
|
+
if (!replyText) {
|
|
875
|
+
log('warn', `empty reply for message ${message.id}; skipping (never posted).`);
|
|
876
|
+
await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: 'empty reply', cost_usd: result.total_cost_usd ?? undefined, session_id: result.session_id || undefined });
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (nextState.auth_expiry_alerted_at) {
|
|
881
|
+
const { auth_expiry_alerted_at: _cleared, ...rest } = nextState;
|
|
882
|
+
nextState = rest;
|
|
883
|
+
log('info', 'auth recovered: successful turn, auth-expiry alert re-armed.');
|
|
884
|
+
}
|
|
885
|
+
await postReply(replyText);
|
|
886
|
+
log('info', `replied to ${message.id} (${replyText.length} chars, session_id=${result.session_id || 'n/a'}, cost_usd=${result.total_cost_usd ?? 'n/a'})`);
|
|
887
|
+
await receipt({
|
|
888
|
+
outcome: 'replied', message_id: message.id, message_from: message.from,
|
|
889
|
+
reply_chars: replyText.length, cost_usd: result.total_cost_usd ?? undefined,
|
|
890
|
+
duration_ms: durationMs, session_id: result.session_id || undefined,
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const decisions = boundDecisions([
|
|
894
|
+
...nextState.decisions,
|
|
895
|
+
{
|
|
896
|
+
at: new Date(now()).toISOString(),
|
|
897
|
+
message_id: message.id,
|
|
898
|
+
from: message.from,
|
|
899
|
+
reply_excerpt: truncate(replyText, 200),
|
|
900
|
+
session_id: result.session_id || null,
|
|
901
|
+
cost_usd: result.total_cost_usd ?? null,
|
|
902
|
+
},
|
|
903
|
+
], maxDecisions);
|
|
904
|
+
|
|
905
|
+
const recentExchanges = boundExchanges([
|
|
906
|
+
...nextState.recent_exchanges,
|
|
907
|
+
{ from: message.from, text: message.text, reply: replyText },
|
|
908
|
+
], historyWindow);
|
|
909
|
+
|
|
910
|
+
nextState = { ...nextState, decisions, recent_exchanges: recentExchanges };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (staleSkipped > 0) {
|
|
914
|
+
log('warn', `stale-skip summary: ${staleSkipped} message(s) skipped as too old to answer (max age ${maxAgeMinutes}m), never answered.`);
|
|
915
|
+
// OWNER-FACING HONESTY (2026-08-24 incident): silence here ATE five real
|
|
916
|
+
// owner messages, including two direct work orders, after a downtime.
|
|
917
|
+
// The churn guard protecting spend must never read as the resident
|
|
918
|
+
// ignoring its owner: post ONE plain line saying what was dropped and
|
|
919
|
+
// that a human/session needs to pick it up. One line per pass, never per
|
|
920
|
+
// message, so the spend cost of honesty is a single say.
|
|
921
|
+
try {
|
|
922
|
+
await postReply(
|
|
923
|
+
`I was unable to answer ${staleSkipped} message(s) in time (older than my ${maxAgeMinutes}m auto-answer window). `
|
|
924
|
+
+ 'They are marked seen, not actioned: please re-send anything still needed, or a full session should pick them up.'
|
|
925
|
+
);
|
|
926
|
+
} catch (err) {
|
|
927
|
+
log('warn', `stale-skip notice failed to post: ${err.message}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
nextState = { ...nextState, host_id: hostId, slot, updated_at: new Date(now()).toISOString(), last_enabled_state: true };
|
|
932
|
+
return { state: nextState, settings, stopped: false, turnsRun };
|
|
933
|
+
}
|