quoxcli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,1440 @@
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
+ * P10: keep the bound slot (or this session's membership of it) alive with the
704
+ * ATTACH token. Failures are logged, never fatal to the lease heartbeat.
705
+ */
706
+ async function heartbeatBoundSlot(client, agentId, getOrgId) {
707
+ const slotState = readSlotState(agentId);
708
+ if (!slotState?.name) return;
709
+ try {
710
+ const attachToken = readFileSync(tokenPath(agentId), 'utf-8').trim();
711
+ const orgId = getOrgId ? getOrgId() : undefined;
712
+ const ep = resolveEndpoint('v1.slots.heartbeat', { name: slotState.name });
713
+ await client.post(ep.path, {
714
+ service: ep.service,
715
+ noAuth: true,
716
+ headers: { Authorization: `Bearer ${attachToken}` },
717
+ body: {
718
+ holder_session: slotState.session_label,
719
+ ...(slotState.member_key ? { member_key: slotState.member_key } : {}),
720
+ ...(orgId ? { org_id: orgId } : {}),
721
+ },
722
+ });
723
+ } catch (err) {
724
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
725
+ printEvent(events.warned('quox', `slot ${slotState.name}`, `heartbeat failed (${status})`));
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Detach WITHOUT a handoff: release the bound slot (warn-only), post the
731
+ * witnessed detach notice (warn-only), release the lease, remove local state.
732
+ * Shared by `agent detach` and the keeper's session-gone release.
733
+ */
734
+ export async function releaseAttachLease({ client, auth, program, agentId, leaseId, reason = 'detach' }) {
735
+ const slotState = readSlotState(agentId);
736
+ if (slotState?.name) {
737
+ const slotEp = resolveEndpoint('v1.slots.release', { name: slotState.name });
738
+ try {
739
+ // P10: attach token so a member release removes only its own membership.
740
+ const slotToken = readFileSync(tokenPath(agentId), 'utf-8').trim();
741
+ await client.post(slotEp.path, {
742
+ service: slotEp.service,
743
+ noAuth: true,
744
+ headers: { Authorization: `Bearer ${slotToken}` },
745
+ body: { holder_session: slotState.session_label, force: false, org_id: resolveOrgId(auth, program) },
746
+ });
747
+ forgetHeldSlot(slotState.name);
748
+ printEvent(events.released('quox', `slot ${slotState.name}`, slotState.session_label));
749
+ } catch (err) {
750
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
751
+ printEvent(events.warned('quox', `slot ${slotState.name}`, `release failed (${status}), continuing detach`));
752
+ }
753
+ }
754
+
755
+ // P4 (docs/QUOXIDENTITY_P4_CONTRACT.md section 5): detach witnessing
756
+ // symmetry. Warn-only: a missed witness must never block the detach.
757
+ try {
758
+ const attachToken = readFileSync(tokenPath(agentId), 'utf-8').trim();
759
+ const detachEp = resolveEndpoint('agent-attach.detach');
760
+ await client.post(detachEp.path, {
761
+ service: detachEp.service,
762
+ noAuth: true,
763
+ headers: { Authorization: `Bearer ${attachToken}` },
764
+ body: { lease_id: leaseId, reason },
765
+ });
766
+ } catch (err) {
767
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
768
+ printEvent(events.warned('quox', `detach notice ${agentId}`, `failed (${status}), continuing detach`));
769
+ }
770
+
771
+ const ep = resolveEndpoint('agent-attach.lease.release', { leaseId });
772
+ await client.post(ep.path, { service: ep.service, body: {} });
773
+ removeAttachDir(agentId);
774
+ printEvent(events.released('quox', `attach ${agentId}`, `lease ${String(leaseId).slice(0, 8)}`));
775
+ }
776
+
777
+ // ---- attach-lease auto-heartbeat keeper (reuses core/slotKeeper.runKeeper) ----
778
+
779
+ const keeperName = (agentId) => `attach-${agentId}`;
780
+
781
+ /** Is `cmd` genuinely `agent keep <agentId>` of the quox CLI? */
782
+ export function isAttachKeeperCmd(cmd, agentId) {
783
+ const c = String(cmd || '').split('\0').join(' ');
784
+ if (!/quox/.test(c)) return false;
785
+ return c.includes(`agent keep ${agentId} `) || c.endsWith(`agent keep ${agentId}`);
786
+ }
787
+
788
+ /**
789
+ * Stop this agent's keeper (detach). Only signals a pid whose cmdline is
790
+ * verifiably the keeper, so a recycled lock pid is never killed.
791
+ */
792
+ export function stopAttachKeeper(agentId, { dir = keeperDir(), readCmd, kill = process.kill.bind(process) } = {}) {
793
+ try {
794
+ const pid = parseInt(readFileSync(join(dir, `${keeperName(agentId)}.pid`), 'utf-8'), 10);
795
+ if (!Number.isFinite(pid) || pid === process.pid) return;
796
+ const cmd = (readCmd || ((p) => readFileSync(`/proc/${p}/cmdline`, 'utf-8')))(pid);
797
+ if (!isAttachKeeperCmd(cmd, agentId)) return;
798
+ kill(pid, 'SIGTERM');
799
+ } catch { /* no keeper, or already gone */ }
800
+ }
801
+
802
+ /**
803
+ * Spawn the detached keeper. Same guard as slot.js defaultSpawnKeeper: only
804
+ * ever from the real quox binary. QUOX_ATTACH_SESSION pins the keeper (which is
805
+ * re-parented away from claude) to the attach state of the session it serves.
806
+ */
807
+ export function spawnAttachKeeper(agentId, profile, claude, { argv1 = process.argv[1], spawnFn = spawn, dir = keeperDir() } = {}) {
808
+ if (!isQuoxEntry(argv1)) throw new Error('not running as the quox CLI');
809
+ mkdirSync(dir, { recursive: true });
810
+ const fd = openSync(join(dir, `${keeperName(agentId)}.log`), 'a');
811
+ try {
812
+ const args = [argv1, ...(profile ? ['--profile', profile] : []), 'agent', 'keep', agentId,
813
+ '--claude-pid', String(claude.pid), '--claude-start', String(claude.starttime ?? '')];
814
+ const env = { ...process.env, ...(claude.sessionKey ? { QUOX_ATTACH_SESSION: claude.sessionKey } : {}) };
815
+ const child = spawnFn(process.execPath, args, { detached: true, stdio: ['ignore', fd, fd], env });
816
+ child.unref();
817
+ } finally {
818
+ closeSync(fd);
819
+ }
820
+ }
821
+
822
+ export function registerAgentAttachCommands(agent, ctx, program) {
823
+ const { client, auth } = ctx;
824
+
825
+ agent
826
+ .command('attach')
827
+ .description('Attach this session as an agent: lease it, fetch its attach kit, and install it locally')
828
+ .argument('<agentId>', 'registry agent id, e.g. seer')
829
+ .option('--ttl <sec>', 'lease TTL in seconds', (v) => parseInt(v, 10), 900)
830
+ .option('--force', 'take over an active lease held elsewhere')
831
+ .option('--label <session_label>', 'session label recorded on the lease')
832
+ .option('--no-slot', "skip checking out the agent's bound slot even if the kit carries one")
833
+ .option('--no-keep', 'do not spawn the background lease keeper (heartbeats the lease every 5m until this Claude session exits)')
834
+ .option('--no-prime', 'do not append the Brain primer (relevant brain passages) to ATTACHED.md')
835
+ .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)')
836
+ .action(async (agentId, options) => {
837
+ auth.requireAuth();
838
+ // quox#567 (CLI half): resolve a display name (e.g. VULCAN) to its
839
+ // registry id (forge) before any lease/kit call. Never send the
840
+ // display name itself to the API.
841
+ agentId = await resolveAgentId(client, agentId);
842
+ const leaseEp = resolveEndpoint('agent-attach.lease.create', { agentId });
843
+ const attachLabel = resolveSessionLabel({ label: options.label, lookup: ctx.sessionNameLookup });
844
+
845
+ let leaseData;
846
+ try {
847
+ leaseData = await client.post(leaseEp.path, {
848
+ service: leaseEp.service,
849
+ body: {
850
+ session_label: attachLabel,
851
+ ttl_seconds: options.ttl,
852
+ takeover: options.force === true,
853
+ },
854
+ });
855
+ } catch (err) {
856
+ if (err instanceof QuoxApiError && err.status === 409
857
+ && (err.body?.error === 'splinter_cap' || err.code === 'splinter_cap')) {
858
+ const max = err.body?.max ?? err.detail?.max ?? '?';
859
+ const active = err.body?.active ?? err.detail?.active ?? '?';
860
+ throw new QuoxError(
861
+ `agent ${agentId} is at its splinter cap (max ${max}, active ${active}). `
862
+ + 'The cap is the platform setting QUOX_MAX_SPLINTERS (Settings > Platform capabilities, org or instance); raise it, or detach another session first.',
863
+ 3,
864
+ );
865
+ }
866
+ if (err instanceof QuoxApiError && err.status === 409) {
867
+ const holder = err.body?.holder ?? err.detail?.holder ?? err.detail ?? 'another session';
868
+ const holderText = typeof holder === 'string' ? holder : JSON.stringify(holder);
869
+ // Contract: "print current holder and exits code 3 with a --force
870
+ // hint". QuoxApiError's own status->exitCode map sends 409 to 6;
871
+ // this is a deliberate override to the contract's exit code.
872
+ throw new QuoxError(
873
+ `agent ${agentId} is already attached (held by ${holderText}). `
874
+ + `Pass --force to take over: quox agent attach ${agentId} --force`,
875
+ 3,
876
+ );
877
+ }
878
+ throw err;
879
+ }
880
+
881
+ const lease = leaseData?.lease || {};
882
+ const token = leaseData?.token;
883
+ if (!token) {
884
+ throw new Error(`POST ${leaseEp.path} did not return a token; cannot fetch the attach kit.`);
885
+ }
886
+
887
+ const kitEp = resolveEndpoint('agent-attach.kit');
888
+ let kit;
889
+ try {
890
+ kit = await client.get(kitEp.path, {
891
+ service: kitEp.service,
892
+ // The kit is fetched with the just-minted ATTACH token, never the
893
+ // caller's own session token: noAuth suppresses client.js's normal
894
+ // Bearer injection so this explicit header is the only credential.
895
+ noAuth: true,
896
+ headers: { Authorization: `Bearer ${token}` },
897
+ });
898
+ } catch (err) {
899
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
900
+ throw new Error(
901
+ `GET ${kitEp.path} failed (${status}) after the lease was granted (lease ${lease.id || 'unknown'}). `
902
+ + `The lease is still active: retry the kit fetch, or run \`quox agent detach ${agentId}\` to release it. `
903
+ + `Original error: ${err.message}`
904
+ );
905
+ }
906
+
907
+ const splinter = splinterInfoFrom(leaseData?.splinter);
908
+ const paths = installAttachKit(agentId, { kit, token, splinter });
909
+ // ONE-BRAIN CX4: agents whose resolved tool list carries brain_context get
910
+ // a primer from the brain. Best effort, bounded, never fails the attach.
911
+ let primer = '';
912
+ if (options.prime !== false && kitHasBrainContext(kit)) {
913
+ primer = await buildBrainPrimer(client);
914
+ try {
915
+ writeFile0600(instructionsPath(agentId), `${readFileSync(instructionsPath(agentId), 'utf-8')}\n${primer}`);
916
+ } catch { /* primer is a bonus; the attach stands */ }
917
+ }
918
+ writeSplinterFile(agentId, splinter.id, splinter.count);
919
+ const leaseId = lease.id || kit?.lease?.id || '';
920
+ const expires = lease.expires_at || kit?.lease?.expires_at || leaseData?.expires_at || 'unknown';
921
+
922
+ // P3 (docs/QUOXIDENTITY_P3_CONTRACT.md, territory G): the kit carries
923
+ // the agent's bound slot (a durable ADDRESS, Territory F hydration).
924
+ // Check it out as a separate "voice hold" — Lease = identity hold,
925
+ // slot hold = voice, by design. A slot already held elsewhere (e.g. a
926
+ // warden/supervisor) must never fail an otherwise-successful attach:
927
+ // warn, name the holder, hint the takeover, and continue (exit 0).
928
+ if (kit?.slot?.name && options.slot !== false) {
929
+ const slotName = kit.slot.name;
930
+ // DEFECT 3 FIX (QuoxIdentity): a resident session's voice identity
931
+ // (checkSlotSayAuthor, collector server.js) resolves to
932
+ // buildSelfIdentity(hostId) = `resident:<host>`, exactly the way
933
+ // devSupervisor.js checks its own slot out (see checkoutSlot there).
934
+ // Checking out with a synthetic `attach-<agentId>-<lease8>` label
935
+ // instead means a resident's later `slot say` 403s: holder_session
936
+ // never matches its resolved identity. Same resident-detection
937
+ // signal auth.js's own refresh() uses (`auth?.kind === 'resident'`),
938
+ // not a new one. A non-resident (ordinary human JWT) keeps the
939
+ // existing synthetic-label/interactive behaviour unchanged.
940
+ const tokens = typeof auth.getTokens === 'function' ? auth.getTokens() : null;
941
+ const isResident = tokens?.kind === 'resident' && !!tokens?.host_id;
942
+ const sessionLabel = isResident
943
+ // Per-splinter label: two same-host residents must not share a
944
+ // holder_session, or the second is mistaken for the holder
945
+ // re-checking-out (no member join) and its detach frees the hold.
946
+ // "@" not "#": the collector rejects "#" in say/from labels.
947
+ ? `${buildSelfIdentity(tokens.host_id)}@${String(leaseId).slice(0, 8)}`
948
+ : (attachLabel || `attach-${agentId}-${String(leaseId).slice(0, 8)}`);
949
+ const slotEp = resolveEndpoint('v1.slots.checkout', { name: slotName });
950
+ try {
951
+ // P10: present the ATTACH token (not the profile token) so the
952
+ // collector resolves the caller as the agent; a held slot of the
953
+ // same agent then JOINS as a member instead of 409.
954
+ const slotRes = await client.post(slotEp.path, {
955
+ service: slotEp.service,
956
+ noAuth: true,
957
+ headers: { Authorization: `Bearer ${token}` },
958
+ body: {
959
+ holder_session: sessionLabel,
960
+ holder_kind: isResident ? 'resident' : 'interactive',
961
+ ...(isResident ? { host_id: tokens.host_id } : {}),
962
+ org_id: resolveOrgId(auth, program),
963
+ },
964
+ });
965
+ if (slotRes?.joined === true) {
966
+ const member = {
967
+ splinter: slotRes.member?.splinter ?? null,
968
+ member_key: slotRes.member?.member_key ?? null,
969
+ };
970
+ recordHeldSlot(slotName, sessionLabel, undefined, (ctx.claudeSessionInfo || claudeSessionInfo)(), member);
971
+ writeSlotState(agentId, { name: slotName, session_label: sessionLabel, member: true, ...member });
972
+ printInfo(`Joined slot ${slotName} as member ${member.splinter ?? member.member_key ?? 'unknown'}`);
973
+ } else {
974
+ recordHeldSlot(slotName, sessionLabel, undefined, (ctx.claudeSessionInfo || claudeSessionInfo)());
975
+ writeSlotState(agentId, { name: slotName, session_label: sessionLabel });
976
+ printEvent(events.assigned('quox', `slot ${slotName}`, sessionLabel));
977
+ }
978
+ } catch (err) {
979
+ const is409 = err instanceof QuoxApiError && err.status === 409;
980
+ const holder = (is409 && (err.detail?.holder_session || err.body?.holder_session)) || 'another session';
981
+ printEvent(events.warned(
982
+ 'quox',
983
+ `slot ${slotName}`,
984
+ is409 ? `held by ${holder}` : `checkout failed (${err instanceof QuoxApiError ? err.status : 'network error'})`
985
+ ));
986
+ if (is409) {
987
+ printInfo(` takeover: quox slot checkout ${slotName} --force`);
988
+ }
989
+ }
990
+ }
991
+
992
+ // F6 (QuoxIdentity CLI finding-fix): a lease granted with little TTL
993
+ // left will expire mid-work if the session never heartbeats. Warn at
994
+ // grant time rather than only on a later attach-status check.
995
+ const msLeft = expires !== 'unknown' ? new Date(expires).getTime() - Date.now() : NaN;
996
+ if (Number.isFinite(msLeft) && msLeft > 0 && msLeft < 15 * 60 * 1000) {
997
+ printEvent(events.warned(
998
+ 'quox',
999
+ `attach ${agentId}`,
1000
+ `expires in ${Math.max(1, Math.round(msLeft / 60000))}m, run attach-heartbeat`
1001
+ ));
1002
+ }
1003
+
1004
+ const splinterTag = splinter.present && splinter.ordinal === 1 && splinter.siblings.length ? ' (S1)' : '';
1005
+ printEvent(line('quox', `attach ${agentId}${splinterTag}`, { result: `lease ${String(leaseId).slice(0, 8)} · expires ${expires}` }));
1006
+ if (splinter.present && splinter.ordinal > 1) {
1007
+ printInfo(`Joined ${kit?.display_name || agentId} as SPLINTER ${splinter.id} (${splinter.count} live session${splinter.count === 1 ? '' : 's'})`);
1008
+ }
1009
+ if (splinter.present && (splinter.ordinal > 1 || splinter.siblings.length)) {
1010
+ for (const sib of splinter.siblings) {
1011
+ printInfo(` ${sib.id} ${sib.session_label || '(no label)'} · heartbeat ${fmtAge(sib.last_heartbeat_at)}`);
1012
+ }
1013
+ }
1014
+ printInfo(` kit: ${paths.kitPath}`);
1015
+ printInfo(` token: ${paths.tokenPath}`);
1016
+ printInfo(` instructions: ${paths.instructionsPath}`);
1017
+ // Auto-heartbeat: the lease TTL is 15m and nothing renewed it unless the
1018
+ // model remembered `attach-heartbeat`. A detached keeper (bound to this
1019
+ // claude session) renews every 5m and releases the lease when the
1020
+ // session exits. Only when a claude session is detected.
1021
+ if (options.keep !== false) {
1022
+ const me = claudeSessionInfo();
1023
+ if (me) {
1024
+ try {
1025
+ stopAttachKeeper(agentId, { dir: ctx.keeperDir });
1026
+ (ctx.spawnKeeper || spawnAttachKeeper)(agentId, program.opts().profile, me);
1027
+ printInfo(` keeper: heartbeating this lease every 5m until the claude session exits (--no-keep to opt out)`);
1028
+ } catch (err) {
1029
+ printEvent(events.warned('quox', `keeper ${agentId}`, `not started (${err.message}); run attach-heartbeat yourself`));
1030
+ }
1031
+ }
1032
+ }
1033
+ printInfo(` detach with: quox agent detach ${agentId}`);
1034
+ // P9: surface the (automatic) session binding so multi-session use is
1035
+ // legible. The key is derived from the process tree — nothing to set.
1036
+ {
1037
+ const key = sessionKey();
1038
+ if (key) {
1039
+ printInfo(` session: ${key}, this attach is scoped to THIS session; other sessions on this box stay independent`);
1040
+ }
1041
+ }
1042
+ // P2 (docs/QUOXIDENTITY_P2_CONTRACT.md, territory E): kit.memory.handoff
1043
+ // is the most recent handoff note left by a prior attached session.
1044
+ // Note it so the session knows continuity was restored, not starting cold.
1045
+ const handoff = kit?.memory?.handoff;
1046
+ if (handoff) {
1047
+ const createdAt = handoff.created_at || handoff.createdAt || 'unknown';
1048
+ printEvent(line('quox', 'continuity restored', { result: `handoff ${createdAt}` }));
1049
+ }
1050
+ if (program.opts().output === 'json') formatDetail({ lease, kit }, { format: 'json' });
1051
+
1052
+ // Print the adoption block to stdout by default. The point: a human runs
1053
+ // `! quox agent attach <agent>` inside a live Claude/Codex session, and
1054
+ // the identity SEER should adopt lands directly in that session's context
1055
+ // instead of relying on a separate "now go read ATTACHED.md" step that is
1056
+ // easy to skip. --no-adopt (or -o json) suppresses it for scripts.
1057
+ if (options.adopt !== false && program.opts().output !== 'json') {
1058
+ const bar = '─'.repeat(60);
1059
+ printInfo('');
1060
+ printInfo(bar);
1061
+ printInfo(`ADOPT NOW: you are attached as ${agentId}. From this point on in this`);
1062
+ printInfo(`session, operate AS ${agentId} under the identity and tool scope below.`);
1063
+ printInfo(bar);
1064
+ printInfo(renderAttachedInstructions({ agentId, kit, splinter }));
1065
+ if (primer) printInfo(`\n${primer}`);
1066
+ printInfo(bar);
1067
+ }
1068
+ });
1069
+
1070
+ agent
1071
+ .command('detach')
1072
+ .description('Detach this session from an agent: release its lease and remove the local attach kit')
1073
+ .argument('<agentId>', 'registry agent id')
1074
+ .option('--handoff <file>', 'path to a file with a handoff note (state, open threads, next moves) to store before detaching')
1075
+ .option('--handoff-text <text>', 'handoff note text, provided inline instead of a file')
1076
+ .option('--force-detach', 'release the lease even if storing the handoff note fails')
1077
+ .option('--no-brain-note', 'do not also save the handoff to the knowledge brain as a private note')
1078
+ .action(async (agentId, options) => {
1079
+ auth.requireAuth();
1080
+ // quox#567 (CLI half): same display-name -> registry-id resolution as attach.
1081
+ agentId = await resolveAgentId(client, agentId);
1082
+ const stored = readStoredKit(agentId);
1083
+ const leaseId = stored?.lease?.id;
1084
+ if (!leaseId) throw noLocalLeaseError(agentId, 'attach-status');
1085
+
1086
+ if (options.handoff && options.handoffText) {
1087
+ throw new QuoxError('Pass only one of --handoff or --handoff-text.', 2);
1088
+ }
1089
+
1090
+ // P2 (docs/QUOXIDENTITY_P2_CONTRACT.md, territory E): store the handoff
1091
+ // BEFORE releasing the lease, and abort the detach (naming the endpoint
1092
+ // + status) rather than silently losing the handoff on store failure.
1093
+ // --force-detach is the explicit override for that abort.
1094
+ let handoffContent = null;
1095
+ if (options.handoff) {
1096
+ try {
1097
+ handoffContent = readFileSync(options.handoff, 'utf-8');
1098
+ } catch (err) {
1099
+ throw new Error(`Cannot read handoff file ${options.handoff}: ${err.message}`);
1100
+ }
1101
+ } else if (options.handoffText) {
1102
+ handoffContent = options.handoffText;
1103
+ }
1104
+
1105
+ if (handoffContent !== null) {
1106
+ // finding-10: the handoff runs at the very end of a session, often long
1107
+ // after the last heartbeat, so the short-lived attach token has usually
1108
+ // expired by now — which 401'd the handoff and lost continuity. Refresh
1109
+ // it first (heartbeat uses the resident session, so it works even with
1110
+ // an expired attach token, as long as the lease still lives).
1111
+ const token = await ensureFreshAttachToken(client, agentId);
1112
+
1113
+ const handoffEp = resolveEndpoint('agent-attach.handoff');
1114
+ try {
1115
+ await client.post(handoffEp.path, {
1116
+ service: handoffEp.service,
1117
+ // Same posture as the kit fetch: authenticate with the attach
1118
+ // JWT explicitly, not the caller's own session token.
1119
+ noAuth: true,
1120
+ headers: { Authorization: `Bearer ${token}` },
1121
+ body: { content: handoffContent },
1122
+ });
1123
+ } catch (err) {
1124
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1125
+ const message =
1126
+ `POST ${handoffEp.path} failed (${status}); the lease was NOT released so the handoff `
1127
+ + `is not lost (lease ${String(leaseId).slice(0, 8)}). Retry the detach, or pass `
1128
+ + `--force-detach to release the lease anyway. Original error: ${err.message}`;
1129
+ if (!options.forceDetach) throw new Error(message);
1130
+ printEvent(events.warned('quox', `handoff ${agentId}`, `store failed (${status}), forcing detach`));
1131
+ }
1132
+ // ONE-BRAIN CX3: also keep the handoff in the knowledge brain. Best
1133
+ // effort: a capture failure warns and never blocks the detach.
1134
+ if (options.brainNote !== false) {
1135
+ const res = await saveHandoffNote(client, {
1136
+ agentId,
1137
+ content: handoffContent,
1138
+ prov: decisionProvenance(),
1139
+ sessionName: resolveSessionLabel({ lookup: ctx.sessionNameLookup }),
1140
+ });
1141
+ if (res.ok) printEvent(line('quox', `handoff note ${agentId}`, { result: 'saved to brain' }));
1142
+ else printEvent(events.warned('quox', `handoff note ${agentId}`, `brain save failed (${res.error}), continuing detach`));
1143
+ }
1144
+ } else {
1145
+ printInfo(' Tip: a handoff preserves continuity for the next session. Pass --handoff <file> or --handoff-text "...".');
1146
+ }
1147
+
1148
+ // P3/P4: slot release, witnessed detach notice, lease release (shared
1149
+ // with the keeper's session-gone release).
1150
+ try { stopAttachKeeper(agentId, { dir: ctx.keeperDir }); } catch { /* best effort */ }
1151
+ await releaseAttachLease({
1152
+ client, auth, program, agentId, leaseId,
1153
+ reason: handoffContent !== null ? 'handoff_and_detach' : 'detach',
1154
+ });
1155
+ });
1156
+
1157
+ agent
1158
+ .command('keep', { hidden: true })
1159
+ .description('Background attach-lease keeper: heartbeat the lease every 5m until the owning Claude session exits (spawned by attach)')
1160
+ .argument('<agentId>', 'canonical registry agent id (passed by attach)')
1161
+ .option('--claude-pid <pid>', 'pid of the claude session this keeper serves')
1162
+ .option('--claude-start <ticks>', 'start ticks of that claude process (guards against pid reuse)')
1163
+ .action(async (agentId, options) => {
1164
+ auth.requireAuth();
1165
+ const me = options.claudePid
1166
+ ? { pid: Number(options.claudePid), starttime: options.claudeStart || null }
1167
+ : claudeSessionInfo();
1168
+ const res = await runKeeper({
1169
+ name: keeperName(agentId),
1170
+ session: agentId,
1171
+ claude: me,
1172
+ probe: ctx.probe || makeProcessProbe(procStartTime),
1173
+ sleep: ctx.sleep,
1174
+ dir: ctx.keeperDir,
1175
+ heartbeat: () => heartbeatAttachLease(client, agentId, { getOrgId: () => resolveOrgId(auth, program) }),
1176
+ release: async () => {
1177
+ const leaseId = readStoredKit(agentId)?.lease?.id;
1178
+ if (!leaseId) return;
1179
+ await releaseAttachLease({ client, auth, program, agentId, leaseId });
1180
+ },
1181
+ forget: () => {},
1182
+ });
1183
+ process.exitCode = res.code;
1184
+ });
1185
+
1186
+ agent
1187
+ .command('attach-status')
1188
+ .description('Show the current attach lease status for an agent')
1189
+ .argument('<agentId>', 'registry agent id')
1190
+ .action(async (agentId) => {
1191
+ auth.requireAuth();
1192
+ // quox#567 (CLI half): same display-name -> registry-id resolution as attach.
1193
+ agentId = await resolveAgentId(client, agentId);
1194
+ const ep = resolveEndpoint('agent-attach.status', { agentId });
1195
+ const data = await client.get(ep.path, { service: ep.service });
1196
+ const active = data?.active_lease || null;
1197
+
1198
+ if (!active) {
1199
+ printInfo(`No active attach lease for ${agentId}. my_grant: ${!!data?.my_grant}`);
1200
+ } else {
1201
+ formatDetail(active, { format: program.opts().output, title: `Attach lease: ${agentId}` });
1202
+ printInfo(` my_grant: ${!!data?.my_grant}`);
1203
+ }
1204
+ if (Array.isArray(data?.splinters) && data.splinters.length) {
1205
+ printInfo(' splinters:');
1206
+ for (const sp of data.splinters) {
1207
+ printInfo(` ${sp.id}${sp.is_root ? ' (root)' : ''} ${sp.session_label || '(no label)'} · heartbeat ${fmtAge(sp.last_heartbeat_at)}`);
1208
+ }
1209
+ }
1210
+
1211
+ // P3: best-effort bound slot + current holder. Never fails
1212
+ // attach-status: an org with no binding wired yet (or a caller without
1213
+ // the route deployed) degrades to silence, not an error.
1214
+ try {
1215
+ const bindingEp = resolveEndpoint('agent-attach.slot-binding.get', { agentId });
1216
+ const binding = await client.get(bindingEp.path, { service: bindingEp.service });
1217
+ if (binding?.name) {
1218
+ printInfo(` slot: ${binding.name}${binding.holder_session ? ` (held by ${binding.holder_session})` : ' (free)'}`);
1219
+ }
1220
+ } catch {
1221
+ // No binding, or the route isn't deployed yet: not a status-command failure.
1222
+ }
1223
+
1224
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1225
+ });
1226
+
1227
+ agent
1228
+ .command('attach-heartbeat')
1229
+ .description("Heartbeat this session's attach lease, rotating the stored token if one is issued")
1230
+ .argument('<agentId>', 'registry agent id')
1231
+ .action(async (agentId) => {
1232
+ auth.requireAuth();
1233
+ // quox#567 (CLI half): same display-name -> registry-id resolution as attach.
1234
+ agentId = await resolveAgentId(client, agentId);
1235
+ const leaseId = readStoredKit(agentId)?.lease?.id;
1236
+ if (!leaseId) throw noLocalLeaseError(agentId, 'attach-heartbeat');
1237
+
1238
+ const { expires } = await heartbeatAttachLease(client, agentId, { getOrgId: () => resolveOrgId(auth, program) });
1239
+ printEvent(line('quox', `heartbeat ${agentId}`, { result: `lease ${String(leaseId).slice(0, 8)} · expires ${expires}`, status: 'ok' }));
1240
+ });
1241
+
1242
+ // F3 (QuoxIdentity CLI finding-fix, SEER-TERMINAL): the first-class way to
1243
+ // do WITNESSED work AS the attached agent. Reads the token this session
1244
+ // stored on `attach` and calls the collector's governed
1245
+ // /api/v1/tools/execute with it, authenticated the same way the kit fetch
1246
+ // is: noAuth + an explicit Authorization header, never the caller's own
1247
+ // session token. Before this verb, witnessed work as the attached identity
1248
+ // meant hand-curling the endpoint.
1249
+ agent
1250
+ .command('exec')
1251
+ .description('Execute a tool as the attached agent (witnessed, uses the stored attach token)')
1252
+ .argument('<agentId>', 'registry agent id')
1253
+ .requiredOption('--tool <name>', 'tool name to execute, e.g. memory_search')
1254
+ .requiredOption('--input <json>', 'tool input, as a JSON string, e.g. \'{"query":"..."}\'')
1255
+ .action(async (agentId, options) => {
1256
+ // quox#567 (CLI half): same display-name -> registry-id resolution as
1257
+ // attach. Must run BEFORE ensureFreshAttachToken, since the stored
1258
+ // attach kit lives under the canonical id (~/.quox/attach/<id>/).
1259
+ agentId = await resolveAgentId(client, agentId);
1260
+
1261
+ // finding-10: refresh a near-expired token before use (heartbeats the
1262
+ // lease, which authenticates with the resident session, so it works even
1263
+ // after the short-lived attach token has lapsed).
1264
+ const token = await ensureFreshAttachToken(client, agentId);
1265
+
1266
+ const input = parseJSON(options.input, '--input');
1267
+ const orgId = resolveOrgId(auth, program);
1268
+
1269
+ const ep = resolveEndpoint('v1.tools.execute');
1270
+ let data;
1271
+ try {
1272
+ data = await client.post(ep.path, {
1273
+ service: ep.service,
1274
+ noAuth: true,
1275
+ headers: { Authorization: `Bearer ${token}` },
1276
+ body: { tool_name: options.tool, input, org_id: orgId },
1277
+ });
1278
+ } catch (err) {
1279
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1280
+ if (err instanceof QuoxApiError && (err.status === 401 || err.status === 403)) {
1281
+ throw new Error(
1282
+ `POST ${ep.path} failed (${status}): the attach token for "${agentId}" is no longer valid `
1283
+ + `(expired or revoked lease). Re-attach with \`quox agent attach ${agentId}\`.`
1284
+ );
1285
+ }
1286
+ throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
1287
+ }
1288
+
1289
+ // finding (autonomous loop): the glyph must reflect the TOOL result, not
1290
+ // just that the HTTP transport returned 200. A tool that reports failure
1291
+ // (success:false OR ok:false) is a FAILURE; printing ✔ made a scanning
1292
+ // agent read failures as passes ([error-body-is-not-data] at the CLI
1293
+ // surface). Set a non-zero exit code too so a scripted / feedback-loop
1294
+ // caller can't mistake it for a pass.
1295
+ const toolOk = !execResultFailed(data);
1296
+ // Skip the announce line under --json/-o json: a scripted caller parses
1297
+ // stdout as a single JSON document, and the glyph line would break that.
1298
+ if (program.opts().output !== 'json') {
1299
+ printEvent(line('quox', `exec ${options.tool}`, {
1300
+ result: toolOk ? agentId : `${agentId} · ${execResultError(data)}`,
1301
+ status: toolOk ? 'ok' : 'fail',
1302
+ }));
1303
+ }
1304
+ formatDetail(data, { format: program.opts().output, title: `Tool result: ${options.tool}` });
1305
+ if (!toolOk) process.exitCode = 1;
1306
+ });
1307
+
1308
+ // P3 (docs/QUOXIDENTITY_P3_CONTRACT.md, territory F/G): admin verbs mapping
1309
+ // to the PUT/DELETE slot-binding routes. Binding an address is an admin
1310
+ // act (org owner/admin only, not attach tokens) — org-admin errors surface
1311
+ // the endpoint + status rather than a bare "forbidden".
1312
+ agent
1313
+ .command('bind-slot')
1314
+ .description("Bind an agent identity to a durable slot address (org admin only)")
1315
+ .argument('<agentId>', 'registry agent id')
1316
+ .argument('<slotName>', 'slot name to bind, e.g. seer-address')
1317
+ .action(async (agentId, slotName) => {
1318
+ auth.requireAuth();
1319
+ const ep = resolveEndpoint('agent-attach.slot-binding.set', { agentId });
1320
+ let data;
1321
+ try {
1322
+ data = await client.put(ep.path, { service: ep.service, body: { slot_name: slotName } });
1323
+ } catch (err) {
1324
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1325
+ throw new Error(`PUT ${ep.path} failed (${status}): ${err.message}`);
1326
+ }
1327
+ printEvent(events.created('quox', `bind-slot ${agentId}`, slotName));
1328
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1329
+ });
1330
+
1331
+ agent
1332
+ .command('unbind-slot')
1333
+ .description("Remove an agent identity's slot binding (org admin only)")
1334
+ .argument('<agentId>', 'registry agent id')
1335
+ .action(async (agentId) => {
1336
+ auth.requireAuth();
1337
+ const ep = resolveEndpoint('agent-attach.slot-binding.delete', { agentId });
1338
+ let data;
1339
+ try {
1340
+ data = await client.delete(ep.path, { service: ep.service });
1341
+ } catch (err) {
1342
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1343
+ throw new Error(`DELETE ${ep.path} failed (${status}): ${err.message}`);
1344
+ }
1345
+ printEvent(events.released('quox', `bind-slot ${agentId}`, agentId));
1346
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1347
+ });
1348
+
1349
+ // P1 grant model (owner decision: per-agent attach grant, default owner-only).
1350
+ // These verbs are the only client for the auth grant routes; without them the
1351
+ // grant model is unreachable except by raw HTTP. Org owner/admin only, so the
1352
+ // errors surface the endpoint + status rather than a bare "forbidden".
1353
+ agent
1354
+ .command('grant-attach')
1355
+ .description('Grant a user permission to attach as an agent (org admin only)')
1356
+ .argument('<agentId>', 'registry agent id, e.g. seer')
1357
+ .argument('<userId>', 'the user id to grant attach permission to')
1358
+ .action(async (agentId, userId) => {
1359
+ auth.requireAuth();
1360
+ // quox#567 (CLI half): same display-name -> registry-id resolution as attach.
1361
+ agentId = await resolveAgentId(client, agentId);
1362
+ const ep = resolveEndpoint('agent-attach.grants.add', { agentId });
1363
+ let data;
1364
+ try {
1365
+ data = await client.post(ep.path, { service: ep.service, body: { user_id: userId } });
1366
+ } catch (err) {
1367
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1368
+ throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
1369
+ }
1370
+ printEvent(events.created('quox', `grant-attach ${agentId}`, userId));
1371
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1372
+ });
1373
+
1374
+ agent
1375
+ .command('grant-attach-all')
1376
+ .description('Grant a user permission to attach as ANY agent in the org, present and future (org admin only)')
1377
+ .argument('<userId>', 'the user id to grant roster-wide attach permission to')
1378
+ .action(async (userId) => {
1379
+ auth.requireAuth();
1380
+ const ep = resolveEndpoint('agent-attach.grants.add-all');
1381
+ let data;
1382
+ try {
1383
+ data = await client.post(ep.path, { service: ep.service, body: { user_id: userId } });
1384
+ } catch (err) {
1385
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1386
+ throw new Error(`POST ${ep.path} failed (${status}): ${err.message}`);
1387
+ }
1388
+ printEvent(events.created('quox', 'grant-attach-all', userId));
1389
+ printInfo(` ${userId} can now attach ANY agent in the org (no per-agent grant needed). Revoke: quox agent revoke-attach-all ${userId}`);
1390
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1391
+ });
1392
+
1393
+ agent
1394
+ .command('revoke-attach-all')
1395
+ .description("Revoke a user's roster-wide (attach-any) grant (org admin only)")
1396
+ .argument('<userId>', 'the user id to revoke')
1397
+ .action(async (userId) => {
1398
+ auth.requireAuth();
1399
+ const ep = resolveEndpoint('agent-attach.grants.remove-all', { granteeUserId: userId });
1400
+ let data;
1401
+ try {
1402
+ data = await client.delete(ep.path, { service: ep.service });
1403
+ } catch (err) {
1404
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1405
+ throw new Error(`DELETE ${ep.path} failed (${status}): ${err.message}`);
1406
+ }
1407
+ printEvent(events.released('quox', 'grant-attach-all', userId));
1408
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1409
+ });
1410
+
1411
+ agent
1412
+ .command('revoke-attach')
1413
+ .description("Revoke a user's attach grant for an agent (org admin only)")
1414
+ .argument('<agentId>', 'registry agent id')
1415
+ .argument('<userId>', 'the user id to revoke')
1416
+ .action(async (agentId, userId) => {
1417
+ auth.requireAuth();
1418
+ const ep = resolveEndpoint('agent-attach.grants.remove', { agentId, granteeUserId: userId });
1419
+ let data;
1420
+ try {
1421
+ data = await client.delete(ep.path, { service: ep.service });
1422
+ } catch (err) {
1423
+ const status = err instanceof QuoxApiError ? err.status : 'network error';
1424
+ throw new Error(`DELETE ${ep.path} failed (${status}): ${err.message}`);
1425
+ }
1426
+ printEvent(events.released('quox', `grant-attach ${agentId}`, userId));
1427
+ if (program.opts().output === 'json') formatDetail(data, { format: 'json' });
1428
+ });
1429
+
1430
+ agent
1431
+ .command('list-grants')
1432
+ .description('List attach grants for an agent (org admin only)')
1433
+ .argument('<agentId>', 'registry agent id')
1434
+ .action(async (agentId) => {
1435
+ auth.requireAuth();
1436
+ const ep = resolveEndpoint('agent-attach.grants.list', { agentId });
1437
+ const data = await client.get(ep.path, { service: ep.service });
1438
+ formatDetail(data, { format: program.opts().output, title: `Attach grants: ${agentId}` });
1439
+ });
1440
+ }