quoxcli 0.2.0 → 0.4.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 (442) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/actions.js +15 -0
  5. package/lib/api/endpoints/admin.js +46 -0
  6. package/lib/api/endpoints/agent-schedules.js +26 -0
  7. package/lib/api/endpoints/agents.js +7 -0
  8. package/lib/api/endpoints/assistants.js +12 -0
  9. package/lib/api/endpoints/away.js +15 -0
  10. package/lib/api/endpoints/blog.js +16 -0
  11. package/lib/api/endpoints/chat.js +14 -0
  12. package/lib/api/endpoints/contactsq.js +6 -0
  13. package/lib/api/endpoints/conversations.js +14 -0
  14. package/lib/api/endpoints/devServerRegistry.js +25 -0
  15. package/lib/api/endpoints/engagement.js +26 -0
  16. package/lib/api/endpoints/evidence.js +20 -0
  17. package/lib/api/endpoints/executions.js +13 -0
  18. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  19. package/lib/api/endpoints/files.js +14 -0
  20. package/lib/api/endpoints/hat.js +44 -0
  21. package/lib/api/endpoints/inbox.js +20 -0
  22. package/lib/api/endpoints/index.js +26 -0
  23. package/lib/api/endpoints/loops.js +26 -0
  24. package/lib/api/endpoints/memory.js +19 -0
  25. package/lib/api/endpoints/mind.js +13 -0
  26. package/lib/api/endpoints/mon.js +179 -0
  27. package/lib/api/endpoints/objectives.js +12 -0
  28. package/lib/api/endpoints/plugins.js +18 -0
  29. package/lib/api/endpoints/presence.js +33 -0
  30. package/lib/api/endpoints/quoxplan.js +7 -0
  31. package/lib/api/endpoints/quoxseo.js +12 -0
  32. package/lib/api/endpoints/router.js +17 -0
  33. package/lib/api/endpoints/safety.js +7 -0
  34. package/lib/api/endpoints/server.js +21 -0
  35. package/lib/api/endpoints/setup.js +6 -0
  36. package/lib/api/endpoints/skills.js +20 -0
  37. package/lib/api/endpoints/stepup.js +20 -0
  38. package/lib/api/endpoints/teams.js +2 -2
  39. package/lib/api/endpoints/triggers.js +26 -0
  40. package/lib/api/endpoints/v1.js +120 -0
  41. package/lib/api/endpoints/ward.js +7 -0
  42. package/lib/api/endpoints/workflows.js +7 -0
  43. package/lib/api/endpoints/x.js +8 -0
  44. package/lib/commands/actions.js +131 -0
  45. package/lib/commands/admin.js +16 -0
  46. package/lib/commands/agent.js +170 -27
  47. package/lib/commands/agentAttach.js +140 -13
  48. package/lib/commands/approvals.js +78 -1
  49. package/lib/commands/aside.js +24 -0
  50. package/lib/commands/ask.js +146 -0
  51. package/lib/commands/assistant.js +59 -4
  52. package/lib/commands/auth.js +8 -2
  53. package/lib/commands/backup.js +8 -4
  54. package/lib/commands/blog.js +110 -0
  55. package/lib/commands/brain.js +105 -14
  56. package/lib/commands/build.js +19 -5
  57. package/lib/commands/chat.js +180 -26
  58. package/lib/commands/chatCompanion.js +27 -0
  59. package/lib/commands/chatState.js +45 -0
  60. package/lib/commands/claude.js +35 -38
  61. package/lib/commands/contacts.js +134 -4
  62. package/lib/commands/contactsSendAt.js +21 -0
  63. package/lib/commands/conversation.js +35 -4
  64. package/lib/commands/dashboard.js +43 -0
  65. package/lib/commands/department.js +28 -7
  66. package/lib/commands/devResident.js +15 -3
  67. package/lib/commands/devSupervisor.js +13 -1
  68. package/lib/commands/discord.js +75 -39
  69. package/lib/commands/done.js +98 -28
  70. package/lib/commands/engagement.js +107 -6
  71. package/lib/commands/evidence.js +154 -0
  72. package/lib/commands/ext.js +23 -0
  73. package/lib/commands/fetchAllowlist.js +71 -0
  74. package/lib/commands/file.js +38 -1
  75. package/lib/commands/fleet.js +336 -4
  76. package/lib/commands/governance.js +12 -3
  77. package/lib/commands/gripe.js +44 -13
  78. package/lib/commands/hat.js +254 -9
  79. package/lib/commands/helpers.js +18 -1
  80. package/lib/commands/hyperion.js +58 -0
  81. package/lib/commands/image.js +68 -0
  82. package/lib/commands/inbox.js +74 -23
  83. package/lib/commands/kuma.js +1050 -0
  84. package/lib/commands/kumaSla.js +174 -0
  85. package/lib/commands/licence.js +52 -155
  86. package/lib/commands/loop.js +291 -0
  87. package/lib/commands/lucid.js +10 -4
  88. package/lib/commands/memory.js +85 -8
  89. package/lib/commands/mind.js +71 -0
  90. package/lib/commands/mon.js +69 -2
  91. package/lib/commands/monitoring.js +1 -1
  92. package/lib/commands/motion.js +2 -2
  93. package/lib/commands/nucleus.js +48 -17
  94. package/lib/commands/objective.js +59 -1
  95. package/lib/commands/offload.js +89 -0
  96. package/lib/commands/onboard.js +2 -1
  97. package/lib/commands/org.js +30 -3
  98. package/lib/commands/peek.js +28 -0
  99. package/lib/commands/plan.js +38 -0
  100. package/lib/commands/plugin.js +110 -0
  101. package/lib/commands/presence.js +89 -0
  102. package/lib/commands/provider.js +217 -0
  103. package/lib/commands/qlarity.js +19 -3
  104. package/lib/commands/quoxchat.js +13 -4
  105. package/lib/commands/quoxseo.js +69 -0
  106. package/lib/commands/reach.js +2 -1
  107. package/lib/commands/research.js +79 -0
  108. package/lib/commands/router.js +77 -0
  109. package/lib/commands/rows.js +367 -0
  110. package/lib/commands/run.js +82 -10
  111. package/lib/commands/scope.js +2 -2
  112. package/lib/commands/server.js +405 -0
  113. package/lib/commands/setup.js +33 -7
  114. package/lib/commands/shortcut.js +41 -0
  115. package/lib/commands/skills.js +68 -9
  116. package/lib/commands/slot.js +260 -7
  117. package/lib/commands/status.js +1 -1
  118. package/lib/commands/team.js +3 -1
  119. package/lib/commands/tips.js +23 -0
  120. package/lib/commands/tool.js +133 -45
  121. package/lib/commands/trace.js +12 -7
  122. package/lib/commands/trigger.js +95 -0
  123. package/lib/commands/update.js +46 -0
  124. package/lib/commands/vault.js +31 -8
  125. package/lib/commands/vm.js +237 -0
  126. package/lib/commands/voice.js +34 -3
  127. package/lib/commands/volt.js +45 -3
  128. package/lib/commands/walkthrough.js +32 -0
  129. package/lib/commands/watch.js +73 -21
  130. package/lib/commands/welcome.js +14 -0
  131. package/lib/commands/workflow.js +189 -32
  132. package/lib/commands/x.js +13 -4
  133. package/lib/commands/youtube.js +172 -0
  134. package/lib/core/actingAs.js +2 -2
  135. package/lib/core/actionItems.js +115 -0
  136. package/lib/core/activeSummary.js +186 -0
  137. package/lib/core/agentAlias.js +2 -0
  138. package/lib/core/agentFamilies.js +7 -0
  139. package/lib/core/aside.js +54 -0
  140. package/lib/core/askDecision.js +60 -0
  141. package/lib/core/auth.js +164 -13
  142. package/lib/core/callerSource.js +1 -1
  143. package/lib/core/cliBuild.js +62 -0
  144. package/lib/core/client.js +152 -35
  145. package/lib/core/commandRegistrars.js +130 -0
  146. package/lib/core/config.js +143 -25
  147. package/lib/core/customHats.js +168 -0
  148. package/lib/core/doneEngineFetch.js +8 -0
  149. package/lib/core/errors.js +329 -20
  150. package/lib/core/findingReconcile.js +90 -0
  151. package/lib/core/global-flags.js +105 -5
  152. package/lib/core/governedSession.js +1 -3
  153. package/lib/core/hyperion.js +102 -0
  154. package/lib/core/inboxResolution.js +65 -0
  155. package/lib/core/lazyCommands.js +4 -1
  156. package/lib/core/liveSessionsReport.js +1 -1
  157. package/lib/core/loopGlyph.js +97 -0
  158. package/lib/core/offload.js +533 -0
  159. package/lib/core/org-override.js +1 -1
  160. package/lib/core/orgResolve.js +70 -0
  161. package/lib/core/output.js +11 -11
  162. package/lib/core/peek.js +126 -0
  163. package/lib/core/profileUsers.js +51 -0
  164. package/lib/core/promptCancel.js +18 -0
  165. package/lib/core/repoPath.js +11 -0
  166. package/lib/core/residentSeed.js +3 -1
  167. package/lib/core/rootOptions.js +39 -0
  168. package/lib/core/router.js +168 -0
  169. package/lib/core/selfUpdate.js +188 -0
  170. package/lib/core/sessionHeartbeat.js +12 -8
  171. package/lib/core/sessionsOverview.js +64 -0
  172. package/lib/core/shortcut.js +83 -0
  173. package/lib/core/slotKeeper.js +248 -3
  174. package/lib/core/slotWake.js +120 -0
  175. package/lib/core/supervisorEngine.js +71 -2
  176. package/lib/core/version.js +1 -1
  177. package/lib/core/vm.js +366 -0
  178. package/lib/core/vmTui.js +86 -0
  179. package/lib/core/walkthrough.js +35 -0
  180. package/lib/dragon/companionView.js +34 -0
  181. package/lib/dragon/frames.js +118 -0
  182. package/lib/dragon/jobs.js +123 -0
  183. package/lib/dragon/renderWorker.js +15 -0
  184. package/lib/dragon/select.js +72 -0
  185. package/lib/evidence/bundleExport.js +47 -0
  186. package/lib/evidence/bundleVerify.js +77 -0
  187. package/lib/hyperion/deck.js +29 -0
  188. package/lib/lucid/LucidCard.jsx +68 -0
  189. package/lib/lucid/LucidIntro.jsx +45 -58
  190. package/lib/lucid/cards.js +86 -0
  191. package/lib/lucid/markdown.js +81 -0
  192. package/lib/lucid/motion.js +56 -0
  193. package/lib/quoxon/EXTENSIONS.md +23 -0
  194. package/lib/quoxon/activity.js +14 -2
  195. package/lib/quoxon/aocl.js +117 -0
  196. package/lib/quoxon/api.js +690 -25
  197. package/lib/quoxon/app.js +3586 -250
  198. package/lib/quoxon/approvalFacts.js +96 -0
  199. package/lib/quoxon/approvalSentence.js +44 -0
  200. package/lib/quoxon/approvalsView.js +384 -0
  201. package/lib/quoxon/asideView.js +40 -0
  202. package/lib/quoxon/ask.js +121 -0
  203. package/lib/quoxon/away.js +52 -0
  204. package/lib/quoxon/bang.js +224 -0
  205. package/lib/quoxon/bulkRisk.js +17 -0
  206. package/lib/quoxon/capabilities.js +13 -6
  207. package/lib/quoxon/cells.js +53 -0
  208. package/lib/quoxon/commandShort.js +124 -0
  209. package/lib/quoxon/commands.js +429 -39
  210. package/lib/quoxon/connect.js +210 -23
  211. package/lib/quoxon/connectCard.js +19 -2
  212. package/lib/quoxon/consent.js +86 -0
  213. package/lib/quoxon/copy.js +4 -3
  214. package/lib/quoxon/countdownLine.js +20 -0
  215. package/lib/quoxon/deck.js +30 -0
  216. package/lib/quoxon/demo.autoplay.js +29 -0
  217. package/lib/quoxon/demo.fixtures.js +1 -1
  218. package/lib/quoxon/demo.js +46 -3
  219. package/lib/quoxon/devObserver.js +20 -0
  220. package/lib/quoxon/dividerAocl.js +192 -0
  221. package/lib/quoxon/dividerRule.js +57 -0
  222. package/lib/quoxon/dragonCompanion.js +255 -0
  223. package/lib/quoxon/dragonModes.js +151 -0
  224. package/lib/quoxon/dragonMoments.js +82 -0
  225. package/lib/quoxon/dragonReads.js +68 -0
  226. package/lib/quoxon/dragonView.js +41 -0
  227. package/lib/quoxon/earlyInput.js +40 -0
  228. package/lib/quoxon/echo.js +15 -0
  229. package/lib/quoxon/egg.js +2 -2
  230. package/lib/quoxon/events.js +266 -26
  231. package/lib/quoxon/extensions.js +276 -0
  232. package/lib/quoxon/feedback.js +50 -0
  233. package/lib/quoxon/gallery.js +21 -7
  234. package/lib/quoxon/handoffOffer.js +78 -0
  235. package/lib/quoxon/hatSwitch.js +37 -0
  236. package/lib/quoxon/headless.js +161 -0
  237. package/lib/quoxon/hostUrls.js +41 -0
  238. package/lib/quoxon/inboxView.js +79 -0
  239. package/lib/quoxon/indicator.js +6 -2
  240. package/lib/quoxon/installCli.js +79 -0
  241. package/lib/quoxon/intent.js +12 -1
  242. package/lib/quoxon/keys.js +51 -8
  243. package/lib/quoxon/launcher.js +103 -0
  244. package/lib/quoxon/loopCard.js +456 -0
  245. package/lib/quoxon/loopCards.js +180 -0
  246. package/lib/quoxon/loopStream.js +87 -0
  247. package/lib/quoxon/main.js +101 -160
  248. package/lib/quoxon/markdown.js +45 -21
  249. package/lib/quoxon/markdownSettle.js +19 -0
  250. package/lib/quoxon/matrixDomains.json +46 -0
  251. package/lib/quoxon/mentions.js +90 -0
  252. package/lib/quoxon/mind.js +103 -0
  253. package/lib/quoxon/mode.js +46 -0
  254. package/lib/quoxon/model.js +132 -26
  255. package/lib/quoxon/modelPrefs.js +345 -0
  256. package/lib/quoxon/motion/clock.js +3 -2
  257. package/lib/quoxon/motion/level.js +91 -0
  258. package/lib/quoxon/mouse.js +222 -0
  259. package/lib/quoxon/noai.js +57 -0
  260. package/lib/quoxon/objectiveOffer.js +82 -0
  261. package/lib/quoxon/objects/away.js +72 -0
  262. package/lib/quoxon/objects/fetch.js +17 -5
  263. package/lib/quoxon/objects/fleet.js +149 -0
  264. package/lib/quoxon/objects/health.js +186 -0
  265. package/lib/quoxon/objects/index.js +46 -7
  266. package/lib/quoxon/objects/mirror.js +137 -0
  267. package/lib/quoxon/objects/model.js +33 -0
  268. package/lib/quoxon/objects/peek.js +17 -0
  269. package/lib/quoxon/objects/proof.js +12 -4
  270. package/lib/quoxon/objects/prove.js +130 -0
  271. package/lib/quoxon/objects/replay.js +125 -0
  272. package/lib/quoxon/objects/sitrep.js +58 -0
  273. package/lib/quoxon/objects/view.js +11 -0
  274. package/lib/quoxon/onboarding/copy.js +32 -4
  275. package/lib/quoxon/onboarding/credential.js +17 -4
  276. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  277. package/lib/quoxon/onboarding/failureCause.js +33 -0
  278. package/lib/quoxon/onboarding/finish.js +15 -0
  279. package/lib/quoxon/onboarding/index.js +34 -7
  280. package/lib/quoxon/onboarding/installRunner.js +64 -5
  281. package/lib/quoxon/onboarding/preflight.js +17 -3
  282. package/lib/quoxon/onboarding/probe.js +65 -0
  283. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  284. package/lib/quoxon/onboarding/view.js +76 -19
  285. package/lib/quoxon/orgLabel.js +10 -0
  286. package/lib/quoxon/orgSwitch.js +59 -0
  287. package/lib/quoxon/ownBroadcast.js +22 -0
  288. package/lib/quoxon/palette.js +43 -0
  289. package/lib/quoxon/planSteps.js +51 -0
  290. package/lib/quoxon/print.js +12 -5
  291. package/lib/quoxon/profileSwitch.js +53 -0
  292. package/lib/quoxon/promptHistory.js +41 -0
  293. package/lib/quoxon/providers.js +48 -0
  294. package/lib/quoxon/qart.js +20 -51
  295. package/lib/quoxon/qdragon.js +410 -0
  296. package/lib/quoxon/qdragonFly.js +62 -0
  297. package/lib/quoxon/qdragonScenes.js +282 -0
  298. package/lib/quoxon/qeyes.js +213 -0
  299. package/lib/quoxon/qmotion.js +75 -37
  300. package/lib/quoxon/render.js +740 -124
  301. package/lib/quoxon/resend.js +72 -0
  302. package/lib/quoxon/roster.js +117 -17
  303. package/lib/quoxon/route.js +6 -5
  304. package/lib/quoxon/rows.js +954 -0
  305. package/lib/quoxon/rowsStore.js +80 -0
  306. package/lib/quoxon/screenContext.js +150 -0
  307. package/lib/quoxon/screenFrames.js +254 -0
  308. package/lib/quoxon/serverChip.js +42 -0
  309. package/lib/quoxon/session.js +182 -2
  310. package/lib/quoxon/sessionDead.js +17 -0
  311. package/lib/quoxon/signin.js +125 -0
  312. package/lib/quoxon/slash/agents.js +112 -0
  313. package/lib/quoxon/slash/argSources.js +65 -0
  314. package/lib/quoxon/slash/args.js +65 -0
  315. package/lib/quoxon/slash/automate.js +89 -0
  316. package/lib/quoxon/slash/broadcast.js +140 -0
  317. package/lib/quoxon/slash/broadcastRun.js +108 -0
  318. package/lib/quoxon/slash/card.js +30 -0
  319. package/lib/quoxon/slash/challenge.js +114 -0
  320. package/lib/quoxon/slash/cliCommand.js +94 -0
  321. package/lib/quoxon/slash/cliLeaves.json +1023 -0
  322. package/lib/quoxon/slash/compact.js +1 -1
  323. package/lib/quoxon/slash/confirm.js +30 -0
  324. package/lib/quoxon/slash/crystallize.js +69 -0
  325. package/lib/quoxon/slash/dashboard.js +136 -0
  326. package/lib/quoxon/slash/draft.js +54 -0
  327. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  328. package/lib/quoxon/slash/hatWalk.js +206 -0
  329. package/lib/quoxon/slash/inverses.js +77 -0
  330. package/lib/quoxon/slash/killer.js +24 -0
  331. package/lib/quoxon/slash/killerApi.js +16 -0
  332. package/lib/quoxon/slash/ledger.js +13 -8
  333. package/lib/quoxon/slash/listView.js +3 -1
  334. package/lib/quoxon/slash/modelCmd.js +261 -0
  335. package/lib/quoxon/slash/palette.js +22 -8
  336. package/lib/quoxon/slash/paletteController.js +117 -7
  337. package/lib/quoxon/slash/panels.js +21 -6
  338. package/lib/quoxon/slash/picker.js +20 -2
  339. package/lib/quoxon/slash/qpeCmd.js +35 -0
  340. package/lib/quoxon/slash/restart.js +95 -13
  341. package/lib/quoxon/slash/resume.js +18 -1
  342. package/lib/quoxon/slash/rowCmd.js +157 -0
  343. package/lib/quoxon/slash/rowWalk.js +175 -0
  344. package/lib/quoxon/slash/settings.js +16 -6
  345. package/lib/quoxon/slash/spawn.js +177 -0
  346. package/lib/quoxon/slash/stepUp.js +274 -0
  347. package/lib/quoxon/slash/stepUpCard.js +60 -0
  348. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  349. package/lib/quoxon/slash/todoCmd.js +122 -0
  350. package/lib/quoxon/slash/undoApi.js +38 -0
  351. package/lib/quoxon/slash/undoCmd.js +49 -0
  352. package/lib/quoxon/slash/undoWish.js +15 -0
  353. package/lib/quoxon/slash/watch.js +164 -0
  354. package/lib/quoxon/slash/wish.js +23 -0
  355. package/lib/quoxon/slash/wishCmd.js +32 -0
  356. package/lib/quoxon/slotChat.js +300 -0
  357. package/lib/quoxon/slotFormat.js +195 -0
  358. package/lib/quoxon/slotRun.js +131 -0
  359. package/lib/quoxon/sound/mapping.js +2 -1
  360. package/lib/quoxon/stray.js +22 -0
  361. package/lib/quoxon/suggestCommand.js +90 -0
  362. package/lib/quoxon/term.js +46 -11
  363. package/lib/quoxon/textBuffer.js +176 -2
  364. package/lib/quoxon/theme.js +58 -25
  365. package/lib/quoxon/tips.js +433 -0
  366. package/lib/quoxon/toolCards.js +30 -0
  367. package/lib/quoxon/toolError.js +41 -0
  368. package/lib/quoxon/transcript.js +42 -4
  369. package/lib/quoxon/web/qface.js +18 -10
  370. package/lib/quoxon/welcome.js +1 -1
  371. package/lib/quoxon/whatsnew.js +136 -0
  372. package/lib/quoxon/whatsnew.jsonl +134 -0
  373. package/lib/quoxon/windowOrg.js +123 -0
  374. package/lib/server/devServers.js +137 -0
  375. package/lib/server/diag.js +129 -0
  376. package/lib/server/doctor.js +71 -0
  377. package/lib/server/install.js +149 -0
  378. package/lib/server/pluginEnable.js +161 -0
  379. package/lib/server/skew.js +56 -0
  380. package/lib/server/snapshot.js +131 -0
  381. package/lib/server/status.js +128 -0
  382. package/lib/server/sys.js +65 -0
  383. package/lib/server/uninstall.js +63 -0
  384. package/lib/server/update.js +234 -0
  385. package/lib/toolOutputShape.js +315 -0
  386. package/lib/tui/views/HyperionDeck.jsx +79 -0
  387. package/lib/tui/views/LucidDeck.jsx +209 -217
  388. package/package.json +5 -1
  389. package/vendor/dragonlab/SOURCE +4 -0
  390. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  391. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  392. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  393. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  394. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  395. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  396. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  397. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  398. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  399. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  400. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  401. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  402. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  403. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  404. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  405. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  406. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  407. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  408. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  409. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  410. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  411. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  412. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  413. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  414. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  415. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  416. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  417. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  418. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  419. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  420. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  421. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  422. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  423. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  424. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  425. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  426. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  427. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  428. package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
  429. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  430. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  431. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  432. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  433. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  434. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  435. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  436. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  437. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  438. package/vendor/dragonlab/lib/power-effects.js +294 -0
  439. package/vendor/dragonlab/lib/power-registry.js +74 -0
  440. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  441. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
  442. package/lib/core/websiteSession.js +0 -148
@@ -13,9 +13,10 @@
13
13
  * environmental (clock, sleep, probe, signals, fs dir) is injected.
14
14
  */
15
15
 
16
+ import { decideWake, wakeText, draftLines, promptBoxState, isWakeLine, wakeQueued, WAKE_SETTLE_MS } from './slotWake.js';
16
17
  import {
17
18
  openSync, closeSync, writeSync, readFileSync, unlinkSync, appendFileSync,
18
- statSync, writeFileSync, mkdirSync,
19
+ statSync, writeFileSync, mkdirSync, chmodSync,
19
20
  } from 'node:fs';
20
21
  import { homedir } from 'node:os';
21
22
  import { join } from 'node:path';
@@ -32,6 +33,8 @@ export const BACKOFF_CAP_MS = 5 * 60 * 1000;
32
33
  // long outage beats letting the hold lapse (the collector revives a stale
33
34
  // slot for its own holder on the next successful heartbeat).
34
35
  export const MAX_FAILS = 24;
36
+ // #893: consecutive re-leases allowed without a good heartbeat in between.
37
+ export const MAX_REACQUIRES = 3;
35
38
  export const MAX_AUTH_FAILS = 3;
36
39
  export const LOG_MAX_BYTES = 256 * 1024;
37
40
  // QUOX-CLAUDE-3: the control queue is polled far more often than the 5-min
@@ -43,6 +46,18 @@ export const CONTROL_POLL_MS = 20 * 1000;
43
46
  export const CONTROL_BACKOFF_CAP_TICKS = 15;
44
47
  export const BUSY_WINDOW_MS = 60 * 1000;
45
48
  export const NO_TMUX_RESULT = 'session not under tmux; start it with quox claude';
49
+ // FORCE-WAKE: only a wake the server accepted from a human (an org admin via the CLI, or an allowlisted
50
+ // Discord user) carries this provenance; anything else keeps the old refusal at a draft.
51
+ const HUMAN_VIA = new Set(['cli', 'discord']);
52
+ export const FORCE_STEP_MS = 400; // between a key and the capture that checks it landed
53
+ export const FORCE_SUBMIT_WAIT_STEPS = 15; // ~6s for the wake line to leave the box before the draft goes back
54
+ // WAKE-REAL (owner 2026-10-17 "wake just pipes text to the prompt but doesnt wake anyone"): Claude Code drops an
55
+ // Enter that reaches it in the same read as the typed line (a loaded session reads both bursts as one paste), so
56
+ // the line sits in the box unsent. Proven live: QUOXON's 00:36:41 wake stayed unsent 3.5 min. The keeper lets
57
+ // the line land before Enter, then re-presses Enter only while the box still holds exactly its own line.
58
+ export const WAKE_LAND_STEPS = 10; // ~4s for the typed line to show in the box
59
+ export const WAKE_ENTER_TRIES = 4; // Enters sent at most (each only while the box holds only the wake line)
60
+ export const WAKE_ENTER_WAIT_STEPS = 6; // ~2.4s for the box to clear after each Enter
46
61
 
47
62
  export function keeperDir() {
48
63
  return join(homedir(), '.quox', 'keepers');
@@ -80,12 +95,22 @@ function defaultExecFile(file, args) {
80
95
  export async function runKeeper(opts) {
81
96
  const {
82
97
  name, session, claude, heartbeat, release, forget, probe,
98
+ // #893: optional. Re-lease after the hold LAPSED (expired, not taken over).
99
+ // Must never take over; a refusal (409) means someone else holds it.
100
+ reacquire = null,
83
101
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
84
102
  rand = Math.random,
85
103
  dir = keeperDir(),
86
104
  // Remote control (all optional; absent pollControl = heartbeat-only keeper).
87
105
  pollControl, ackControl, tmuxPane = null,
88
106
  execFile = defaultExecFile,
107
+ // POKE-WAKE: the gap between the two pane samples a wake compares.
108
+ settle = (ms) => new Promise((r) => setTimeout(r, ms)),
109
+ // KEEPER-REEXEC: the installed build (null = unknown), how to start a successor on the new
110
+ // build, and how to stop a keeper on an older one. All optional; absent = old behaviour.
111
+ buildId = () => null,
112
+ reexec = null,
113
+ kill = (pid, sig) => process.kill(pid, sig),
89
114
  now = () => Date.now(),
90
115
  installSignals = (cleanup) => {
91
116
  const h = () => { cleanup(); process.exit(0); };
@@ -96,6 +121,8 @@ export async function runKeeper(opts) {
96
121
 
97
122
  mkdirSync(dir, { recursive: true });
98
123
  const lockPath = join(dir, `${name}.pid`);
124
+ const buildPath = join(dir, `${name}.build`);
125
+ const startBuild = buildId();
99
126
  const logPath = join(dir, `${name}.log`);
100
127
  const log = (msg) => {
101
128
  try {
@@ -119,8 +146,21 @@ export async function runKeeper(opts) {
119
146
  let other = NaN;
120
147
  try { other = parseInt(readFileSync(lockPath, 'utf-8'), 10); } catch { /* raced away */ }
121
148
  if (Number.isFinite(other) && other !== process.pid && probe.alive(other)) {
122
- log(`another keeper (pid ${other}) already running; exiting`);
123
- return { code: 0, reason: 'already-running' };
149
+ // KEEPER-REEXEC: a keeper on an older (or unrecorded) build is replaced, so checkout always
150
+ // leaves the slot kept by the installed code. SIGTERM drops only its lock: the hold is kept.
151
+ const otherBuild = (() => { try { return readFileSync(buildPath, 'utf-8').trim() || null; } catch { return null; } })();
152
+ if (startBuild && otherBuild !== startBuild && attempt === 0) {
153
+ log(`replacing keeper pid ${other} (build ${otherBuild || 'unrecorded'}) with build ${startBuild}`);
154
+ try { kill(other, 'SIGTERM'); } catch { /* already gone */ }
155
+ for (let w = 0; w < 20 && probe.alive(other); w += 1) await settle(250);
156
+ if (probe.alive(other)) {
157
+ log(`keeper pid ${other} did not exit; leaving it`);
158
+ return { code: 0, reason: 'already-running' };
159
+ }
160
+ } else {
161
+ log(`another keeper (pid ${other}) already running; exiting`);
162
+ return { code: 0, reason: 'already-running' };
163
+ }
124
164
  }
125
165
  try { unlinkSync(lockPath); } catch { /* raced away */ }
126
166
  }
@@ -136,15 +176,37 @@ export async function runKeeper(opts) {
136
176
  } catch { /* already gone */ }
137
177
  };
138
178
  installSignals(cleanup);
179
+ if (startBuild) { try { writeFileSync(buildPath, startBuild); } catch { /* informational */ } }
180
+ // KEEPER-REEXEC: checked on every control poll. Lock is removed BEFORE the successor starts so it
181
+ // can take it; the hold is never released (the successor heartbeats the same slot).
182
+ let reexecFailedFor = null;
183
+ const staleBuild = () => {
184
+ if (!startBuild || typeof reexec !== 'function') return false;
185
+ const nowBuild = buildId();
186
+ if (!nowBuild || nowBuild === startBuild) return false;
187
+ if (reexecFailedFor === nowBuild) return false;
188
+ log(`installed build ${startBuild} -> ${nowBuild}: re-exec`);
189
+ cleanup();
190
+ try { reexec(); } catch (e) {
191
+ // No successor: take the lock back and keep heartbeating on the old code rather than drop the hold.
192
+ reexecFailedFor = nowBuild;
193
+ try { writeFileSync(lockPath, String(process.pid)); } catch { /* best effort */ }
194
+ log(`re-exec failed (${e.message}); staying on build ${startBuild}`);
195
+ return false;
196
+ }
197
+ return true;
198
+ };
139
199
 
140
200
  try {
141
201
  log(`keeper started session=${session} claude=${claude?.pid}:${claude?.starttime}`);
142
202
  let fails = 0;
203
+ let reacquires = 0;
143
204
  let delay = KEEP_INTERVAL_MS * (1 + rand() * 0.2);
144
205
 
145
206
  // Control queue state: ids already executed (never re-run if an ack failed,
146
207
  // only re-acked), and distinct failure accounting with tick-skip backoff.
147
208
  const handled = new Map();
209
+ const received = new Set();
148
210
  const startedAt = now();
149
211
  let lastHeartbeatAt = null;
150
212
  // Busy/idle: only a hash of the pane is kept (pane text can hold secrets
@@ -175,8 +237,12 @@ export async function runKeeper(opts) {
175
237
  for (const row of rows) {
176
238
  let outcome = handled.get(row.id);
177
239
  if (!outcome) {
240
+ if (!received.has(row.id)) { received.add(row.id); log(`control ${row.command} ${row.id} received from ${row.requested_via || '?'}`); }
178
241
  outcome = await runControlCommand(row);
242
+ // POKE-WAKE: a wake on a busy pane stays pending server-side and is retried next tick.
243
+ if (outcome === null) continue;
179
244
  handled.set(row.id, outcome);
245
+ received.delete(row.id);
180
246
  log(`control ${row.command} ${row.id} from ${row.requested_via || '?'}: ${outcome.status} (${outcome.result})`);
181
247
  }
182
248
  await ackControl(row.id, outcome.status, outcome.result);
@@ -225,8 +291,169 @@ export async function runKeeper(opts) {
225
291
  parts.push(`keeper up ${fmtDur((now() - startedAt) / 1000)}`);
226
292
  return parts.join(', ').slice(0, 500);
227
293
  };
294
+ // POKE-WAKE: wake an idle session so it reads its slot page now. Types ONE fixed line (wakeText),
295
+ // literally (-l, no key-name parsing), only when the pane is still and the prompt box empty.
296
+ const wakeSeen = new Map();
297
+ const waitLogged = new Map();
298
+ let lastWokeAt = null;
299
+ const capture = async () => {
300
+ try {
301
+ // -e: escape-aware, so the faint empty-box placeholder is not mistaken for a draft (FORCE-WAKE).
302
+ const out = await execFile('tmux', ['capture-pane', '-p', '-e', '-t', tmuxPane]);
303
+ return typeof out === 'string' ? out : String(out?.stdout ?? '');
304
+ } catch { return null; }
305
+ };
306
+ const runWake = async (row) => {
307
+ if (!tmuxPane) return { status: 'failed', result: NO_TMUX_RESULT };
308
+ // The 10m budget runs from the REQUEST (server created_at), not from this keeper's first sight of
309
+ // it: a re-exec'd keeper continues the same budget, and an old request is not mistaken for a new one.
310
+ if (!wakeSeen.has(row.id)) {
311
+ const created = Date.parse(row.created_at || '');
312
+ wakeSeen.set(row.id, Number.isFinite(created) && created <= now() ? created : now());
313
+ }
314
+ const before = await capture();
315
+ await settle(WAKE_SETTLE_MS);
316
+ const after = await capture();
317
+ const force = HUMAN_VIA.has(row.requested_via);
318
+ const d = decideWake({ slot: name, before, after, firstSeenAt: wakeSeen.get(row.id), nowMs: now(), lastWokeAt, force });
319
+ if (d.action === 'wait') {
320
+ if (waitLogged.get(row.id) !== d.reason) { waitLogged.set(row.id, d.reason); log(`wake ${row.id} waiting: ${d.reason} (retrying each poll for up to 10m)`); }
321
+ return null;
322
+ }
323
+ wakeSeen.delete(row.id);
324
+ waitLogged.delete(row.id);
325
+ if (d.action === 'force') return forceWake(row, after);
326
+ if (d.action !== 'type' && d.action !== 'submit') return { status: d.action === 'done' ? 'done' : 'failed', result: d.result };
327
+ try {
328
+ if (d.action === 'submit') log(`wake ${row.id}: an earlier wake line is still unsent in the box; submitting it`);
329
+ const r = await submitWakeLine(row.id, { typed: d.action === 'submit' });
330
+ if (!r.ok) return { status: 'failed', result: `not woken: ${r.why}; the page shows at its next prompt` };
331
+ lastWokeAt = now();
332
+ return { status: 'done', result: wokeResult(r) };
333
+ } catch (err) {
334
+ return { status: 'failed', result: `tmux send-keys failed: ${err.message}` };
335
+ }
336
+ };
337
+ // WAKE-REAL: the two outcomes a waker is told apart: a turn started, or Claude Code queued the line mid-turn.
338
+ const wokeResult = (r, extra = '') => (r.queued
339
+ ? `queued in ${tmuxPane}${extra}: the session is mid-turn, so Claude Code holds the wake line and sends it when the turn ends; it reads slot ${name} then`
340
+ : `woke ${tmuxPane}${extra}: the wake line was submitted and a turn started; it is reading slot ${name} now`);
341
+ // WAKE-REAL: type the fixed line (unless it is already in the box), let it land, Enter, and verify it left the
342
+ // box. Enter is re-pressed only while the box holds exactly this slot's wake line, so nothing else is ever sent.
343
+ // -> { ok: true, queued } | { ok: false, why }
344
+ const submitWakeLine = async (id, { typed = false, clearOnFail = true } = {}) => {
345
+ if (!typed) {
346
+ await keys('-l', wakeText(name));
347
+ for (let k = 0; k < WAKE_LAND_STEPS; k += 1) {
348
+ await settle(FORCE_STEP_MS);
349
+ const b = draftLines(await capture());
350
+ if (isWakeLine(b, name) || (Array.isArray(b) && b.length)) break;
351
+ }
352
+ }
353
+ for (let t = 0; t < WAKE_ENTER_TRIES; t += 1) {
354
+ const b = draftLines(await capture());
355
+ if (Array.isArray(b) && b.length && !isWakeLine(b, name)) {
356
+ log(`wake ${id}: the box holds something other than the wake line; no Enter sent`);
357
+ return { ok: false, why: 'the prompt box changed while waking (someone is typing), so nothing was sent' };
358
+ }
359
+ if (t > 0 && !(Array.isArray(b) && b.length)) return { ok: true, queued: wakeQueued(await capture()) };
360
+ await keys('Enter');
361
+ if (t > 0) log(`wake ${id}: Enter was not taken; pressed it again (${t + 1} of ${WAKE_ENTER_TRIES})`);
362
+ for (let k = 0; k < WAKE_ENTER_WAIT_STEPS; k += 1) {
363
+ const screen = await capture();
364
+ const after = draftLines(screen);
365
+ if (Array.isArray(after) && !after.length) { log(`wake ${id}: wake line submitted (${t + 1} Enter${t ? 's' : ''})`); return { ok: true, queued: wakeQueued(screen) }; }
366
+ if (!isWakeLine(after, name)) break;
367
+ await settle(FORCE_STEP_MS);
368
+ }
369
+ }
370
+ // Still ours in the box after every try: take it back out, so it never sits there as a fake draft (not on a
371
+ // force: C-u would overwrite the kill ring that holds the human's draft; the next wake submits it instead).
372
+ if (!clearOnFail) return { ok: false, why: `Claude Code did not take Enter (${WAKE_ENTER_TRIES} tries)` };
373
+ for (let k = 0; k < 12 && isWakeLine(draftLines(await capture()), name); k += 1) { await keys('C-u'); await settle(FORCE_STEP_MS); }
374
+ log(`wake ${id}: Claude Code did not take Enter after ${WAKE_ENTER_TRIES} tries; wake line cleared`);
375
+ return { ok: false, why: `Claude Code did not take Enter (${WAKE_ENTER_TRIES} tries), so the wake line was cleared from the box` };
376
+ };
377
+ // FORCE-WAKE (owner 2026-10-16: "wake needs to wake"): a human wake at a draft sets the draft aside and
378
+ // puts it back UNSENT. Proven live on Claude Code v2.1.288: C-u kills one visual line (consecutive kills
379
+ // accumulate in its kill ring), the ring survives a submit, and C-y yanks it back. Every step is checked
380
+ // by a capture; nothing of the user's is ever followed by Enter. The draft text never goes in the log;
381
+ // a private copy sits in <slot>.draft until it is verified back in the box.
382
+ const same = (a, b) => Array.isArray(a) && Array.isArray(b) && a.join('\n') === b.join('\n');
383
+ const tag = (lines) => `${lines.length} line(s), ${lines.join('\n').length} chars, sha ${createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 8)}`;
384
+ const keys = (...k) => execFile('tmux', ['send-keys', '-t', tmuxPane, ...k]);
385
+ const forceWake = async (row, screen) => {
386
+ const draft = draftLines(screen);
387
+ const draftPath = join(dir, `${name}.draft`);
388
+ const NOT = 'not woken: ';
389
+ try {
390
+ writeFileSync(draftPath, `${draft.join('\n')}\n`, { mode: 0o600 });
391
+ chmodSync(draftPath, 0o600);
392
+ } catch (e) {
393
+ log(`force ${row.id}: could not save a safety copy of the draft (${e.message}); refusing`);
394
+ return { status: 'failed', result: `${NOT}could not save a safety copy of the draft first, so it was left untouched; the page shows at its next prompt` };
395
+ }
396
+ log(`force ${row.id}: captured draft (${tag(draft)}); safety copy ${draftPath}`);
397
+ // A human may be typing: the box must still hold exactly what was captured.
398
+ if (!same(draftLines(await capture()), draft)) {
399
+ log(`force ${row.id}: draft changed before clearing; refusing`);
400
+ try { unlinkSync(draftPath); } catch { /* best effort */ }
401
+ return { status: 'failed', result: `${NOT}the draft changed while setting it aside (someone is typing); left untouched` };
402
+ }
403
+ try {
404
+ let box = draft;
405
+ for (let k = 0; k < draft.length + 3 && box && box.length; k += 1) {
406
+ await keys('C-u');
407
+ await settle(FORCE_STEP_MS);
408
+ box = draftLines(await capture());
409
+ }
410
+ if (!box || box.length) {
411
+ log(`force ${row.id}: box did not clear (${box ? 'text left' : 'no box'}); yanking the draft back, nothing sent`);
412
+ if (box && box.length) await keys('C-u'); // finish the kill run so the yank puts back the whole draft
413
+ await keys('C-y');
414
+ await settle(FORCE_STEP_MS);
415
+ const back = same(draftLines(await capture()), draft);
416
+ log(`force ${row.id}: draft ${back ? 'restored' : `NOT verified back; safety copy kept at ${draftPath}`}`);
417
+ if (back) { try { unlinkSync(draftPath); } catch { /* best effort */ } }
418
+ return { status: 'failed', result: `${NOT}could not clear the draft safely, so it was ${back ? 'put back as it was' : `left in place (a copy is saved at ${draftPath})`}; nothing was sent` };
419
+ }
420
+ log(`force ${row.id}: cleared the prompt box (${draft.length} C-u)`);
421
+ const sub = await submitWakeLine(row.id, { clearOnFail: false });
422
+ log(`force ${row.id}: typed wake line + Enter${sub.ok ? '' : ` (${sub.why})`}`);
423
+ let st = sub.ok ? 'empty' : null;
424
+ for (let k = 0; sub.ok && k < FORCE_SUBMIT_WAIT_STEPS && st !== 'empty'; k += 1) {
425
+ await settle(FORCE_STEP_MS);
426
+ st = promptBoxState(await capture());
427
+ }
428
+ if (st !== 'empty') {
429
+ log(`force ${row.id}: box not free after the wake line (${st}); draft NOT restored, safety copy kept at ${draftPath}`);
430
+ return { status: 'failed', result: `${NOT}the wake line did not leave the prompt box; your draft is saved at ${draftPath} on that host (in the box kill ring too: Ctrl+Y)` };
431
+ }
432
+ lastWokeAt = now();
433
+ await keys('C-y');
434
+ await settle(FORCE_STEP_MS);
435
+ let back = draftLines(await capture());
436
+ if (!same(back, draft) && Array.isArray(back) && !back.length && draft.length === 1) {
437
+ log(`force ${row.id}: yank did not restore; typing the single-line draft back literally (no Enter)`);
438
+ await keys('-l', draft[0]);
439
+ await settle(FORCE_STEP_MS);
440
+ back = draftLines(await capture());
441
+ }
442
+ if (!same(back, draft)) {
443
+ log(`force ${row.id}: woke, but the draft is NOT verified back; safety copy kept at ${draftPath}`);
444
+ return { status: 'done', result: `${wokeResult(sub)}; but its draft could not be verified back in the box, a copy is saved at ${draftPath} on that host` };
445
+ }
446
+ try { unlinkSync(draftPath); } catch { /* best effort */ }
447
+ log(`force ${row.id}: restored draft (verified, not sent)`);
448
+ return { status: 'done', result: wokeResult(sub, ' (its draft was set aside and put back unsent)') };
449
+ } catch (err) {
450
+ log(`force ${row.id}: tmux failed (${err.message}); safety copy kept at ${draftPath}`);
451
+ return { status: 'failed', result: `tmux send-keys failed mid-wake: ${err.message}; the draft is saved at ${draftPath} on that host` };
452
+ }
453
+ };
228
454
  const runControlCommand = async (row) => {
229
455
  if (row.command === 'status') return { status: 'done', result: await statusResult() };
456
+ if (row.command === 'wake') return runWake(row);
230
457
  if (row.command !== 'compact') return { status: 'failed', result: `unsupported command ${row.command}` };
231
458
  if (!tmuxPane) return { status: 'failed', result: NO_TMUX_RESULT };
232
459
  try {
@@ -243,9 +470,11 @@ export async function runKeeper(opts) {
243
470
  await sleep(CONTROL_POLL_MS);
244
471
  remaining -= CONTROL_POLL_MS;
245
472
  await controlTick();
473
+ if (staleBuild()) return { code: 0, reason: 'reexec' };
246
474
  }
247
475
  await sleep(remaining);
248
476
  if (pollControl) await controlTick();
477
+ if (staleBuild()) return { code: 0, reason: 'reexec' };
249
478
 
250
479
  if (claude && !sameProcess(probe, claude.pid, claude.starttime)) {
251
480
  log(`claude session gone (pid ${claude.pid}); releasing ${name} and exiting`);
@@ -258,10 +487,26 @@ export async function runKeeper(opts) {
258
487
  lastHeartbeatAt = now();
259
488
  if (fails > 0) log(`heartbeat recovered after ${fails} failure(s)`);
260
489
  fails = 0;
490
+ reacquires = 0;
261
491
  log('heartbeat ok');
262
492
  delay = KEEP_INTERVAL_MS * (1 + rand() * 0.2);
263
493
  } catch (err) {
264
494
  if (isHoldLost(err)) {
495
+ // #893: a lease that merely EXPIRED (e.g. auth stalled past the TTL)
496
+ // also answers 409. Try one non-takeover re-lease per lapse, at most
497
+ // MAX_REACQUIRES in a row without a good heartbeat in between.
498
+ if (reacquire && reacquires < MAX_REACQUIRES) {
499
+ reacquires += 1;
500
+ try {
501
+ await reacquire();
502
+ log(`hold lapsed (${err.status || ''} ${err.message}); re-acquired (${reacquires}/${MAX_REACQUIRES})`);
503
+ fails = 0;
504
+ delay = 0;
505
+ continue;
506
+ } catch (re) {
507
+ log(`re-acquire refused: ${re.status || ''} ${re.message}`);
508
+ }
509
+ }
265
510
  log(`HOLD LOST: ${name} is no longer held by ${session}; keeper exiting`);
266
511
  try { forget(); } catch { /* best effort */ }
267
512
  return { code: 0, reason: 'hold-lost' };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * POKE-WAKE (owner 2026-10-15: "/poke cli-site ... not listening right now ... this delay not ideal").
3
+ *
4
+ * A page is a slot message; an idle Claude Code session only reads slot mail when a turn starts
5
+ * (the UserPromptSubmit slot-inbox hook). Nothing starts a turn in an idle session except input.
6
+ * So `wake` is a slot CONTROL command (server-gated to a human org admin, never an agent) that the
7
+ * session's own keeper runs: it types ONE FIXED LINE into the session's tmux pane, and only when
8
+ * the pane is still and its prompt box is empty. The page text is never typed; the session reads
9
+ * it through the normal slot path. Pure decisions live here; the keeper does the I/O.
10
+ */
11
+
12
+ export const WAKE_TTL_MS = 10 * 60 * 1000; // a busy session is retried each control tick for this long
13
+ export const WAKE_COALESCE_MS = 60 * 1000; // repeat pokes inside this window do not type again
14
+ export const WAKE_SETTLE_MS = 2000; // two pane samples this far apart: any change = busy or a human typing
15
+
16
+ const SLOT_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
17
+ // A box rule; the TOP one may carry the session title ("──── QUOXON ─", seen live 2026-10-15).
18
+ const RULE_RE = /^\s*─{8,}(?:.*─)?\s*$/;
19
+ const PROMPT_RE = /^\s*❯(?:[\s ]|$)/;
20
+ // FORCE-WAKE: captures are escape-aware (`capture-pane -e`). Claude Code draws its empty-box suggestion FAINT
21
+ // (ESC[2m) straight after the prompt; plain capture read it as a draft (proven live, v2.1.288, 2026-10-16).
22
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
23
+ const PLACEHOLDER_RE = /❯[\s ]*(?:\x1b\[(?!2m)[0-9;]*m)*\x1b\[2m/;
24
+ const UNCAPTURABLE_RE = /\[(?:Pasted text|Image)\b/;
25
+ const plain = (line) => line.replace(ANSI_RE, '');
26
+
27
+ /** The only text a wake ever types. Names the slot, never carries the page. */
28
+ export function wakeText(slot) {
29
+ if (typeof slot !== 'string' || !SLOT_RE.test(slot)) throw new Error(`not a slot name: ${JSON.stringify(slot)}`);
30
+ return `[quox slot wake] a page is waiting on slot ${slot}: read it with \`quox slot read ${slot}\` and answer there with \`quox slot say ${slot}\`.`;
31
+ }
32
+
33
+ const flat = (s) => String(s).replace(/\s+/g, '');
34
+ /** WAKE-REAL: do these box lines (draftLines) hold exactly this slot's wake line, as Claude Code wrapped it? */
35
+ export function isWakeLine(lines, slot) {
36
+ if (!Array.isArray(lines) || !lines.length || !SLOT_RE.test(String(slot || ''))) return false;
37
+ return flat(lines.join('')) === flat(wakeText(slot));
38
+ }
39
+
40
+ // WAKE-REAL: what Claude Code (v2.1.288, seen live 2026-10-17) shows while a submitted line waits for a running
41
+ // turn: the line above the box with "ctrl+x ctrl+s to send now", and "Press up to edit queued messages" in it.
42
+ const QUEUED_RE = /Press up to edit queued|ctrl\+x ctrl\+s to send now/;
43
+ /** Did the last submit land in Claude Code's queue (session mid-turn) rather than start a turn? */
44
+ export function wakeQueued(text) {
45
+ return typeof text === 'string' && QUEUED_RE.test(aboveFooter(text).split('\n').map(plain).join('\n'));
46
+ }
47
+
48
+ /**
49
+ * The state of the LAST Claude Code prompt box on screen: 'empty', 'draft' (typing would send a
50
+ * human's unsent text) or 'unknown' (no box: a dialog, menu or pager; never treated as empty).
51
+ */
52
+ export function promptBoxState(text) {
53
+ const lines = draftLines(text);
54
+ if (lines === null) return 'unknown';
55
+ return lines.join('\n').replace(/\u00a0/g, ' ').trim() ? 'draft' : 'empty';
56
+ }
57
+
58
+ /**
59
+ * FORCE-WAKE: the last prompt box's draft as rendered, one entry per visual line (plain text, prompt
60
+ * stripped, trailing spaces dropped); [] for an empty box (or the faint placeholder); null for no box.
61
+ */
62
+ export function draftLines(text) {
63
+ if (typeof text !== 'string' || !text) return null;
64
+ const raw = text.split('\n');
65
+ const lines = raw.map(plain);
66
+ for (let i = lines.length - 1; i > 0; i -= 1) {
67
+ if (!PROMPT_RE.test(lines[i]) || !RULE_RE.test(lines[i - 1])) continue;
68
+ if (PLACEHOLDER_RE.test(raw[i])) return [];
69
+ const body = [lines[i].replace(PROMPT_RE, '')];
70
+ for (let j = i + 1; j < lines.length && !RULE_RE.test(lines[j]); j += 1) body.push(lines[j]);
71
+ const out = body.map((l) => l.replace(/\u00a0/g, ' ').replace(/\s+$/, ''));
72
+ while (out.length && !out[out.length - 1]) out.pop();
73
+ return out.join('').trim() ? out : [];
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** The screen down to the last prompt box's closing rule: the footer below it ticks (shell counts,
79
+ * agent timers) on an idle session, so it must not count as activity. No box = the whole screen. */
80
+ export function aboveFooter(text) {
81
+ if (typeof text !== 'string') return text;
82
+ const raw = text.split('\n');
83
+ const lines = raw.map(plain);
84
+ for (let i = lines.length - 1; i > 0; i -= 1) {
85
+ if (!PROMPT_RE.test(lines[i]) || !RULE_RE.test(lines[i - 1])) continue;
86
+ let j = i + 1;
87
+ while (j < lines.length && !RULE_RE.test(lines[j])) j += 1;
88
+ return raw.slice(0, Math.min(j + 1, raw.length)).join('\n');
89
+ }
90
+ return text;
91
+ }
92
+
93
+ /**
94
+ * One wake attempt. before/after are two pane captures WAKE_SETTLE_MS apart (null = unreadable).
95
+ * force (FORCE-WAKE, human-requested rows only): a draft yields { action: 'force' } instead of a refusal.
96
+ * Returns { action: 'type' } | { action: 'force' } | { action: 'submit' } (our own stranded wake line) | { action: 'wait', reason } (leave the request pending) |
97
+ * { action: 'done' | 'fail', result }.
98
+ */
99
+ export function decideWake({ slot, before, after, firstSeenAt, nowMs, lastWokeAt = null, force = false }) {
100
+ if (typeof before !== 'string' || typeof after !== 'string') return { action: 'fail', result: 'could not read the session pane' };
101
+ if (lastWokeAt != null && nowMs - lastWokeAt < WAKE_COALESCE_MS) return { action: 'done', result: `already woken ${Math.round((nowMs - lastWokeAt) / 1000)}s ago` };
102
+ const expired = nowMs - firstSeenAt > WAKE_TTL_MS;
103
+ if (aboveFooter(before) !== aboveFooter(after)) {
104
+ return expired ? { action: 'fail', result: `session still busy and the wake request is ${Math.floor((nowMs - firstSeenAt) / 60000)}m old (limit ${WAKE_TTL_MS / 60000}m); the page on ${slot} shows at its next prompt` } : { action: 'wait', reason: 'busy' };
105
+ }
106
+ const box = promptBoxState(after);
107
+ // WAKE-REAL: the box holds OUR wake line, typed earlier but never submitted (Claude Code dropped its Enter;
108
+ // QUOXON 2026-10-17 00:36:41, proven by sha c8d63692). It is not a human draft: submit it, never set it aside.
109
+ if (box === 'draft' && isWakeLine(draftLines(after), slot)) return { action: 'submit' };
110
+ if (box === 'draft' && force) {
111
+ // FORCE-WAKE: a human wake sets the draft aside and puts it back unsent; only if it can be put back exactly.
112
+ if (UNCAPTURABLE_RE.test(draftLines(after).join('\n'))) return { action: 'fail', result: 'not woken: the draft holds a collapsed pasted block or image, which cannot be set aside and put back exactly; the page shows at its next prompt' };
113
+ return { action: 'force' };
114
+ }
115
+ if (box === 'draft') return { action: 'fail', result: 'not woken: an unsent draft is in the prompt box (typing would send it); the page shows at its next prompt' };
116
+ if (box === 'unknown') {
117
+ return expired ? { action: 'fail', result: `no empty prompt and the wake request is ${Math.floor((nowMs - firstSeenAt) / 60000)}m old (a dialog or menu is open); the page shows at its next prompt` } : { action: 'wait', reason: 'no prompt box' };
118
+ }
119
+ return { action: 'type' };
120
+ }
@@ -278,18 +278,24 @@ export async function attemptSlotClaim({
278
278
  return { claimed: false, reason: 'error', error: err };
279
279
  }
280
280
  claimState.held = false;
281
+ claimState.lapsed = true;
281
282
  }
282
283
  }
283
284
  try {
285
+ const wasHeld = !!(claimState && claimState.everHeld);
284
286
  const res = await checkoutSlot();
285
- if (claimState) { claimState.held = true; claimState.claimedAt = now(); }
287
+ if (claimState) { claimState.held = true; claimState.everHeld = true; claimState.claimedAt = now(); }
286
288
  const refreshed = !!(res && res.refreshed);
289
+ // GRIPE-797: log state transitions only. A periodic safety re-checkout
290
+ // of a hold this process already had is a renewal, not a new claim.
291
+ if (wasHeld && claimState && !claimState.lapsed) return { claimed: true, refreshed: true };
292
+ if (claimState) claimState.lapsed = false;
287
293
  log('info', refreshed
288
294
  ? `slot ${slot}: already held by this resident, refreshed.`
289
295
  : `slot ${slot}: claimed (was free), now present.`);
290
296
  return refreshed ? { claimed: true, refreshed } : { claimed: true };
291
297
  } catch (err) {
292
- if (claimState) claimState.held = false;
298
+ if (claimState) { claimState.held = false; claimState.lapsed = true; }
293
299
  if (err && err.status === 409) {
294
300
  const holder = err.detail?.holder_session || null;
295
301
  if (holder && holder !== selfIdentity) {
@@ -479,6 +485,40 @@ export function detectTurnError(result) {
479
485
  return { type, message, category, signature };
480
486
  }
481
487
 
488
+ /**
489
+ * GRIPE-797: an expired provider login (claude "Failed to authenticate:
490
+ * OAuth session expired and could not be refreshed", codex "not logged in"
491
+ * or a 401) is NOT a generic provider error: the fix is a human running a
492
+ * login command on this host. Returns {provider, command} or null.
493
+ */
494
+ export function detectAuthExpiry(result) {
495
+ if (!result || result.timedOut || result.error) return null;
496
+ // Only a FAILED turn can be an expiry: a successful reply that merely
497
+ // talks about logins or 401s is a reply, never an alert.
498
+ if (!detectTurnError(result)) return null;
499
+ const parts = [result.text, result.stdout, result.stderr];
500
+ const raw = result.raw;
501
+ if (raw && typeof raw === 'object') {
502
+ parts.push(raw.result, raw.message, raw.error && (raw.error.message || raw.error));
503
+ }
504
+ const blob = parts.filter((x) => typeof x === 'string').join('\n');
505
+ if (!blob) return null;
506
+ if (/failed to authenticate|oauth session expired|could not be refreshed|invalid authentication credentials|please run \/login/i.test(blob)) {
507
+ return { provider: 'claude', command: 'claude /login' };
508
+ }
509
+ if (/not logged in|codex login|401 unauthorized|\b401\b[^\n]{0,40}(unauthorized|authentication)|token (has )?expired/i.test(blob)) {
510
+ const claudeish = /claude/i.test(blob) && !/codex/i.test(blob);
511
+ return claudeish ? { provider: 'claude', command: 'claude /login' } : { provider: 'codex', command: 'codex login' };
512
+ }
513
+ return null;
514
+ }
515
+
516
+ export function buildAuthExpiryNoticeText(hostId, auth) {
517
+ const cmd = auth && auth.provider === 'codex' ? 'codex login' : 'claude /login';
518
+ const alt = auth && auth.provider === 'codex' ? '(or `claude /login`)' : '(or `codex login`)';
519
+ return `My login on ${hostId} has expired; run \`${cmd}\` ${alt} on ${hostId}.`;
520
+ }
521
+
482
522
  /**
483
523
  * One short, plain-language, single-sentence notice: no stack, no JSON, no
484
524
  * raw provider payload. This is the ONE honest signal an operator gets per
@@ -644,6 +684,7 @@ export async function runPollOnce({
644
684
  claimState = null,
645
685
  postHeartbeat = null,
646
686
  postTurnReceipt = null,
687
+ alertOwner = null,
647
688
  state,
648
689
  log = () => {},
649
690
  now = () => Date.now(),
@@ -786,6 +827,29 @@ export async function runPollOnce({
786
827
  // resident's own reply. Log the full error, record it in state for
787
828
  // `status` to surface, and post at most one short plain-language
788
829
  // notice per distinct error signature per hour.
830
+ const authExpiry = detectAuthExpiry(result);
831
+ if (authExpiry) {
832
+ log('error', `turn auth expiry for message ${message.id}: provider=${authExpiry.provider} (login expired; never posted raw)`);
833
+ await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: `auth_expired: ${authExpiry.provider}` });
834
+ const noticeText = buildAuthExpiryNoticeText(hostId, authExpiry);
835
+ nextState = {
836
+ ...nextState,
837
+ last_turn_error: { type: 'auth_expired', message: noticeText, category: 'auth expiry', signature: `auth_expired:${authExpiry.provider}`, at: new Date(now()).toISOString() },
838
+ };
839
+ // ONE slot reply + ONE owner alert per expiry episode; the flag is
840
+ // cleared only by a successful reply below.
841
+ if (!nextState.auth_expiry_alerted_at) {
842
+ await postReply(noticeText);
843
+ if (alertOwner) {
844
+ try { await alertOwner(noticeText); } catch (err) { log('warn', `auth-expiry owner alert failed: ${err.message}`); }
845
+ }
846
+ nextState = { ...nextState, auth_expiry_alerted_at: new Date(now()).toISOString() };
847
+ log('warn', `posted auth-expiry notice and owner alert (once per episode): ${noticeText}`);
848
+ } else {
849
+ log('warn', 'auth-expiry notice suppressed (already alerted this episode; clears on next successful turn).');
850
+ }
851
+ continue;
852
+ }
789
853
  const turnError = detectTurnError(result);
790
854
  if (turnError) {
791
855
  log('error', `turn error for message ${message.id}: type=${turnError.type} message=${turnError.message} (never posted to the channel)`);
@@ -813,6 +877,11 @@ export async function runPollOnce({
813
877
  continue;
814
878
  }
815
879
 
880
+ if (nextState.auth_expiry_alerted_at) {
881
+ const { auth_expiry_alerted_at: _cleared, ...rest } = nextState;
882
+ nextState = rest;
883
+ log('info', 'auth recovered: successful turn, auth-expiry alert re-armed.');
884
+ }
816
885
  await postReply(replyText);
817
886
  log('info', `replied to ${message.id} (${replyText.length} chars, session_id=${result.session_id || 'n/a'}, cost_usd=${result.total_cost_usd ?? 'n/a'})`);
818
887
  await receipt({
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { dirname, resolve } from 'node:path';
20
20
 
21
- export const BASE_VERSION = '0.2.0';
21
+ export const BASE_VERSION = '0.4.0';
22
22
 
23
23
  // The CLI source root (…/cli). version.js lives at cli/lib/core/, so two
24
24
  // levels up is the tree whose HEAD identifies the running build.