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,1528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuoxIdentity P1 attach commands — docs/QUOXIDENTITY_P1_CONTRACT.md, territory C.
|
|
3
|
+
*
|
|
4
|
+
* `quox agent attach <agentId>` leases an agent from AUTH (single-active lease,
|
|
5
|
+
* grant-checked), fetches the ATTACH KIT from the COLLECTOR using the minted
|
|
6
|
+
* attach JWT, and installs it locally under ~/.quox/attach/<agentId>/ so a
|
|
7
|
+
* live Claude Code / Codex session can read its assigned system prompt, tool
|
|
8
|
+
* scope, and lease. Registered from cli/lib/commands/agent.js onto the shared
|
|
9
|
+
* `agent` subcommand, per the contract's "follow the file's existing
|
|
10
|
+
* subcommand registration style" instruction.
|
|
11
|
+
*
|
|
12
|
+
* Local install (all files 0600, matching config.js's session-file posture):
|
|
13
|
+
* ~/.quox/attach/<agentId>/kit.json — the collector's kit response verbatim
|
|
14
|
+
* ~/.quox/attach/<agentId>/token — the raw attach JWT
|
|
15
|
+
* ~/.quox/attach/<agentId>/ATTACHED.md — rendered instructions for the session
|
|
16
|
+
*
|
|
17
|
+
* kit.json's own `lease.id` field IS the local record of "which lease does
|
|
18
|
+
* this box currently hold for this agent" — detach/heartbeat read it back
|
|
19
|
+
* rather than inventing a second state file, since the kit is only ever
|
|
20
|
+
* written after both the lease and the kit fetch succeeded.
|
|
21
|
+
*
|
|
22
|
+
* P3 (docs/QUOXIDENTITY_P3_CONTRACT.md, territory G) adds slot-binding
|
|
23
|
+
* awareness on top of the P1/P2 lease/handoff mechanism: an agent identity
|
|
24
|
+
* can own a durable slot ADDRESS (dev_slots row). Lease = identity hold,
|
|
25
|
+
* slot checkout = voice hold; the two are separate by design. This file
|
|
26
|
+
* reuses the slot HTTP shapes already declared in cli/lib/api/endpoints/v1.js
|
|
27
|
+
* (v1.slots.checkout / v1.slots.release) rather than re-declaring them, and
|
|
28
|
+
* reuses cli/lib/commands/slot.js's held-slots ledger helpers
|
|
29
|
+
* (recordHeldSlot/forgetHeldSlot) so the slot-inbox hook still recognises a
|
|
30
|
+
* slot this box holds via an attach, exactly as it would a plain
|
|
31
|
+
* `quox slot checkout`.
|
|
32
|
+
*
|
|
33
|
+
* ~/.quox/attach/<agentId>/slot.json — {name, session_label}: local record
|
|
34
|
+
* of "did THIS session check out the agent's bound slot", used to decide
|
|
35
|
+
* whether detach owes a slot release. Not written when the kit carries no
|
|
36
|
+
* bound slot, --no-slot was passed, or the checkout itself failed (a 409
|
|
37
|
+
* held-elsewhere leaves nothing for this session to release later).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { homedir } from 'node:os';
|
|
41
|
+
import { join, dirname } from 'node:path';
|
|
42
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, rmSync, readdirSync, renameSync, openSync, closeSync } from 'node:fs';
|
|
43
|
+
import { spawn } from 'node:child_process';
|
|
44
|
+
|
|
45
|
+
import { formatDetail, printInfo } from '../core/output.js';
|
|
46
|
+
import { events, line, printEvent } from '../core/quoxglyph.js';
|
|
47
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
48
|
+
import { QuoxError, QuoxApiError } from '../core/errors.js';
|
|
49
|
+
import { resolveOrgId, parseJSON } from './helpers.js';
|
|
50
|
+
import { recordHeldSlot, forgetHeldSlot, isQuoxEntry } from './slot.js';
|
|
51
|
+
import { runKeeper, keeperDir, makeProcessProbe } from '../core/slotKeeper.js';
|
|
52
|
+
import { buildSelfIdentity } from '../core/supervisorEngine.js';
|
|
53
|
+
import { resolveAgentId } from '../core/agentAlias.js';
|
|
54
|
+
import { kitHasBrainContext, buildBrainPrimer, saveHandoffNote } from '../core/brainPrime.js';
|
|
55
|
+
import { decisionProvenance } from './brain.js';
|
|
56
|
+
|
|
57
|
+
export function attachRoot() {
|
|
58
|
+
return join(homedir(), '.quox', 'attach');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// QUOXIDENTITY P9 — session-scoped attach. The original layout stored attach
|
|
62
|
+
// state flat at ~/.quox/attach/<agentId>/, shared by EVERY shell session on the
|
|
63
|
+
// box, and resolution assumed a single "sole" attach per host. The moment a
|
|
64
|
+
// second session attached to a second agent, both sessions' lease-aware routing
|
|
65
|
+
// (memory/exec/whoami) went ambiguous — the "single resident" design flaw.
|
|
66
|
+
//
|
|
67
|
+
// Fix: bind attach state to the session. Each session already carries a stable
|
|
68
|
+
// per-session id in its environment (QUOX_T, the house session convention, set
|
|
69
|
+
// once at session start and inherited by every child process — unlike a shell
|
|
70
|
+
// `export`, which does not survive across a Claude session's separate Bash
|
|
71
|
+
// calls). New attaches live under ~/.quox/attach/sessions/<sessionKey>/<agent>/
|
|
72
|
+
// so two sessions on one box are isolated. The legacy flat path stays READABLE
|
|
73
|
+
// for backward compatibility (a session attached under the old CLI keeps working
|
|
74
|
+
// until it re-attaches).
|
|
75
|
+
|
|
76
|
+
const SESSIONS_SUBDIR = 'sessions';
|
|
77
|
+
|
|
78
|
+
function sanitizeKey(raw) {
|
|
79
|
+
// Strip anything but a safe path segment, then leading dots so a key can
|
|
80
|
+
// never resolve to a parent (e.g. '..' would otherwise point at attachRoot).
|
|
81
|
+
return String(raw || '').trim().replace(/[^A-Za-z0-9._-]/g, '').replace(/^\.+/, '') || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readProcName(pid) {
|
|
85
|
+
try {
|
|
86
|
+
return readFileSync(`/proc/${pid}/comm`, 'utf-8').trim();
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readProcStat(pid) {
|
|
93
|
+
// /proc/<pid>/stat: the comm field (2nd) is parenthesised and may contain
|
|
94
|
+
// spaces/parens, so split AFTER the last ')'. Then rest[1]=ppid, rest[3]=sid.
|
|
95
|
+
try {
|
|
96
|
+
const s = readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
97
|
+
const rp = s.lastIndexOf(')');
|
|
98
|
+
const rest = s.slice(rp + 2).trim().split(/\s+/);
|
|
99
|
+
// rest[0] is stat field 3, so field 22 (starttime, clock ticks since boot)
|
|
100
|
+
// is rest[19]. It guards against pid reuse: pid+starttime identifies a
|
|
101
|
+
// process for the life of the boot.
|
|
102
|
+
return { ppid: parseInt(rest[1], 10), sid: parseInt(rest[3], 10), starttime: rest[19] || null };
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readProcCmdline(pid) {
|
|
109
|
+
try {
|
|
110
|
+
return readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Claude Code records each live session's current name (including /rename)
|
|
117
|
+
// in <config>/sessions/<pid>.json. Read ONLY the `name` field: sibling
|
|
118
|
+
// <pid>.*.key files in that directory are secrets and are never opened.
|
|
119
|
+
function readClaudeSessionFileName(pid) {
|
|
120
|
+
try {
|
|
121
|
+
const dir = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'sessions');
|
|
122
|
+
const data = JSON.parse(readFileSync(join(dir, `${Number(pid)}.json`), 'utf-8'));
|
|
123
|
+
return typeof data?.name === 'string' && data.name.trim() ? data.name.trim() : null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const defaultProcReader = { name: readProcName, stat: readProcStat, cmdline: readProcCmdline, sessionName: readClaudeSessionFileName };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The Claude Code session NAME: the live name Claude Code records for the
|
|
133
|
+
* nearest `claude` ancestor (covers /rename), else the `--resume <NAME>` on
|
|
134
|
+
* its cmdline. null when there is
|
|
135
|
+
* no claude ancestor or it was not started with --resume.
|
|
136
|
+
*/
|
|
137
|
+
export function claudeSessionName(reader = defaultProcReader, startPid = process.pid) {
|
|
138
|
+
try {
|
|
139
|
+
const pid = findClaudeAncestor(reader, startPid);
|
|
140
|
+
if (!pid) return null;
|
|
141
|
+
const fromFile = reader.sessionName ? reader.sessionName(pid) : null;
|
|
142
|
+
if (fromFile) return fromFile;
|
|
143
|
+
const raw = reader.cmdline ? reader.cmdline(pid) : null;
|
|
144
|
+
if (!raw) return null;
|
|
145
|
+
const argv = raw.split('\0');
|
|
146
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
147
|
+
if (argv[i] === '--resume' && argv[i + 1]) return argv[i + 1];
|
|
148
|
+
if (argv[i].startsWith('--resume=') && argv[i].length > 9) return argv[i].slice(9);
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Session label precedence: --label, then the Claude session name, then
|
|
158
|
+
* QUOX_STREAM (advisory only: a stale `export` in a shell rc labelled every
|
|
159
|
+
* splinter the same, 2026-10-03).
|
|
160
|
+
*/
|
|
161
|
+
export function resolveSessionLabel({ label, env = process.env, lookup = claudeSessionName } = {}) {
|
|
162
|
+
return label || lookup() || env.QUOX_STREAM || undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Nearest ancestor process named `claude` (pid), or null. */
|
|
166
|
+
function findClaudeAncestor(reader = defaultProcReader, startPid = process.pid) {
|
|
167
|
+
let pid = startPid;
|
|
168
|
+
for (let hop = 0; hop < 24; hop += 1) {
|
|
169
|
+
if (reader.name(pid) === 'claude') return pid;
|
|
170
|
+
const st = reader.stat(pid);
|
|
171
|
+
if (!st || !st.ppid || st.ppid === pid || pid === 1) break;
|
|
172
|
+
pid = st.ppid;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Identity of the Claude session this process runs under:
|
|
179
|
+
* { sessionKey: 'claude-<pid>', pid, starttime } or null (off-Linux, or no
|
|
180
|
+
* `claude` ancestor). starttime is /proc/<pid>/stat field 22; with the pid
|
|
181
|
+
* it survives pid reuse. Reuses the same walk as autoSessionKey.
|
|
182
|
+
*/
|
|
183
|
+
export function claudeSessionInfo(reader = defaultProcReader, startPid = process.pid) {
|
|
184
|
+
try {
|
|
185
|
+
const pid = findClaudeAncestor(reader, startPid);
|
|
186
|
+
if (!pid) return null;
|
|
187
|
+
const st = reader.stat(pid);
|
|
188
|
+
if (!st || !st.starttime) return null;
|
|
189
|
+
return { sessionKey: `claude-${pid}`, pid, starttime: st.starttime };
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Process start ticks for a pid (null when it does not exist). */
|
|
196
|
+
export function procStartTime(pid) {
|
|
197
|
+
const st = readProcStat(pid);
|
|
198
|
+
return st ? st.starttime : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Derive a stable per-session key AUTOMATICALLY from the process tree — no env
|
|
203
|
+
* var, no setup. Walk the ancestry: the nearest `claude` ancestor PID is stable
|
|
204
|
+
* for the life of a Claude Code session (every command it runs descends from
|
|
205
|
+
* that one process, even though each shell invocation is fresh). For a plain
|
|
206
|
+
* human terminal there is no `claude` ancestor, so fall back to the POSIX
|
|
207
|
+
* session id, which is stable per login/terminal. Returns null only when the
|
|
208
|
+
* process tree is unreadable (e.g. no /proc), where callers degrade to the
|
|
209
|
+
* single-attach behaviour.
|
|
210
|
+
*/
|
|
211
|
+
function autoSessionKey() {
|
|
212
|
+
const claudePid = findClaudeAncestor();
|
|
213
|
+
if (claudePid) return `claude-${claudePid}`;
|
|
214
|
+
const self = readProcStat(process.pid);
|
|
215
|
+
return self && self.sid ? `sid-${self.sid}` : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The current session's stable key. Automatic by default (derived from the
|
|
220
|
+
* process tree, see autoSessionKey). QUOX_ATTACH_SESSION is an optional
|
|
221
|
+
* explicit override for scripts/power users; normal use needs nothing.
|
|
222
|
+
*/
|
|
223
|
+
export function sessionKey() {
|
|
224
|
+
const override = sanitizeKey(process.env.QUOX_ATTACH_SESSION);
|
|
225
|
+
if (override) return override;
|
|
226
|
+
try {
|
|
227
|
+
return autoSessionKey();
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sessionsRoot() {
|
|
234
|
+
return join(attachRoot(), SESSIONS_SUBDIR);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sessionScopedDir(agentId, key = sessionKey()) {
|
|
238
|
+
return key ? join(sessionsRoot(), key, agentId) : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function legacyFlatDir(agentId) {
|
|
242
|
+
return join(attachRoot(), agentId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Resolve the on-disk attach directory for an agent from THIS session's point
|
|
247
|
+
* of view. Read-biased so it returns whichever location actually holds state:
|
|
248
|
+
* the session-scoped dir if it exists, else the legacy flat dir if that holds a
|
|
249
|
+
* token (compat), else the session-scoped path a new attach should WRITE to.
|
|
250
|
+
* With no session key at all it degrades to the original flat behaviour, so a
|
|
251
|
+
* genuinely single-session box is unchanged.
|
|
252
|
+
*/
|
|
253
|
+
export function attachDir(agentId) {
|
|
254
|
+
const key = sessionKey();
|
|
255
|
+
if (!key) return legacyFlatDir(agentId);
|
|
256
|
+
const scoped = sessionScopedDir(agentId, key);
|
|
257
|
+
if (existsSync(scoped)) return scoped;
|
|
258
|
+
const legacy = legacyFlatDir(agentId);
|
|
259
|
+
if (existsSync(join(legacy, 'token'))) return legacy;
|
|
260
|
+
return scoped; // new attach for this session writes here
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Resolve which agent THIS session is acting as, and its attach token.
|
|
265
|
+
* Precedence:
|
|
266
|
+
* 1. QUOX_ATTACH_AGENT env override (explicit "I am <agent>").
|
|
267
|
+
* 2. A keyed session (every Claude session, and any terminal with a POSIX
|
|
268
|
+
* session id) sees ONLY its own subtree: exactly one attach there, else
|
|
269
|
+
* null. It never borrows an unowned flat attach — an un-attached session
|
|
270
|
+
* is itself, not whichever agent some other session happens to hold.
|
|
271
|
+
* 3. Only a genuinely keyless caller (no /proc, no session id) falls back to
|
|
272
|
+
* a single legacy flat attach (pre-P9 single-session compat).
|
|
273
|
+
* Note: this is the IMPLICIT "which am I" resolution. Explicit named access
|
|
274
|
+
* (attachDir(agentId), used by exec/detach/heartbeat) still honours a legacy
|
|
275
|
+
* flat attach, so a pre-P9 session keeps working for the agent it names.
|
|
276
|
+
* Returns { agentId, token } or null when there is no attach or it is
|
|
277
|
+
* genuinely ambiguous (more than one in scope with no override).
|
|
278
|
+
*/
|
|
279
|
+
export function resolveMyAttach() {
|
|
280
|
+
const readToken = (dir) => {
|
|
281
|
+
try {
|
|
282
|
+
const t = readFileSync(join(dir, 'token'), 'utf-8').trim();
|
|
283
|
+
return t || null;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// 1. explicit override
|
|
290
|
+
const override = (process.env.QUOX_ATTACH_AGENT || '').trim();
|
|
291
|
+
if (override) {
|
|
292
|
+
const token = readToken(attachDir(override));
|
|
293
|
+
return token ? { agentId: override, token } : null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 2. keyed session: this session's own subtree only, no borrowing.
|
|
297
|
+
const key = sessionKey();
|
|
298
|
+
if (key) {
|
|
299
|
+
const dir = join(sessionsRoot(), key);
|
|
300
|
+
const sole = soleAttachIn(dir);
|
|
301
|
+
if (!sole) return null;
|
|
302
|
+
const token = readToken(join(dir, sole));
|
|
303
|
+
return token ? { agentId: sole, token } : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 3. keyless caller only: single legacy flat attach (pre-P9 compat).
|
|
307
|
+
const sole = soleAttachIn(attachRoot(), { skip: [SESSIONS_SUBDIR] });
|
|
308
|
+
if (sole) {
|
|
309
|
+
const token = readToken(legacyFlatDir(sole));
|
|
310
|
+
if (token) return { agentId: sole, token };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Like resolveMyAttach but says WHY it is null: 'none' vs 'ambiguous'
|
|
318
|
+
* (keyed session with more than one attach, or a legacy flat set).
|
|
319
|
+
*/
|
|
320
|
+
export function myAttachState() {
|
|
321
|
+
const one = resolveMyAttach();
|
|
322
|
+
if (one) return { state: 'one', ...one };
|
|
323
|
+
if ((process.env.QUOX_ATTACH_AGENT || '').trim()) return { state: 'none', names: [] };
|
|
324
|
+
const key = sessionKey();
|
|
325
|
+
const names = key
|
|
326
|
+
? attachNamesIn(join(sessionsRoot(), key))
|
|
327
|
+
: attachNamesIn(attachRoot(), { skip: [SESSIONS_SUBDIR] });
|
|
328
|
+
return { state: names.length > 1 ? 'ambiguous' : 'none', names };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Names of attach dirs (those holding a token) directly under `dir`, minus `skip`. */
|
|
332
|
+
function attachNamesIn(dir, { skip = [] } = {}) {
|
|
333
|
+
if (!existsSync(dir)) return [];
|
|
334
|
+
let entries;
|
|
335
|
+
try {
|
|
336
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
return entries
|
|
341
|
+
.filter((e) => e.isDirectory() && !skip.includes(e.name))
|
|
342
|
+
.filter((e) => existsSync(join(dir, e.name, 'token')))
|
|
343
|
+
.map((e) => e.name);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The single attach dir name directly under `dir`, or null if zero or >1. */
|
|
347
|
+
function soleAttachIn(dir, opts) {
|
|
348
|
+
const names = attachNamesIn(dir, opts);
|
|
349
|
+
return names.length === 1 ? names[0] : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function kitPath(agentId) {
|
|
353
|
+
return join(attachDir(agentId), 'kit.json');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function tokenPath(agentId) {
|
|
357
|
+
return join(attachDir(agentId), 'token');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function instructionsPath(agentId) {
|
|
361
|
+
return join(attachDir(agentId), 'ATTACHED.md');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// QUOXIDENTITY P9 (docs/QUOXIDENTITY_SPLINTER_CONTRACT.md, territory B): the
|
|
365
|
+
// session-scoped `splinter` file holds one line `S<n> <live count incl. self>`
|
|
366
|
+
// so statuslines/hooks can read it without an API call.
|
|
367
|
+
export function splinterPath(agentId) {
|
|
368
|
+
return join(attachDir(agentId), 'splinter');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Atomic (tmp + rename) write of the splinter file. Never throws. */
|
|
372
|
+
export function writeSplinterFile(agentId, id, count) {
|
|
373
|
+
try {
|
|
374
|
+
const dir = attachDir(agentId);
|
|
375
|
+
mkdirSync(dir, { recursive: true });
|
|
376
|
+
const path = splinterPath(agentId);
|
|
377
|
+
const tmp = `${path}.tmp`;
|
|
378
|
+
writeFileSync(tmp, `${id} ${count}\n`, 'utf-8');
|
|
379
|
+
renameSync(tmp, path);
|
|
380
|
+
} catch {
|
|
381
|
+
// A statusline convenience file must never fail an attach/heartbeat.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Normalise a lease-create `splinter` payload (absent on old servers => S1 alone). */
|
|
386
|
+
export function splinterInfoFrom(splinter) {
|
|
387
|
+
const siblings = Array.isArray(splinter?.siblings) ? splinter.siblings : [];
|
|
388
|
+
const ordinal = Number.isInteger(splinter?.ordinal) ? splinter.ordinal : 1;
|
|
389
|
+
const id = splinter?.id || `S${ordinal}`;
|
|
390
|
+
return { id, ordinal, siblings, count: siblings.length + 1, present: !!splinter };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function fmtAge(iso) {
|
|
394
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
395
|
+
if (Number.isNaN(t)) return 'unknown';
|
|
396
|
+
const sec = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
397
|
+
if (sec < 60) return `${sec}s`;
|
|
398
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
|
399
|
+
return `${Math.floor(sec / 3600)}h`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Best-effort recount from the status route after a heartbeat. Old servers
|
|
404
|
+
* return no `splinters` array: leave the file untouched. Never throws.
|
|
405
|
+
*/
|
|
406
|
+
async function refreshSplinterFile(client, agentId, leaseId) {
|
|
407
|
+
try {
|
|
408
|
+
const ep = resolveEndpoint('agent-attach.status', { agentId });
|
|
409
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
410
|
+
const list = data?.splinters;
|
|
411
|
+
if (!Array.isArray(list) || list.length === 0) return;
|
|
412
|
+
let id = list.find((x) => x.lease_id === leaseId)?.id;
|
|
413
|
+
if (!id) {
|
|
414
|
+
try { id = readFileSync(splinterPath(agentId), 'utf-8').trim().split(/\s+/)[0]; } catch { /* none */ }
|
|
415
|
+
}
|
|
416
|
+
writeSplinterFile(agentId, id || 'S1', list.length);
|
|
417
|
+
} catch {
|
|
418
|
+
// heartbeat succeeded; the recount is a convenience
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Did a tool result report failure? Governed tools are inconsistent about their
|
|
424
|
+
* result envelope (autonomous loop, 2026-09-21: some return {success:false},
|
|
425
|
+
* some {ok:false}, and the negative may be a boolean OR the string 'no'), so a
|
|
426
|
+
* naive `data.success !== false` check let {ok:false} failures slip through as a
|
|
427
|
+
* green ✔. Treat a NEGATIVE `success` or `ok` (boolean false or 'no'/'false')
|
|
428
|
+
* as failure; absence of both is treated as success (many read tools return
|
|
429
|
+
* neither and are fine). The root-cause envelope inconsistency is filed against
|
|
430
|
+
* the tool layer; this is the CLI reading defensively.
|
|
431
|
+
*/
|
|
432
|
+
export function execResultFailed(data) {
|
|
433
|
+
if (!data || typeof data !== 'object') return false;
|
|
434
|
+
const negative = (v) => v === false || v === 'no' || v === 'false';
|
|
435
|
+
return negative(data.success) || negative(data.ok);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The first human-readable error string a failed tool result carries, or a default. */
|
|
439
|
+
function execResultError(data) {
|
|
440
|
+
return (data && (data.error || data.error_code || data.reason || data.message)) || 'tool reported failure';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Decode a JWT's `exp` (unix seconds) without verifying the signature, or null. */
|
|
444
|
+
function decodeJwtExp(token) {
|
|
445
|
+
try {
|
|
446
|
+
const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString());
|
|
447
|
+
return typeof payload.exp === 'number' ? payload.exp : null;
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* QUOXIDENTITY finding-10: the attach token JWT is short-lived (~900s) and only
|
|
455
|
+
* a heartbeat refreshes it. A session that works for a while without an explicit
|
|
456
|
+
* heartbeat finds the token expired, and the NEXT governed call (exec, or the
|
|
457
|
+
* detach handoff — which runs at the very end of a session) 401s, losing the
|
|
458
|
+
* exit-handoff exactly when continuity matters most. SEER read this as a
|
|
459
|
+
* handoff-specific auth bug; it is token expiry (the LEASE may still be alive
|
|
460
|
+
* server-side while the local token JWT has lapsed).
|
|
461
|
+
*
|
|
462
|
+
* Refresh-on-use: if the stored token is within `graceSec` of expiry (or gone),
|
|
463
|
+
* heartbeat the lease FIRST. The heartbeat authenticates with the caller's own
|
|
464
|
+
* (resident/holder) session, NOT the attach token, so it succeeds and mints a
|
|
465
|
+
* fresh attach token even after the old one expired — as long as the lease
|
|
466
|
+
* lives. Returns the freshest token available; never throws. If the heartbeat
|
|
467
|
+
* fails (lease genuinely dead), returns the stale token so the caller surfaces
|
|
468
|
+
* a clear re-attach error rather than a bare 401.
|
|
469
|
+
*/
|
|
470
|
+
export async function ensureFreshAttachToken(client, agentId, { graceSec = 60 } = {}) {
|
|
471
|
+
let token;
|
|
472
|
+
try {
|
|
473
|
+
token = readFileSync(tokenPath(agentId), 'utf-8').trim();
|
|
474
|
+
} catch (err) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Not attached as "${agentId}" (no stored attach token at ${tokenPath(agentId)}). `
|
|
477
|
+
+ `Run \`quox agent attach ${agentId}\` first.`
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const exp = decodeJwtExp(token);
|
|
482
|
+
const now = Math.floor(Date.now() / 1000);
|
|
483
|
+
if (exp && exp - now > graceSec) return token; // still comfortably valid
|
|
484
|
+
|
|
485
|
+
const stored = readStoredKit(agentId);
|
|
486
|
+
const leaseId = stored?.lease?.id;
|
|
487
|
+
if (!leaseId) return token;
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const ep = resolveEndpoint('agent-attach.lease.heartbeat', { leaseId });
|
|
491
|
+
const data = await client.post(ep.path, { service: ep.service, body: {} });
|
|
492
|
+
if (data?.token) {
|
|
493
|
+
writeFile0600(tokenPath(agentId), `${data.token}\n`);
|
|
494
|
+
token = data.token;
|
|
495
|
+
const newExpiry = data?.lease?.expires_at || data?.expires_at || null;
|
|
496
|
+
if (newExpiry && stored?.lease) {
|
|
497
|
+
stored.lease.expires_at = newExpiry;
|
|
498
|
+
if (data?.lease?.id) stored.lease.id = data.lease.id;
|
|
499
|
+
writeFile0600(kitPath(agentId), `${JSON.stringify(stored, null, 2)}\n`);
|
|
500
|
+
}
|
|
501
|
+
await refreshSplinterFile(client, agentId, leaseId);
|
|
502
|
+
}
|
|
503
|
+
} catch {
|
|
504
|
+
// Lease likely dead (heartbeat 401/404) — return the stale token; the
|
|
505
|
+
// caller's request will surface the clear "re-attach" error path.
|
|
506
|
+
}
|
|
507
|
+
return token;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// P3: local record of "this session checked out the agent's bound slot" —
|
|
511
|
+
// see the file header comment. Read by detach to decide whether it owes a
|
|
512
|
+
// slot release; cleared implicitly when removeAttachDir() removes the whole
|
|
513
|
+
// attach directory.
|
|
514
|
+
export function slotStatePath(agentId) {
|
|
515
|
+
return join(attachDir(agentId), 'slot.json');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function writeFile0600(path, content) {
|
|
519
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
520
|
+
writeFileSync(path, content, 'utf-8');
|
|
521
|
+
try {
|
|
522
|
+
chmodSync(path, 0o600);
|
|
523
|
+
} catch {
|
|
524
|
+
// Best effort only (e.g. Windows): a failed chmod must not fail an
|
|
525
|
+
// attach the server already granted.
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Read back the locally-installed kit for an agent, or null if not attached. */
|
|
530
|
+
export function readStoredKit(agentId) {
|
|
531
|
+
const p = kitPath(agentId);
|
|
532
|
+
if (!existsSync(p)) return null;
|
|
533
|
+
try {
|
|
534
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** P3: read back the locally checked-out slot for an agent, or null if none. */
|
|
541
|
+
export function readSlotState(agentId) {
|
|
542
|
+
const p = slotStatePath(agentId);
|
|
543
|
+
if (!existsSync(p)) return null;
|
|
544
|
+
try {
|
|
545
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** P3: record that this session checked out the agent's bound slot. */
|
|
552
|
+
export function writeSlotState(agentId, state) {
|
|
553
|
+
writeFile0600(slotStatePath(agentId), `${JSON.stringify(state, null, 2)}\n`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Render ATTACHED.md: the honesty preamble is VERBATIM from the contract
|
|
558
|
+
* (docs/QUOXIDENTITY_P1_CONTRACT.md CLI section), plus the system prompt,
|
|
559
|
+
* tool scope, and lease id/expiry the session needs to operate.
|
|
560
|
+
*/
|
|
561
|
+
/** Compact input signature for a tool from its kit schema: `(host*, port)`. */
|
|
562
|
+
export function toolInputSig(schema) {
|
|
563
|
+
const props = (schema && schema.input_schema && schema.input_schema.properties) || {};
|
|
564
|
+
const required = new Set((schema && schema.input_schema && schema.input_schema.required) || []);
|
|
565
|
+
const names = Object.keys(props);
|
|
566
|
+
if (names.length === 0) return '()';
|
|
567
|
+
return `(${names.map((n) => (required.has(n) ? `${n}*` : n)).join(', ')})`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** One tool-scope line: `- name(sig): first sentence of description`. */
|
|
571
|
+
function renderToolLine(name, toolSchemas) {
|
|
572
|
+
const sc = toolSchemas && toolSchemas[name];
|
|
573
|
+
if (!sc) return `- ${name}`;
|
|
574
|
+
const desc = typeof sc.description === 'string' && sc.description
|
|
575
|
+
? ` — ${sc.description.split(/\. /)[0].replace(/\.$/, '')}`
|
|
576
|
+
: '';
|
|
577
|
+
return `- ${name}${toolInputSig(sc)}${desc}`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function renderAttachedInstructions({ agentId, kit, splinter }) {
|
|
581
|
+
const tools = Array.isArray(kit?.tools) ? kit.tools : [];
|
|
582
|
+
const lease = kit?.lease || {};
|
|
583
|
+
const lines = [
|
|
584
|
+
`# Attached as ${agentId}`,
|
|
585
|
+
'',
|
|
586
|
+
`You are attached as ${agentId}. Platform calls with the attach token act AS this agent`,
|
|
587
|
+
'and are witnessed. Local tools are not constrained by this kit; honour the tool scope.',
|
|
588
|
+
'',
|
|
589
|
+
`Lease: ${lease.id || 'unknown'} (expires ${lease.expires_at || 'unknown'})`,
|
|
590
|
+
...(splinter?.present ? [
|
|
591
|
+
`You are ${kit?.display_name || agentId} splinter ${splinter.id} (${splinter.count} live session${splinter.count === 1 ? '' : 's'})`
|
|
592
|
+
+ (splinter.siblings.length
|
|
593
|
+
? `; siblings: ${splinter.siblings.map((x) => `${x.id} ${x.session_label || '(no label)'}`).join(', ')}`
|
|
594
|
+
: ''),
|
|
595
|
+
] : []),
|
|
596
|
+
'',
|
|
597
|
+
// F6 (QuoxIdentity CLI finding-fix): the lease expires unless renewed.
|
|
598
|
+
// Sessions that ran long tasks without a heartbeat found the lease had
|
|
599
|
+
// quietly expired underneath them; name the fix inline, not just on
|
|
600
|
+
// attach-status.
|
|
601
|
+
`Plain quox verbs in this session now act as ${agentId} by default; use --as self (or -p <profile>) to act as yourself.`,
|
|
602
|
+
'',
|
|
603
|
+
`For sustained work, heartbeat to hold the lease: \`quox agent attach-heartbeat ${agentId}\`.`,
|
|
604
|
+
'',
|
|
605
|
+
'## System prompt',
|
|
606
|
+
'',
|
|
607
|
+
kit?.system_prompt || '(none provided)',
|
|
608
|
+
'',
|
|
609
|
+
'## Tool scope',
|
|
610
|
+
'',
|
|
611
|
+
// Each tool shows its input signature from kit.tool_schemas (fields marked *
|
|
612
|
+
// are required), so an attached session calls it right the first time
|
|
613
|
+
// instead of probing with `{}` (2026-09-21 tooling audit, top finding).
|
|
614
|
+
tools.length
|
|
615
|
+
? `${tools.map((t) => renderToolLine(t, kit?.tool_schemas)).join('\n')}\n\n(fields marked * are required; call with \`quox agent exec ${agentId} --tool <name> --input '<json>'\`)`
|
|
616
|
+
: '(no tools listed)',
|
|
617
|
+
'',
|
|
618
|
+
// P3 (docs/QUOXIDENTITY_P3_CONTRACT.md, territory G): the agent's bound
|
|
619
|
+
// slot is a durable ADDRESS; messaging it reaches whichever session
|
|
620
|
+
// currently holds the attach lease, regardless of which slot is checked
|
|
621
|
+
// out. Only rendered when the kit actually carries one (older kits or
|
|
622
|
+
// unbound agents keep the plain P1/P2 file shape).
|
|
623
|
+
...(kit?.slot?.name ? [
|
|
624
|
+
'## Your address',
|
|
625
|
+
'',
|
|
626
|
+
`This agent has a durable address: **${kit.slot.name}**. Speak into it with`,
|
|
627
|
+
`\`quox slot say ${kit.slot.name} "..."\` (keep it short, 1-2 lines) and read`,
|
|
628
|
+
`it with \`quox slot read ${kit.slot.name}\`.`,
|
|
629
|
+
'',
|
|
630
|
+
] : []),
|
|
631
|
+
'## Detach ritual',
|
|
632
|
+
'',
|
|
633
|
+
'Before detaching, write a handoff note (what state you left things in, open threads,',
|
|
634
|
+
'and next moves) and detach with it, so the next session attached to this agent resumes',
|
|
635
|
+
'with continuity instead of starting cold:',
|
|
636
|
+
'',
|
|
637
|
+
`\`quox agent detach ${agentId} --handoff-text "<state, open threads, next moves>"\``,
|
|
638
|
+
'',
|
|
639
|
+
`or \`quox agent detach ${agentId} --handoff <file>\` for a longer note.`,
|
|
640
|
+
'',
|
|
641
|
+
`Detach without a handoff: \`quox agent detach ${agentId}\` (releases the lease only).`,
|
|
642
|
+
'',
|
|
643
|
+
];
|
|
644
|
+
return lines.join('\n');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Install the kit + token + rendered instructions. Returns the three paths. */
|
|
648
|
+
export function installAttachKit(agentId, { kit, token, splinter }) {
|
|
649
|
+
mkdirSync(attachDir(agentId), { recursive: true });
|
|
650
|
+
writeFile0600(kitPath(agentId), `${JSON.stringify(kit, null, 2)}\n`);
|
|
651
|
+
writeFile0600(tokenPath(agentId), `${token}\n`);
|
|
652
|
+
writeFile0600(instructionsPath(agentId), renderAttachedInstructions({ agentId, kit, splinter }));
|
|
653
|
+
return { kitPath: kitPath(agentId), tokenPath: tokenPath(agentId), instructionsPath: instructionsPath(agentId) };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function removeAttachDir(agentId) {
|
|
657
|
+
const dir = attachDir(agentId);
|
|
658
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function noLocalLeaseError(agentId, verb) {
|
|
662
|
+
return new Error(
|
|
663
|
+
`No local attach state for "${agentId}" (expected ${kitPath(agentId)}). `
|
|
664
|
+
+ `Run \`quox agent attach ${agentId}\` first, then \`quox agent ${verb} ${agentId}\`.`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* One attach-lease heartbeat: extends the lease, rotates the stored token if
|
|
670
|
+
* one is issued, persists the fresh expiry into kit.json and refreshes the
|
|
671
|
+
* splinter file. Shared by `attach-heartbeat` and the background keeper.
|
|
672
|
+
*/
|
|
673
|
+
export async function heartbeatAttachLease(client, agentId, { getOrgId } = {}) {
|
|
674
|
+
const stored = readStoredKit(agentId);
|
|
675
|
+
const leaseId = stored?.lease?.id;
|
|
676
|
+
// No local kit means the session already detached: that is "hold lost" to
|
|
677
|
+
// the keeper (404), not a retryable failure.
|
|
678
|
+
if (!leaseId) throw new QuoxApiError(404, 'no_local_lease', `No local attach state for "${agentId}"`);
|
|
679
|
+
|
|
680
|
+
const ep = resolveEndpoint('agent-attach.lease.heartbeat', { leaseId });
|
|
681
|
+
const data = await client.post(ep.path, { service: ep.service, body: {} });
|
|
682
|
+
|
|
683
|
+
if (data?.token) {
|
|
684
|
+
writeFile0600(tokenPath(agentId), `${data.token}\n`);
|
|
685
|
+
printInfo(` token rotated: ${tokenPath(agentId)}`);
|
|
686
|
+
}
|
|
687
|
+
const newExpiry = data?.lease?.expires_at || data?.expires_at || null;
|
|
688
|
+
// Persist the extended expiry back into kit.json so whoami and the
|
|
689
|
+
// attach expiry-warning read the FRESH deadline, not the stale one from
|
|
690
|
+
// the original attach. Without this, a successful heartbeat still shows
|
|
691
|
+
// "about to expire" everywhere the stored kit is read (SEER-TERMINAL F6).
|
|
692
|
+
if (newExpiry && stored?.lease) {
|
|
693
|
+
stored.lease.expires_at = newExpiry;
|
|
694
|
+
if (data?.lease?.id) stored.lease.id = data.lease.id;
|
|
695
|
+
writeFile0600(kitPath(agentId), `${JSON.stringify(stored, null, 2)}\n`);
|
|
696
|
+
}
|
|
697
|
+
await refreshSplinterFile(client, agentId, leaseId);
|
|
698
|
+
await heartbeatBoundSlot(client, agentId, getOrgId);
|
|
699
|
+
return { leaseId, expires: newExpiry || 'unchanged' };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* #893: re-lease an attach that LAPSED (expired while heartbeats could not get
|
|
704
|
+
* through, e.g. an auth stall longer than the TTL). Never takes over: a 409
|
|
705
|
+
* here means another session holds the agent, and the keeper then exits as
|
|
706
|
+
* before. Rewrites only the token, lease id and expiry; the installed kit
|
|
707
|
+
* (prompt, tools, memory) is unchanged.
|
|
708
|
+
*/
|
|
709
|
+
const leaseMetaPath = (agentId) => join(dirname(tokenPath(agentId)), 'lease-meta.json');
|
|
710
|
+
|
|
711
|
+
export async function reacquireAttachLease(client, agentId) {
|
|
712
|
+
const stored = readStoredKit(agentId);
|
|
713
|
+
if (!stored?.lease?.id) throw new QuoxApiError(404, 'no_local_lease', `No local attach state for "${agentId}"`);
|
|
714
|
+
let meta = {};
|
|
715
|
+
try { meta = JSON.parse(readFileSync(leaseMetaPath(agentId), 'utf-8')); } catch { /* defaults */ }
|
|
716
|
+
const ep = resolveEndpoint('agent-attach.lease.create', { agentId });
|
|
717
|
+
const data = await client.post(ep.path, {
|
|
718
|
+
service: ep.service,
|
|
719
|
+
body: {
|
|
720
|
+
session_label: meta.session_label || agentId,
|
|
721
|
+
ttl_seconds: meta.ttl_seconds || 900,
|
|
722
|
+
takeover: false,
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
if (!data?.token || !data?.lease?.id) throw new Error('re-lease returned no token or lease id');
|
|
726
|
+
writeFile0600(tokenPath(agentId), `${data.token}\n`);
|
|
727
|
+
stored.lease = { ...stored.lease, id: data.lease.id, expires_at: data.lease.expires_at || data.expires_at || null };
|
|
728
|
+
writeFile0600(kitPath(agentId), `${JSON.stringify(stored, null, 2)}\n`);
|
|
729
|
+
return { leaseId: data.lease.id };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* P10: keep the bound slot (or this session's membership of it) alive with the
|
|
734
|
+
* ATTACH token. Failures are logged, never fatal to the lease heartbeat.
|
|
735
|
+
*/
|
|
736
|
+
async function heartbeatBoundSlot(client, agentId, getOrgId) {
|
|
737
|
+
const slotState = readSlotState(agentId);
|
|
738
|
+
if (!slotState?.name) return;
|
|
739
|
+
try {
|
|
740
|
+
const attachToken = readFileSync(tokenPath(agentId), 'utf-8').trim();
|
|
741
|
+
const orgId = getOrgId ? getOrgId() : undefined;
|
|
742
|
+
const ep = resolveEndpoint('v1.slots.heartbeat', { name: slotState.name });
|
|
743
|
+
await client.post(ep.path, {
|
|
744
|
+
service: ep.service,
|
|
745
|
+
noAuth: true,
|
|
746
|
+
headers: { Authorization: `Bearer ${attachToken}` },
|
|
747
|
+
body: {
|
|
748
|
+
holder_session: slotState.session_label,
|
|
749
|
+
...(slotState.member_key ? { member_key: slotState.member_key } : {}),
|
|
750
|
+
...(orgId ? { org_id: orgId } : {}),
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
} catch (err) {
|
|
754
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
755
|
+
printEvent(events.warned('quox', `slot ${slotState.name}`, `heartbeat failed (${status})`));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Detach WITHOUT a handoff: release the bound slot (warn-only), post the
|
|
761
|
+
* witnessed detach notice (warn-only), release the lease, remove local state.
|
|
762
|
+
* Shared by `agent detach` and the keeper's session-gone release.
|
|
763
|
+
*/
|
|
764
|
+
export async function releaseAttachLease({ client, auth, program, agentId, leaseId, reason = 'detach' }) {
|
|
765
|
+
const slotState = readSlotState(agentId);
|
|
766
|
+
if (slotState?.name) {
|
|
767
|
+
const slotEp = resolveEndpoint('v1.slots.release', { name: slotState.name });
|
|
768
|
+
try {
|
|
769
|
+
// P10: attach token so a member release removes only its own membership.
|
|
770
|
+
const slotToken = readFileSync(tokenPath(agentId), 'utf-8').trim();
|
|
771
|
+
await client.post(slotEp.path, {
|
|
772
|
+
service: slotEp.service,
|
|
773
|
+
noAuth: true,
|
|
774
|
+
headers: { Authorization: `Bearer ${slotToken}` },
|
|
775
|
+
body: { holder_session: slotState.session_label, force: false, org_id: resolveOrgId(auth, program) },
|
|
776
|
+
});
|
|
777
|
+
forgetHeldSlot(slotState.name);
|
|
778
|
+
printEvent(events.released('quox', `slot ${slotState.name}`, slotState.session_label));
|
|
779
|
+
} catch (err) {
|
|
780
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
781
|
+
printEvent(events.warned('quox', `slot ${slotState.name}`, `release failed (${status}), continuing detach`));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// P4 (docs/QUOXIDENTITY_P4_CONTRACT.md section 5): detach witnessing
|
|
786
|
+
// symmetry. Warn-only: a missed witness must never block the detach.
|
|
787
|
+
try {
|
|
788
|
+
const attachToken = readFileSync(tokenPath(agentId), 'utf-8').trim();
|
|
789
|
+
const detachEp = resolveEndpoint('agent-attach.detach');
|
|
790
|
+
await client.post(detachEp.path, {
|
|
791
|
+
service: detachEp.service,
|
|
792
|
+
noAuth: true,
|
|
793
|
+
headers: { Authorization: `Bearer ${attachToken}` },
|
|
794
|
+
body: { lease_id: leaseId, reason },
|
|
795
|
+
});
|
|
796
|
+
} catch (err) {
|
|
797
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
798
|
+
printEvent(events.warned('quox', `detach notice ${agentId}`, `failed (${status}), continuing detach`));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const ep = resolveEndpoint('agent-attach.lease.release', { leaseId });
|
|
802
|
+
await client.post(ep.path, { service: ep.service, body: {} });
|
|
803
|
+
removeAttachDir(agentId);
|
|
804
|
+
printEvent(events.released('quox', `attach ${agentId}`, `lease ${String(leaseId).slice(0, 8)}`));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ---- attach-lease auto-heartbeat keeper (reuses core/slotKeeper.runKeeper) ----
|
|
808
|
+
|
|
809
|
+
const keeperName = (agentId) => `attach-${agentId}`;
|
|
810
|
+
|
|
811
|
+
/** Is `cmd` genuinely `agent keep <agentId>` of the quox CLI? */
|
|
812
|
+
export function isAttachKeeperCmd(cmd, agentId) {
|
|
813
|
+
const c = String(cmd || '').split('\0').join(' ');
|
|
814
|
+
if (!/quox/.test(c)) return false;
|
|
815
|
+
return c.includes(`agent keep ${agentId} `) || c.endsWith(`agent keep ${agentId}`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Stop this agent's keeper (detach). Only signals a pid whose cmdline is
|
|
820
|
+
* verifiably the keeper, so a recycled lock pid is never killed.
|
|
821
|
+
*/
|
|
822
|
+
export function stopAttachKeeper(agentId, { dir = keeperDir(), readCmd, kill = process.kill.bind(process) } = {}) {
|
|
823
|
+
try {
|
|
824
|
+
const pid = parseInt(readFileSync(join(dir, `${keeperName(agentId)}.pid`), 'utf-8'), 10);
|
|
825
|
+
if (!Number.isFinite(pid) || pid === process.pid) return;
|
|
826
|
+
const cmd = (readCmd || ((p) => readFileSync(`/proc/${p}/cmdline`, 'utf-8')))(pid);
|
|
827
|
+
if (!isAttachKeeperCmd(cmd, agentId)) return;
|
|
828
|
+
kill(pid, 'SIGTERM');
|
|
829
|
+
} catch { /* no keeper, or already gone */ }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Spawn the detached keeper. Same guard as slot.js defaultSpawnKeeper: only
|
|
834
|
+
* ever from the real quox binary. QUOX_ATTACH_SESSION pins the keeper (which is
|
|
835
|
+
* re-parented away from claude) to the attach state of the session it serves.
|
|
836
|
+
*/
|
|
837
|
+
export function spawnAttachKeeper(agentId, profile, claude, { argv1 = process.argv[1], spawnFn = spawn, dir = keeperDir() } = {}) {
|
|
838
|
+
if (!isQuoxEntry(argv1)) throw new Error('not running as the quox CLI');
|
|
839
|
+
mkdirSync(dir, { recursive: true });
|
|
840
|
+
const fd = openSync(join(dir, `${keeperName(agentId)}.log`), 'a');
|
|
841
|
+
try {
|
|
842
|
+
const args = [argv1, ...(profile ? ['--profile', profile] : []), 'agent', 'keep', agentId,
|
|
843
|
+
'--claude-pid', String(claude.pid), '--claude-start', String(claude.starttime ?? '')];
|
|
844
|
+
const env = { ...process.env, ...(claude.sessionKey ? { QUOX_ATTACH_SESSION: claude.sessionKey } : {}) };
|
|
845
|
+
const child = spawnFn(process.execPath, args, { detached: true, stdio: ['ignore', fd, fd], env });
|
|
846
|
+
child.unref();
|
|
847
|
+
} finally {
|
|
848
|
+
closeSync(fd);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function registerAgentAttachCommands(agent, ctx, program) {
|
|
853
|
+
const { client, auth } = ctx;
|
|
854
|
+
|
|
855
|
+
agent
|
|
856
|
+
.command('attach')
|
|
857
|
+
.description('Attach this session as an agent: lease it, fetch its attach kit, and install it locally')
|
|
858
|
+
.argument('<agentId>', 'registry agent id, e.g. seer')
|
|
859
|
+
.option('--ttl <sec>', 'lease TTL in seconds', (v) => parseInt(v, 10), 900)
|
|
860
|
+
.option('--force', 'take over an active lease held elsewhere')
|
|
861
|
+
.option('--label <session_label>', 'session label recorded on the lease')
|
|
862
|
+
.option('--no-slot', "skip checking out the agent's bound slot even if the kit carries one")
|
|
863
|
+
.option('--no-keep', 'do not spawn the background lease keeper (heartbeats the lease every 5m until this Claude session exits)')
|
|
864
|
+
.option('--no-prime', 'do not append the Brain primer (relevant brain passages) to ATTACHED.md')
|
|
865
|
+
.option('--no-adopt', 'do not print the adoption block to stdout (for scripts; the default prints it so a session run via ! adopts the identity without a second step)')
|
|
866
|
+
.action(async (agentId, options) => {
|
|
867
|
+
auth.requireAuth();
|
|
868
|
+
// quox#567 (CLI half): resolve a display name (e.g. VULCAN) to its
|
|
869
|
+
// registry id (forge) before any lease/kit call. Never send the
|
|
870
|
+
// display name itself to the API.
|
|
871
|
+
agentId = await resolveAgentId(client, agentId);
|
|
872
|
+
const leaseEp = resolveEndpoint('agent-attach.lease.create', { agentId });
|
|
873
|
+
const attachLabel = resolveSessionLabel({ label: options.label, lookup: ctx.sessionNameLookup });
|
|
874
|
+
|
|
875
|
+
let leaseData;
|
|
876
|
+
try {
|
|
877
|
+
leaseData = await client.post(leaseEp.path, {
|
|
878
|
+
service: leaseEp.service,
|
|
879
|
+
body: {
|
|
880
|
+
session_label: attachLabel,
|
|
881
|
+
ttl_seconds: options.ttl,
|
|
882
|
+
takeover: options.force === true,
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
} catch (err) {
|
|
886
|
+
if (err instanceof QuoxApiError && err.status === 409
|
|
887
|
+
&& (err.body?.error === 'splinter_cap' || err.code === 'splinter_cap')) {
|
|
888
|
+
const max = err.body?.max ?? err.detail?.max ?? '?';
|
|
889
|
+
const active = err.body?.active ?? err.detail?.active ?? '?';
|
|
890
|
+
throw new QuoxError(
|
|
891
|
+
`agent ${agentId} is at its splinter cap (max ${max}, active ${active}). `
|
|
892
|
+
+ 'The cap is the platform setting QUOX_MAX_SPLINTERS (Settings > Platform capabilities, org or instance); raise it, or detach another session first.',
|
|
893
|
+
3,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
if (err instanceof QuoxApiError && err.status === 409) {
|
|
897
|
+
const holder = err.body?.holder ?? err.detail?.holder ?? err.detail ?? 'another session';
|
|
898
|
+
const holderText = typeof holder === 'string' ? holder : JSON.stringify(holder);
|
|
899
|
+
// Contract: "print current holder and exits code 3 with a --force
|
|
900
|
+
// hint". QuoxApiError's own status->exitCode map sends 409 to 6;
|
|
901
|
+
// this is a deliberate override to the contract's exit code.
|
|
902
|
+
throw new QuoxError(
|
|
903
|
+
`agent ${agentId} is already attached (held by ${holderText}). `
|
|
904
|
+
+ `Pass --force to take over: quox agent attach ${agentId} --force`,
|
|
905
|
+
3,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
throw err;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const lease = leaseData?.lease || {};
|
|
912
|
+
const token = leaseData?.token;
|
|
913
|
+
if (!token) {
|
|
914
|
+
throw new Error(`POST ${leaseEp.path} did not return a token; cannot fetch the attach kit.`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const kitEp = resolveEndpoint('agent-attach.kit');
|
|
918
|
+
let kit;
|
|
919
|
+
try {
|
|
920
|
+
kit = await client.get(kitEp.path, {
|
|
921
|
+
service: kitEp.service,
|
|
922
|
+
// The kit is fetched with the just-minted ATTACH token, never the
|
|
923
|
+
// caller's own session token: noAuth suppresses client.js's normal
|
|
924
|
+
// Bearer injection so this explicit header is the only credential.
|
|
925
|
+
noAuth: true,
|
|
926
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
927
|
+
});
|
|
928
|
+
} catch (err) {
|
|
929
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
930
|
+
// quox#1026 (b): the kit cannot resolve this agent at all, so no retry can succeed. Release the lease
|
|
931
|
+
// just granted (never leave it held) and say plainly which agents are attachable.
|
|
932
|
+
if (err instanceof QuoxApiError && err.status === 404 && err.code === 'unknown_agent' && lease.id) {
|
|
933
|
+
let released = false;
|
|
934
|
+
try {
|
|
935
|
+
const relEp = resolveEndpoint('agent-attach.lease.release', { leaseId: lease.id });
|
|
936
|
+
await client.post(relEp.path, { service: relEp.service, body: {} });
|
|
937
|
+
released = true;
|
|
938
|
+
} catch { /* reported below */ }
|
|
939
|
+
throw new Error(
|
|
940
|
+
`"${agentId}" is not attachable: only core registry agents and custom assistants can be attached `
|
|
941
|
+
+ '(an agent made with `quox agent create` has no attach kit yet, quox#1026). '
|
|
942
|
+
+ (released ? `The lease ${lease.id} was released.` : `Releasing lease ${lease.id} failed: run \`quox agent detach ${agentId} --lease ${lease.id}\`.`)
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
throw new Error(
|
|
946
|
+
`GET ${kitEp.path} failed (${status}) after the lease was granted (lease ${lease.id || 'unknown'}). `
|
|
947
|
+
+ `The lease is still active: retry the kit fetch, or run \`quox agent detach ${agentId}${lease.id ? ` --lease ${lease.id}` : ''}\` to release it. `
|
|
948
|
+
+ `Original error: ${err.message}`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const splinter = splinterInfoFrom(leaseData?.splinter);
|
|
953
|
+
const paths = installAttachKit(agentId, { kit, token, splinter });
|
|
954
|
+
// #893: remember the label and TTL (beside the token, not in kit.json,
|
|
955
|
+
// which stays exactly what the server sent) so the keeper can re-lease a
|
|
956
|
+
// lapsed attach under the same identity.
|
|
957
|
+
try {
|
|
958
|
+
writeFile0600(leaseMetaPath(agentId), `${JSON.stringify({ session_label: attachLabel, ttl_seconds: options.ttl })}\n`);
|
|
959
|
+
} catch { /* the attach stands; a re-lease falls back to defaults */ }
|
|
960
|
+
// ONE-BRAIN CX4: agents whose resolved tool list carries brain_context get
|
|
961
|
+
// a primer from the brain. Best effort, bounded, never fails the attach.
|
|
962
|
+
let primer = '';
|
|
963
|
+
if (options.prime !== false && kitHasBrainContext(kit)) {
|
|
964
|
+
primer = await buildBrainPrimer(client);
|
|
965
|
+
try {
|
|
966
|
+
writeFile0600(instructionsPath(agentId), `${readFileSync(instructionsPath(agentId), 'utf-8')}\n${primer}`);
|
|
967
|
+
} catch { /* primer is a bonus; the attach stands */ }
|
|
968
|
+
}
|
|
969
|
+
writeSplinterFile(agentId, splinter.id, splinter.count);
|
|
970
|
+
const leaseId = lease.id || kit?.lease?.id || '';
|
|
971
|
+
const expires = lease.expires_at || kit?.lease?.expires_at || leaseData?.expires_at || 'unknown';
|
|
972
|
+
|
|
973
|
+
// P3 (docs/QUOXIDENTITY_P3_CONTRACT.md, territory G): the kit carries
|
|
974
|
+
// the agent's bound slot (a durable ADDRESS, Territory F hydration).
|
|
975
|
+
// Check it out as a separate "voice hold" — Lease = identity hold,
|
|
976
|
+
// slot hold = voice, by design. A slot already held elsewhere (e.g. a
|
|
977
|
+
// warden/supervisor) must never fail an otherwise-successful attach:
|
|
978
|
+
// warn, name the holder, hint the takeover, and continue (exit 0).
|
|
979
|
+
if (kit?.slot?.name && options.slot !== false) {
|
|
980
|
+
const slotName = kit.slot.name;
|
|
981
|
+
// DEFECT 3 FIX (QuoxIdentity): a resident session's voice identity
|
|
982
|
+
// (checkSlotSayAuthor, collector server.js) resolves to
|
|
983
|
+
// buildSelfIdentity(hostId) = `resident:<host>`, exactly the way
|
|
984
|
+
// devSupervisor.js checks its own slot out (see checkoutSlot there).
|
|
985
|
+
// Checking out with a synthetic `attach-<agentId>-<lease8>` label
|
|
986
|
+
// instead means a resident's later `slot say` 403s: holder_session
|
|
987
|
+
// never matches its resolved identity. Same resident-detection
|
|
988
|
+
// signal auth.js's own refresh() uses (`auth?.kind === 'resident'`),
|
|
989
|
+
// not a new one. A non-resident (ordinary human JWT) keeps the
|
|
990
|
+
// existing synthetic-label/interactive behaviour unchanged.
|
|
991
|
+
const tokens = typeof auth.getTokens === 'function' ? auth.getTokens() : null;
|
|
992
|
+
const isResident = tokens?.kind === 'resident' && !!tokens?.host_id;
|
|
993
|
+
const sessionLabel = isResident
|
|
994
|
+
// Per-splinter label: two same-host residents must not share a
|
|
995
|
+
// holder_session, or the second is mistaken for the holder
|
|
996
|
+
// re-checking-out (no member join) and its detach frees the hold.
|
|
997
|
+
// "@" not "#": the collector rejects "#" in say/from labels.
|
|
998
|
+
? `${buildSelfIdentity(tokens.host_id)}@${String(leaseId).slice(0, 8)}`
|
|
999
|
+
: (attachLabel || `attach-${agentId}-${String(leaseId).slice(0, 8)}`);
|
|
1000
|
+
const slotEp = resolveEndpoint('v1.slots.checkout', { name: slotName });
|
|
1001
|
+
try {
|
|
1002
|
+
// P10: present the ATTACH token (not the profile token) so the
|
|
1003
|
+
// collector resolves the caller as the agent; a held slot of the
|
|
1004
|
+
// same agent then JOINS as a member instead of 409.
|
|
1005
|
+
const slotRes = await client.post(slotEp.path, {
|
|
1006
|
+
service: slotEp.service,
|
|
1007
|
+
noAuth: true,
|
|
1008
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1009
|
+
body: {
|
|
1010
|
+
holder_session: sessionLabel,
|
|
1011
|
+
holder_kind: isResident ? 'resident' : 'interactive',
|
|
1012
|
+
...(isResident ? { host_id: tokens.host_id } : {}),
|
|
1013
|
+
org_id: resolveOrgId(auth, program),
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
if (slotRes?.joined === true) {
|
|
1017
|
+
const member = {
|
|
1018
|
+
splinter: slotRes.member?.splinter ?? null,
|
|
1019
|
+
member_key: slotRes.member?.member_key ?? null,
|
|
1020
|
+
};
|
|
1021
|
+
recordHeldSlot(slotName, sessionLabel, undefined, (ctx.claudeSessionInfo || claudeSessionInfo)(), member);
|
|
1022
|
+
writeSlotState(agentId, { name: slotName, session_label: sessionLabel, member: true, ...member });
|
|
1023
|
+
printInfo(`Joined slot ${slotName} as member ${member.splinter ?? member.member_key ?? 'unknown'}`);
|
|
1024
|
+
} else {
|
|
1025
|
+
recordHeldSlot(slotName, sessionLabel, undefined, (ctx.claudeSessionInfo || claudeSessionInfo)());
|
|
1026
|
+
writeSlotState(agentId, { name: slotName, session_label: sessionLabel });
|
|
1027
|
+
printEvent(events.assigned('quox', `slot ${slotName}`, sessionLabel));
|
|
1028
|
+
}
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
const is409 = err instanceof QuoxApiError && err.status === 409;
|
|
1031
|
+
const holder = (is409 && (err.detail?.holder_session || err.body?.holder_session)) || 'another session';
|
|
1032
|
+
printEvent(events.warned(
|
|
1033
|
+
'quox',
|
|
1034
|
+
`slot ${slotName}`,
|
|
1035
|
+
is409 ? `held by ${holder}` : `checkout failed (${err instanceof QuoxApiError ? err.status : 'network error'})`
|
|
1036
|
+
));
|
|
1037
|
+
if (is409) {
|
|
1038
|
+
printInfo(` takeover: quox slot checkout ${slotName} --force`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// F6 (QuoxIdentity CLI finding-fix): a lease granted with little TTL
|
|
1044
|
+
// left will expire mid-work if the session never heartbeats. Warn at
|
|
1045
|
+
// grant time rather than only on a later attach-status check.
|
|
1046
|
+
const msLeft = expires !== 'unknown' ? new Date(expires).getTime() - Date.now() : NaN;
|
|
1047
|
+
if (Number.isFinite(msLeft) && msLeft > 0 && msLeft < 15 * 60 * 1000) {
|
|
1048
|
+
printEvent(events.warned(
|
|
1049
|
+
'quox',
|
|
1050
|
+
`attach ${agentId}`,
|
|
1051
|
+
`expires in ${Math.max(1, Math.round(msLeft / 60000))}m, run attach-heartbeat`
|
|
1052
|
+
));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const splinterTag = splinter.present && splinter.ordinal === 1 && splinter.siblings.length ? ' (S1)' : '';
|
|
1056
|
+
printEvent(line('quox', `attach ${agentId}${splinterTag}`, { result: `lease ${String(leaseId).slice(0, 8)} · expires ${expires}` }));
|
|
1057
|
+
if (splinter.present && splinter.ordinal > 1) {
|
|
1058
|
+
printInfo(`Joined ${kit?.display_name || agentId} as SPLINTER ${splinter.id} (${splinter.count} live session${splinter.count === 1 ? '' : 's'})`);
|
|
1059
|
+
}
|
|
1060
|
+
if (splinter.present && (splinter.ordinal > 1 || splinter.siblings.length)) {
|
|
1061
|
+
for (const sib of splinter.siblings) {
|
|
1062
|
+
printInfo(` ${sib.id} ${sib.session_label || '(no label)'} · heartbeat ${fmtAge(sib.last_heartbeat_at)}`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
printInfo(` kit: ${paths.kitPath}`);
|
|
1066
|
+
printInfo(` token: ${paths.tokenPath}`);
|
|
1067
|
+
printInfo(` instructions: ${paths.instructionsPath}`);
|
|
1068
|
+
// Auto-heartbeat: the lease TTL is 15m and nothing renewed it unless the
|
|
1069
|
+
// model remembered `attach-heartbeat`. A detached keeper (bound to this
|
|
1070
|
+
// claude session) renews every 5m and releases the lease when the
|
|
1071
|
+
// session exits. Only when a claude session is detected.
|
|
1072
|
+
if (options.keep !== false) {
|
|
1073
|
+
const me = claudeSessionInfo();
|
|
1074
|
+
if (me) {
|
|
1075
|
+
try {
|
|
1076
|
+
stopAttachKeeper(agentId, { dir: ctx.keeperDir });
|
|
1077
|
+
(ctx.spawnKeeper || spawnAttachKeeper)(agentId, program.opts().profile, me);
|
|
1078
|
+
printInfo(` keeper: heartbeating this lease every 5m until the claude session exits (--no-keep to opt out)`);
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
printEvent(events.warned('quox', `keeper ${agentId}`, `not started (${err.message}); run attach-heartbeat yourself`));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
printInfo(` detach with: quox agent detach ${agentId}`);
|
|
1085
|
+
// P9: surface the (automatic) session binding so multi-session use is
|
|
1086
|
+
// legible. The key is derived from the process tree — nothing to set.
|
|
1087
|
+
{
|
|
1088
|
+
const key = sessionKey();
|
|
1089
|
+
if (key) {
|
|
1090
|
+
printInfo(` session: ${key}, this attach is scoped to THIS session; other sessions on this box stay independent`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
// P2 (docs/QUOXIDENTITY_P2_CONTRACT.md, territory E): kit.memory.handoff
|
|
1094
|
+
// is the most recent handoff note left by a prior attached session.
|
|
1095
|
+
// Note it so the session knows continuity was restored, not starting cold.
|
|
1096
|
+
const handoff = kit?.memory?.handoff;
|
|
1097
|
+
if (handoff) {
|
|
1098
|
+
const createdAt = handoff.created_at || handoff.createdAt || 'unknown';
|
|
1099
|
+
printEvent(line('quox', 'continuity restored', { result: `handoff ${createdAt}` }));
|
|
1100
|
+
}
|
|
1101
|
+
if (program.opts().output === 'json') formatDetail({ lease, kit }, { format: 'json' });
|
|
1102
|
+
|
|
1103
|
+
// Print the adoption block to stdout by default. The point: a human runs
|
|
1104
|
+
// `! quox agent attach <agent>` inside a live Claude/Codex session, and
|
|
1105
|
+
// the identity SEER should adopt lands directly in that session's context
|
|
1106
|
+
// instead of relying on a separate "now go read ATTACHED.md" step that is
|
|
1107
|
+
// easy to skip. --no-adopt (or -o json) suppresses it for scripts.
|
|
1108
|
+
if (options.adopt !== false && program.opts().output !== 'json') {
|
|
1109
|
+
const bar = '─'.repeat(60);
|
|
1110
|
+
printInfo('');
|
|
1111
|
+
printInfo(bar);
|
|
1112
|
+
printInfo(`ADOPT NOW: you are attached as ${agentId}. From this point on in this`);
|
|
1113
|
+
printInfo(`session, operate AS ${agentId} under the identity and tool scope below.`);
|
|
1114
|
+
printInfo(bar);
|
|
1115
|
+
printInfo(renderAttachedInstructions({ agentId, kit, splinter }));
|
|
1116
|
+
if (primer) printInfo(`\n${primer}`);
|
|
1117
|
+
printInfo(bar);
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
agent
|
|
1122
|
+
.command('detach')
|
|
1123
|
+
.description('Detach this session from an agent: release its lease and remove the local attach kit')
|
|
1124
|
+
.argument('<agentId>', 'registry agent id')
|
|
1125
|
+
.option('--handoff <file>', 'path to a file with a handoff note (state, open threads, next moves) to store before detaching')
|
|
1126
|
+
.option('--handoff-text <text>', 'handoff note text, provided inline instead of a file')
|
|
1127
|
+
.option('--force-detach', 'release the lease even if storing the handoff note fails')
|
|
1128
|
+
.option('--lease <leaseId>', 'release this lease when there is no local attach kit (an attach that failed after its lease was granted); the server allows only its holder or an org admin')
|
|
1129
|
+
.option('--no-brain-note', 'do not also save the handoff to the knowledge brain as a private note')
|
|
1130
|
+
.action(async (agentId, options) => {
|
|
1131
|
+
auth.requireAuth();
|
|
1132
|
+
// quox#567 (CLI half): same display-name -> registry-id resolution as attach.
|
|
1133
|
+
agentId = await resolveAgentId(client, agentId);
|
|
1134
|
+
const stored = readStoredKit(agentId);
|
|
1135
|
+
const leaseId = stored?.lease?.id;
|
|
1136
|
+
// #1003: no local kit (attach failed after the lease was granted). Release exactly the lease the caller
|
|
1137
|
+
// names, never a guessed one: a lease held by another session must not be released by inference.
|
|
1138
|
+
if (!leaseId && options.lease) {
|
|
1139
|
+
const relEp = resolveEndpoint('agent-attach.lease.release', { leaseId: options.lease });
|
|
1140
|
+
await client.post(relEp.path, { service: relEp.service, body: {} });
|
|
1141
|
+
removeAttachDir(agentId);
|
|
1142
|
+
printEvent(events.released('quox', `attach ${agentId}`, `lease ${String(options.lease).slice(0, 8)} (no local kit)`));
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (!leaseId) {
|
|
1146
|
+
throw new Error(`${noLocalLeaseError(agentId, 'attach-status').message} `
|
|
1147
|
+
+ `If an attach failed after its lease was granted, release that lease with \`quox agent detach ${agentId} --lease <leaseId>\` `
|
|
1148
|
+
+ `(the lease id is in the attach error and in \`quox agent attach-status ${agentId}\`).`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (options.handoff && options.handoffText) {
|
|
1152
|
+
throw new QuoxError('Pass only one of --handoff or --handoff-text.', 2);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// P2 (docs/QUOXIDENTITY_P2_CONTRACT.md, territory E): store the handoff
|
|
1156
|
+
// BEFORE releasing the lease, and abort the detach (naming the endpoint
|
|
1157
|
+
// + status) rather than silently losing the handoff on store failure.
|
|
1158
|
+
// --force-detach is the explicit override for that abort.
|
|
1159
|
+
let handoffContent = null;
|
|
1160
|
+
if (options.handoff) {
|
|
1161
|
+
try {
|
|
1162
|
+
handoffContent = readFileSync(options.handoff, 'utf-8');
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
throw new Error(`Cannot read handoff file ${options.handoff}: ${err.message}`);
|
|
1165
|
+
}
|
|
1166
|
+
} else if (options.handoffText) {
|
|
1167
|
+
handoffContent = options.handoffText;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (handoffContent !== null) {
|
|
1171
|
+
// finding-10: the handoff runs at the very end of a session, often long
|
|
1172
|
+
// after the last heartbeat, so the short-lived attach token has usually
|
|
1173
|
+
// expired by now — which 401'd the handoff and lost continuity. Refresh
|
|
1174
|
+
// it first (heartbeat uses the resident session, so it works even with
|
|
1175
|
+
// an expired attach token, as long as the lease still lives).
|
|
1176
|
+
const token = await ensureFreshAttachToken(client, agentId);
|
|
1177
|
+
|
|
1178
|
+
const handoffEp = resolveEndpoint('agent-attach.handoff');
|
|
1179
|
+
try {
|
|
1180
|
+
await client.post(handoffEp.path, {
|
|
1181
|
+
service: handoffEp.service,
|
|
1182
|
+
// Same posture as the kit fetch: authenticate with the attach
|
|
1183
|
+
// JWT explicitly, not the caller's own session token.
|
|
1184
|
+
noAuth: true,
|
|
1185
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1186
|
+
body: { content: handoffContent },
|
|
1187
|
+
});
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1190
|
+
const message =
|
|
1191
|
+
`POST ${handoffEp.path} failed (${status}); the lease was NOT released so the handoff `
|
|
1192
|
+
+ `is not lost (lease ${String(leaseId).slice(0, 8)}). Retry the detach, or pass `
|
|
1193
|
+
+ `--force-detach to release the lease anyway. Original error: ${err.message}`;
|
|
1194
|
+
if (!options.forceDetach) throw new Error(message);
|
|
1195
|
+
printEvent(events.warned('quox', `handoff ${agentId}`, `store failed (${status}), forcing detach`));
|
|
1196
|
+
}
|
|
1197
|
+
// ONE-BRAIN CX3: also keep the handoff in the knowledge brain. Best
|
|
1198
|
+
// effort: a capture failure warns and never blocks the detach.
|
|
1199
|
+
if (options.brainNote !== false) {
|
|
1200
|
+
const res = await saveHandoffNote(client, {
|
|
1201
|
+
agentId,
|
|
1202
|
+
content: handoffContent,
|
|
1203
|
+
prov: decisionProvenance(),
|
|
1204
|
+
sessionName: resolveSessionLabel({ lookup: ctx.sessionNameLookup }),
|
|
1205
|
+
});
|
|
1206
|
+
if (res.ok) printEvent(line('quox', `handoff note ${agentId}`, { result: 'saved to brain' }));
|
|
1207
|
+
else printEvent(events.warned('quox', `handoff note ${agentId}`, `brain save failed (${res.error}), continuing detach`));
|
|
1208
|
+
}
|
|
1209
|
+
} else {
|
|
1210
|
+
printInfo(' Tip: a handoff preserves continuity for the next session. Pass --handoff <file> or --handoff-text "...".');
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// P3/P4: slot release, witnessed detach notice, lease release (shared
|
|
1214
|
+
// with the keeper's session-gone release).
|
|
1215
|
+
try { stopAttachKeeper(agentId, { dir: ctx.keeperDir }); } catch { /* best effort */ }
|
|
1216
|
+
await releaseAttachLease({
|
|
1217
|
+
client, auth, program, agentId, leaseId,
|
|
1218
|
+
reason: handoffContent !== null ? 'handoff_and_detach' : 'detach',
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
agent
|
|
1223
|
+
.command('keep', { hidden: true })
|
|
1224
|
+
.description('Background attach-lease keeper: heartbeat the lease every 5m until the owning Claude session exits (spawned by attach)')
|
|
1225
|
+
.argument('<agentId>', 'canonical registry agent id (passed by attach)')
|
|
1226
|
+
.option('--claude-pid <pid>', 'pid of the claude session this keeper serves')
|
|
1227
|
+
.option('--claude-start <ticks>', 'start ticks of that claude process (guards against pid reuse)')
|
|
1228
|
+
.action(async (agentId, options) => {
|
|
1229
|
+
auth.requireAuth();
|
|
1230
|
+
const me = options.claudePid
|
|
1231
|
+
? { pid: Number(options.claudePid), starttime: options.claudeStart || null }
|
|
1232
|
+
: claudeSessionInfo();
|
|
1233
|
+
const res = await runKeeper({
|
|
1234
|
+
name: keeperName(agentId),
|
|
1235
|
+
session: agentId,
|
|
1236
|
+
claude: me,
|
|
1237
|
+
probe: ctx.probe || makeProcessProbe(procStartTime),
|
|
1238
|
+
sleep: ctx.sleep,
|
|
1239
|
+
dir: ctx.keeperDir,
|
|
1240
|
+
heartbeat: () => heartbeatAttachLease(client, agentId, { getOrgId: () => resolveOrgId(auth, program) }),
|
|
1241
|
+
reacquire: () => reacquireAttachLease(client, agentId),
|
|
1242
|
+
release: async () => {
|
|
1243
|
+
const leaseId = readStoredKit(agentId)?.lease?.id;
|
|
1244
|
+
if (!leaseId) return;
|
|
1245
|
+
await releaseAttachLease({ client, auth, program, agentId, leaseId });
|
|
1246
|
+
},
|
|
1247
|
+
forget: () => {},
|
|
1248
|
+
});
|
|
1249
|
+
process.exitCode = res.code;
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
agent
|
|
1253
|
+
.command('attach-status')
|
|
1254
|
+
.description('Show the current attach lease status for an agent')
|
|
1255
|
+
.argument('<agentId>', 'registry agent id')
|
|
1256
|
+
.action(async (agentId) => {
|
|
1257
|
+
auth.requireAuth();
|
|
1258
|
+
// quox#567 (CLI half): same display-name -> registry-id resolution as attach.
|
|
1259
|
+
agentId = await resolveAgentId(client, agentId);
|
|
1260
|
+
const ep = resolveEndpoint('agent-attach.status', { agentId });
|
|
1261
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
1262
|
+
const active = data?.active_lease || null;
|
|
1263
|
+
|
|
1264
|
+
if (!active) {
|
|
1265
|
+
printInfo(`No active attach lease for ${agentId}. my_grant: ${!!data?.my_grant}`);
|
|
1266
|
+
} else {
|
|
1267
|
+
formatDetail(active, { format: program.opts().output, title: `Attach lease: ${agentId}` });
|
|
1268
|
+
printInfo(` my_grant: ${!!data?.my_grant}`);
|
|
1269
|
+
}
|
|
1270
|
+
if (Array.isArray(data?.splinters) && data.splinters.length) {
|
|
1271
|
+
printInfo(' splinters:');
|
|
1272
|
+
for (const sp of data.splinters) {
|
|
1273
|
+
printInfo(` ${sp.id}${sp.is_root ? ' (root)' : ''} ${sp.session_label || '(no label)'} · heartbeat ${fmtAge(sp.last_heartbeat_at)}`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// P3: best-effort bound slot + current holder. Never fails
|
|
1278
|
+
// attach-status: an org with no binding wired yet (or a caller without
|
|
1279
|
+
// the route deployed) degrades to silence, not an error.
|
|
1280
|
+
try {
|
|
1281
|
+
const bindingEp = resolveEndpoint('agent-attach.slot-binding.get', { agentId });
|
|
1282
|
+
// quox#1308: the route answers { binding: { slot_name, ... } }; this read .name off the body and never printed.
|
|
1283
|
+
const res = await client.get(bindingEp.path, { service: bindingEp.service });
|
|
1284
|
+
const slotName = res?.binding?.slot_name;
|
|
1285
|
+
if (slotName) printInfo(` slot: ${slotName}`);
|
|
1286
|
+
} catch {
|
|
1287
|
+
// No binding, or the route isn't deployed yet: not a status-command failure.
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
agent
|
|
1294
|
+
.command('attach-heartbeat')
|
|
1295
|
+
.description("Heartbeat this session's attach lease, rotating the stored token if one is issued")
|
|
1296
|
+
.argument('<agentId>', 'registry agent id')
|
|
1297
|
+
.action(async (agentId) => {
|
|
1298
|
+
auth.requireAuth();
|
|
1299
|
+
// quox#567 (CLI half): same display-name -> registry-id resolution as attach.
|
|
1300
|
+
agentId = await resolveAgentId(client, agentId);
|
|
1301
|
+
const leaseId = readStoredKit(agentId)?.lease?.id;
|
|
1302
|
+
if (!leaseId) throw noLocalLeaseError(agentId, 'attach-heartbeat');
|
|
1303
|
+
|
|
1304
|
+
const { expires } = await heartbeatAttachLease(client, agentId, { getOrgId: () => resolveOrgId(auth, program) });
|
|
1305
|
+
printEvent(line('quox', `heartbeat ${agentId}`, { result: `lease ${String(leaseId).slice(0, 8)} · expires ${expires}`, status: 'ok' }));
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// F3 (QuoxIdentity CLI finding-fix, SEER-TERMINAL): the first-class way to
|
|
1309
|
+
// do WITNESSED work AS the attached agent. Reads the token this session
|
|
1310
|
+
// stored on `attach` and calls the collector's governed
|
|
1311
|
+
// /api/v1/tools/execute with it, authenticated the same way the kit fetch
|
|
1312
|
+
// is: noAuth + an explicit Authorization header, never the caller's own
|
|
1313
|
+
// session token. Before this verb, witnessed work as the attached identity
|
|
1314
|
+
// meant hand-curling the endpoint.
|
|
1315
|
+
agent
|
|
1316
|
+
.command('exec')
|
|
1317
|
+
.description('Execute a tool as the attached agent (witnessed, uses the stored attach token)')
|
|
1318
|
+
.argument('<agentId>', 'registry agent id')
|
|
1319
|
+
.requiredOption('--tool <name>', 'tool name to execute, e.g. memory_search')
|
|
1320
|
+
.requiredOption('--input <json>', 'tool input, as a JSON string, e.g. \'{"query":"..."}\'')
|
|
1321
|
+
.action(async (agentId, options) => {
|
|
1322
|
+
// quox#567 (CLI half): same display-name -> registry-id resolution as
|
|
1323
|
+
// attach. Must run BEFORE ensureFreshAttachToken, since the stored
|
|
1324
|
+
// attach kit lives under the canonical id (~/.quox/attach/<id>/).
|
|
1325
|
+
agentId = await resolveAgentId(client, agentId);
|
|
1326
|
+
|
|
1327
|
+
// finding-10: refresh a near-expired token before use (heartbeats the
|
|
1328
|
+
// lease, which authenticates with the resident session, so it works even
|
|
1329
|
+
// after the short-lived attach token has lapsed).
|
|
1330
|
+
const token = await ensureFreshAttachToken(client, agentId);
|
|
1331
|
+
|
|
1332
|
+
const input = parseJSON(options.input, '--input');
|
|
1333
|
+
const orgId = resolveOrgId(auth, program);
|
|
1334
|
+
|
|
1335
|
+
const ep = resolveEndpoint('v1.tools.execute');
|
|
1336
|
+
let data;
|
|
1337
|
+
try {
|
|
1338
|
+
data = await client.post(ep.path, {
|
|
1339
|
+
service: ep.service,
|
|
1340
|
+
noAuth: true,
|
|
1341
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1342
|
+
body: { tool_name: options.tool, input, org_id: orgId },
|
|
1343
|
+
});
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1346
|
+
if (err instanceof QuoxApiError && (err.status === 401 || err.status === 403)) {
|
|
1347
|
+
throw new Error(
|
|
1348
|
+
`POST ${ep.path} failed (${status}): the attach token for "${agentId}" is no longer valid `
|
|
1349
|
+
+ `(expired or revoked lease). Re-attach with \`quox agent attach ${agentId}\`.`
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// finding (autonomous loop): the glyph must reflect the TOOL result, not
|
|
1356
|
+
// just that the HTTP transport returned 200. A tool that reports failure
|
|
1357
|
+
// (success:false OR ok:false) is a FAILURE; printing ✔ made a scanning
|
|
1358
|
+
// agent read failures as passes ([error-body-is-not-data] at the CLI
|
|
1359
|
+
// surface). Set a non-zero exit code too so a scripted / feedback-loop
|
|
1360
|
+
// caller can't mistake it for a pass.
|
|
1361
|
+
const toolOk = !execResultFailed(data);
|
|
1362
|
+
// Skip the announce line under --json/-o json: a scripted caller parses
|
|
1363
|
+
// stdout as a single JSON document, and the glyph line would break that.
|
|
1364
|
+
if (program.opts().output !== 'json') {
|
|
1365
|
+
printEvent(line('quox', `exec ${options.tool}`, {
|
|
1366
|
+
result: toolOk ? agentId : `${agentId} · ${execResultError(data)}`,
|
|
1367
|
+
status: toolOk ? 'ok' : 'fail',
|
|
1368
|
+
}));
|
|
1369
|
+
}
|
|
1370
|
+
formatDetail(data, { format: program.opts().output, title: `Tool result: ${options.tool}` });
|
|
1371
|
+
if (!toolOk) process.exitCode = 1;
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
// P3 (docs/QUOXIDENTITY_P3_CONTRACT.md, territory F/G): admin verbs mapping
|
|
1375
|
+
// to the PUT/DELETE slot-binding routes. Binding an address is an admin
|
|
1376
|
+
// act (org owner/admin only, not attach tokens) — org-admin errors surface
|
|
1377
|
+
// the endpoint + status rather than a bare "forbidden".
|
|
1378
|
+
agent
|
|
1379
|
+
.command('bind-slot')
|
|
1380
|
+
.description("Bind an agent identity to a durable slot address (org admin only)")
|
|
1381
|
+
.argument('<agentId>', 'registry agent id')
|
|
1382
|
+
.argument('<slotName>', 'slot name to bind, e.g. seer-address')
|
|
1383
|
+
.action(async (agentId, slotName) => {
|
|
1384
|
+
auth.requireAuth();
|
|
1385
|
+
const ep = resolveEndpoint('agent-attach.slot-binding.set', { agentId });
|
|
1386
|
+
let data;
|
|
1387
|
+
try {
|
|
1388
|
+
data = await client.put(ep.path, { service: ep.service, body: { slot_name: slotName } });
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1391
|
+
throw new Error(`PUT ${ep.path} failed (${status}): ${err.message}`);
|
|
1392
|
+
}
|
|
1393
|
+
printEvent(events.created('quox', `bind-slot ${agentId}`, slotName));
|
|
1394
|
+
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// quox#1308: bind-slot was write-only. Reading an address is open to any org member (the GET route has no
|
|
1398
|
+
// admin gate). No binding is an answer, not a failure: exit 0, and --json prints { binding: null }.
|
|
1399
|
+
agent
|
|
1400
|
+
.command('slot')
|
|
1401
|
+
.description("Show which slot an agent is bound to (the only slot a user-made agent may post to)")
|
|
1402
|
+
.argument('<agentId>', 'registry agent id')
|
|
1403
|
+
.action(async (agentId) => {
|
|
1404
|
+
auth.requireAuth();
|
|
1405
|
+
const ep = resolveEndpoint('agent-attach.slot-binding.get', { agentId });
|
|
1406
|
+
let binding = null;
|
|
1407
|
+
try {
|
|
1408
|
+
const res = await client.get(ep.path, { service: ep.service });
|
|
1409
|
+
binding = res?.binding || null;
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1412
|
+
if (!(status === 404 && err.code === 'no_binding')) throw new Error(`GET ${ep.path} failed (${status}): ${err.message}`);
|
|
1413
|
+
}
|
|
1414
|
+
if (program.opts().output === 'json') { formatDetail({ binding }, { format: 'json' }); return; }
|
|
1415
|
+
if (!binding) { printInfo(`${agentId} has no slot binding. An org admin can bind one: quox agent bind-slot ${agentId} <slot>`); return; }
|
|
1416
|
+
printInfo(`${agentId} is bound to slot ${binding.slot_name}${binding.created_at ? ` (since ${binding.created_at})` : ''}`);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
agent
|
|
1420
|
+
.command('unbind-slot')
|
|
1421
|
+
.description("Remove an agent identity's slot binding (org admin only)")
|
|
1422
|
+
.argument('<agentId>', 'registry agent id')
|
|
1423
|
+
.action(async (agentId) => {
|
|
1424
|
+
auth.requireAuth();
|
|
1425
|
+
const ep = resolveEndpoint('agent-attach.slot-binding.delete', { agentId });
|
|
1426
|
+
let data;
|
|
1427
|
+
try {
|
|
1428
|
+
data = await client.delete(ep.path, { service: ep.service });
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1431
|
+
throw new Error(`DELETE ${ep.path} failed (${status}): ${err.message}`);
|
|
1432
|
+
}
|
|
1433
|
+
printEvent(events.released('quox', `bind-slot ${agentId}`, agentId));
|
|
1434
|
+
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
// P1 grant model (owner decision: per-agent attach grant, default owner-only).
|
|
1438
|
+
// These verbs are the only client for the auth grant routes; without them the
|
|
1439
|
+
// grant model is unreachable except by raw HTTP. Org owner/admin only, so the
|
|
1440
|
+
// errors surface the endpoint + status rather than a bare "forbidden".
|
|
1441
|
+
agent
|
|
1442
|
+
.command('grant-attach')
|
|
1443
|
+
.description('Grant a user permission to attach as an agent (org admin only)')
|
|
1444
|
+
.argument('<agentId>', 'registry agent id, e.g. seer')
|
|
1445
|
+
.argument('<userId>', 'the user id to grant attach permission to')
|
|
1446
|
+
.action(async (agentId, userId) => {
|
|
1447
|
+
auth.requireAuth();
|
|
1448
|
+
// quox#567 (CLI half): same display-name -> registry-id resolution as attach.
|
|
1449
|
+
agentId = await resolveAgentId(client, agentId);
|
|
1450
|
+
const ep = resolveEndpoint('agent-attach.grants.add', { agentId });
|
|
1451
|
+
let data;
|
|
1452
|
+
try {
|
|
1453
|
+
data = await client.post(ep.path, { service: ep.service, body: { user_id: userId } });
|
|
1454
|
+
} catch (err) {
|
|
1455
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1456
|
+
throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
|
|
1457
|
+
}
|
|
1458
|
+
printEvent(events.created('quox', `grant-attach ${agentId}`, userId));
|
|
1459
|
+
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
agent
|
|
1463
|
+
.command('grant-attach-all')
|
|
1464
|
+
.description('Grant a user permission to attach as ANY agent in the org, present and future (org admin only)')
|
|
1465
|
+
.argument('<userId>', 'the user id to grant roster-wide attach permission to')
|
|
1466
|
+
.action(async (userId) => {
|
|
1467
|
+
auth.requireAuth();
|
|
1468
|
+
const ep = resolveEndpoint('agent-attach.grants.add-all');
|
|
1469
|
+
let data;
|
|
1470
|
+
try {
|
|
1471
|
+
data = await client.post(ep.path, { service: ep.service, body: { user_id: userId } });
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1474
|
+
throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
|
|
1475
|
+
}
|
|
1476
|
+
printEvent(events.created('quox', 'grant-attach-all', userId));
|
|
1477
|
+
printInfo(` ${userId} can now attach ANY agent in the org (no per-agent grant needed). Revoke: quox agent revoke-attach-all ${userId}`);
|
|
1478
|
+
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
agent
|
|
1482
|
+
.command('revoke-attach-all')
|
|
1483
|
+
.description("Revoke a user's roster-wide (attach-any) grant (org admin only)")
|
|
1484
|
+
.argument('<userId>', 'the user id to revoke')
|
|
1485
|
+
.action(async (userId) => {
|
|
1486
|
+
auth.requireAuth();
|
|
1487
|
+
const ep = resolveEndpoint('agent-attach.grants.remove-all', { granteeUserId: userId });
|
|
1488
|
+
let data;
|
|
1489
|
+
try {
|
|
1490
|
+
data = await client.delete(ep.path, { service: ep.service });
|
|
1491
|
+
} catch (err) {
|
|
1492
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1493
|
+
throw new Error(`DELETE ${ep.path} failed (${status}): ${err.message}`);
|
|
1494
|
+
}
|
|
1495
|
+
printEvent(events.released('quox', 'grant-attach-all', userId));
|
|
1496
|
+
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
agent
|
|
1500
|
+
.command('revoke-attach')
|
|
1501
|
+
.description("Revoke a user's attach grant for an agent (org admin only)")
|
|
1502
|
+
.argument('<agentId>', 'registry agent id')
|
|
1503
|
+
.argument('<userId>', 'the user id to revoke')
|
|
1504
|
+
.action(async (agentId, userId) => {
|
|
1505
|
+
auth.requireAuth();
|
|
1506
|
+
const ep = resolveEndpoint('agent-attach.grants.remove', { agentId, granteeUserId: userId });
|
|
1507
|
+
let data;
|
|
1508
|
+
try {
|
|
1509
|
+
data = await client.delete(ep.path, { service: ep.service });
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
const status = err instanceof QuoxApiError ? err.status : 'network error';
|
|
1512
|
+
throw new Error(`DELETE ${ep.path} failed (${status}): ${err.message}`);
|
|
1513
|
+
}
|
|
1514
|
+
printEvent(events.released('quox', `grant-attach ${agentId}`, userId));
|
|
1515
|
+
if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
agent
|
|
1519
|
+
.command('list-grants')
|
|
1520
|
+
.description('List attach grants for an agent (org admin only)')
|
|
1521
|
+
.argument('<agentId>', 'registry agent id')
|
|
1522
|
+
.action(async (agentId) => {
|
|
1523
|
+
auth.requireAuth();
|
|
1524
|
+
const ep = resolveEndpoint('agent-attach.grants.list', { agentId });
|
|
1525
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
1526
|
+
formatDetail(data, { format: program.opts().output, title: `Attach grants: ${agentId}` });
|
|
1527
|
+
});
|
|
1528
|
+
}
|