quoxcli 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (516) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +144 -2
  3. package/bin/quox.js +265 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +151 -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/agent-schedules.js +26 -0
  9. package/lib/api/endpoints/agents.js +63 -0
  10. package/lib/api/endpoints/assistants.js +62 -0
  11. package/lib/api/endpoints/audit.js +14 -0
  12. package/lib/api/endpoints/auth.js +50 -0
  13. package/lib/api/endpoints/awareness.js +34 -0
  14. package/lib/api/endpoints/away.js +15 -0
  15. package/lib/api/endpoints/backup-schedules.js +20 -0
  16. package/lib/api/endpoints/backups.js +86 -0
  17. package/lib/api/endpoints/brain.js +140 -0
  18. package/lib/api/endpoints/budgets.js +62 -0
  19. package/lib/api/endpoints/calendarq.js +71 -0
  20. package/lib/api/endpoints/catalog.js +14 -0
  21. package/lib/api/endpoints/chat.js +61 -0
  22. package/lib/api/endpoints/consent.js +26 -0
  23. package/lib/api/endpoints/contactsq.js +68 -0
  24. package/lib/api/endpoints/conversations.js +46 -0
  25. package/lib/api/endpoints/custom-roles.js +92 -0
  26. package/lib/api/endpoints/divisions.js +56 -0
  27. package/lib/api/endpoints/domains.js +38 -0
  28. package/lib/api/endpoints/engagement.js +231 -0
  29. package/lib/api/endpoints/engine.js +11 -0
  30. package/lib/api/endpoints/entity.js +50 -0
  31. package/lib/api/endpoints/executions.js +45 -0
  32. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  33. package/lib/api/endpoints/file.js +20 -0
  34. package/lib/api/endpoints/files.js +40 -0
  35. package/lib/api/endpoints/github.js +21 -0
  36. package/lib/api/endpoints/governance.js +43 -0
  37. package/lib/api/endpoints/hat.js +66 -0
  38. package/lib/api/endpoints/inbox.js +52 -0
  39. package/lib/api/endpoints/inboxq.js +120 -0
  40. package/lib/api/endpoints/index.js +242 -0
  41. package/lib/api/endpoints/invites.js +19 -0
  42. package/lib/api/endpoints/llm-policy.js +43 -0
  43. package/lib/api/endpoints/lucid.js +89 -0
  44. package/lib/api/endpoints/members.js +32 -0
  45. package/lib/api/endpoints/memory.js +94 -0
  46. package/lib/api/endpoints/mfa.js +32 -0
  47. package/lib/api/endpoints/mind.js +13 -0
  48. package/lib/api/endpoints/mon.js +184 -0
  49. package/lib/api/endpoints/notesq.js +43 -0
  50. package/lib/api/endpoints/objectives.js +68 -0
  51. package/lib/api/endpoints/orgs.js +38 -0
  52. package/lib/api/endpoints/plugins.js +152 -0
  53. package/lib/api/endpoints/powers.js +75 -0
  54. package/lib/api/endpoints/presence.js +33 -0
  55. package/lib/api/endpoints/prompts.js +69 -0
  56. package/lib/api/endpoints/qlarity.js +122 -0
  57. package/lib/api/endpoints/quoxbox.js +55 -0
  58. package/lib/api/endpoints/quoxchat.js +54 -0
  59. package/lib/api/endpoints/quoxflow.js +32 -0
  60. package/lib/api/endpoints/quoxplan.js +121 -0
  61. package/lib/api/endpoints/quoxseo.js +114 -0
  62. package/lib/api/endpoints/reach.js +47 -0
  63. package/lib/api/endpoints/router.js +17 -0
  64. package/lib/api/endpoints/runs.js +38 -0
  65. package/lib/api/endpoints/safety.js +53 -0
  66. package/lib/api/endpoints/scim.js +46 -0
  67. package/lib/api/endpoints/scopes.js +14 -0
  68. package/lib/api/endpoints/security.js +69 -0
  69. package/lib/api/endpoints/setup.js +90 -0
  70. package/lib/api/endpoints/skills.js +200 -0
  71. package/lib/api/endpoints/sso.js +67 -0
  72. package/lib/api/endpoints/stepup.js +20 -0
  73. package/lib/api/endpoints/tags.js +32 -0
  74. package/lib/api/endpoints/teams.js +56 -0
  75. package/lib/api/endpoints/telephony.js +46 -0
  76. package/lib/api/endpoints/tg.js +56 -0
  77. package/lib/api/endpoints/tools.js +50 -0
  78. package/lib/api/endpoints/traces.js +157 -0
  79. package/lib/api/endpoints/triggers.js +96 -0
  80. package/lib/api/endpoints/user-library.js +41 -0
  81. package/lib/api/endpoints/users.js +20 -0
  82. package/lib/api/endpoints/v1.js +585 -0
  83. package/lib/api/endpoints/vault.js +192 -0
  84. package/lib/api/endpoints/voice.js +22 -0
  85. package/lib/api/endpoints/volt.js +84 -0
  86. package/lib/api/endpoints/ward.js +47 -0
  87. package/lib/api/endpoints/webhooks.js +46 -0
  88. package/lib/api/endpoints/workflows.js +169 -0
  89. package/lib/api/endpoints/x.js +32 -0
  90. package/lib/api/endpoints.js +15 -0
  91. package/lib/commands/actions.js +61 -0
  92. package/lib/commands/admin.js +375 -0
  93. package/lib/commands/aee.js +99 -0
  94. package/lib/commands/agent.js +337 -0
  95. package/lib/commands/agentAttach.js +1528 -0
  96. package/lib/commands/approvals.js +119 -0
  97. package/lib/commands/aside.js +24 -0
  98. package/lib/commands/ask.js +144 -0
  99. package/lib/commands/assistant.js +169 -0
  100. package/lib/commands/auth.js +464 -0
  101. package/lib/commands/awareness.js +88 -0
  102. package/lib/commands/backup.js +377 -0
  103. package/lib/commands/brain.js +1085 -0
  104. package/lib/commands/budget.js +241 -0
  105. package/lib/commands/build.js +170 -0
  106. package/lib/commands/calendar.js +216 -0
  107. package/lib/commands/capabilities.js +34 -0
  108. package/lib/commands/chat.js +295 -0
  109. package/lib/commands/claude.js +380 -0
  110. package/lib/commands/config-cmd.js +106 -0
  111. package/lib/commands/consent.js +84 -0
  112. package/lib/commands/contacts.js +1016 -0
  113. package/lib/commands/contactsSelftest.js +132 -0
  114. package/lib/commands/conversation.js +105 -0
  115. package/lib/commands/dashboard.js +43 -0
  116. package/lib/commands/department.js +96 -0
  117. package/lib/commands/dev.js +620 -0
  118. package/lib/commands/devResident.js +326 -0
  119. package/lib/commands/devSupervisor.js +1062 -0
  120. package/lib/commands/discord.js +132 -0
  121. package/lib/commands/doctor.js +181 -0
  122. package/lib/commands/domain.js +114 -0
  123. package/lib/commands/done.js +347 -0
  124. package/lib/commands/engagement.js +597 -0
  125. package/lib/commands/entity.js +119 -0
  126. package/lib/commands/facebook.js +212 -0
  127. package/lib/commands/fetchAllowlist.js +71 -0
  128. package/lib/commands/file.js +165 -0
  129. package/lib/commands/fleet.js +1189 -0
  130. package/lib/commands/governance.js +647 -0
  131. package/lib/commands/gripe.js +355 -0
  132. package/lib/commands/hat.js +374 -0
  133. package/lib/commands/helpers.js +96 -0
  134. package/lib/commands/hyperion.js +58 -0
  135. package/lib/commands/inbox.js +173 -0
  136. package/lib/commands/instagram.js +264 -0
  137. package/lib/commands/integration.js +100 -0
  138. package/lib/commands/inventory.js +115 -0
  139. package/lib/commands/invites.js +45 -0
  140. package/lib/commands/licence.js +139 -0
  141. package/lib/commands/linkedin.js +178 -0
  142. package/lib/commands/llm-policy.js +117 -0
  143. package/lib/commands/llm.js +45 -0
  144. package/lib/commands/lucid.js +597 -0
  145. package/lib/commands/mail.js +531 -0
  146. package/lib/commands/memory.js +361 -0
  147. package/lib/commands/mind.js +71 -0
  148. package/lib/commands/mon.js +581 -0
  149. package/lib/commands/monitoring.js +86 -0
  150. package/lib/commands/motion.js +22 -0
  151. package/lib/commands/notes.js +1246 -0
  152. package/lib/commands/notesBatch.js +160 -0
  153. package/lib/commands/notesBridges.js +56 -0
  154. package/lib/commands/notesLinks.js +60 -0
  155. package/lib/commands/notification.js +57 -0
  156. package/lib/commands/nucleus.js +123 -0
  157. package/lib/commands/objective.js +240 -0
  158. package/lib/commands/offload.js +89 -0
  159. package/lib/commands/onboard.js +329 -0
  160. package/lib/commands/open.js +56 -0
  161. package/lib/commands/org.js +276 -0
  162. package/lib/commands/peek.js +28 -0
  163. package/lib/commands/plan.js +944 -0
  164. package/lib/commands/plugin-doctor.js +1091 -0
  165. package/lib/commands/plugin.js +649 -0
  166. package/lib/commands/policy.js +78 -0
  167. package/lib/commands/powers.js +296 -0
  168. package/lib/commands/presence.js +89 -0
  169. package/lib/commands/prompt.js +350 -0
  170. package/lib/commands/provider.js +159 -0
  171. package/lib/commands/qlarity.js +706 -0
  172. package/lib/commands/quoxbox.js +270 -0
  173. package/lib/commands/quoxchat.js +275 -0
  174. package/lib/commands/quoxseo.js +378 -0
  175. package/lib/commands/reach.js +159 -0
  176. package/lib/commands/role.js +286 -0
  177. package/lib/commands/router.js +77 -0
  178. package/lib/commands/rows.js +358 -0
  179. package/lib/commands/run.js +187 -0
  180. package/lib/commands/safety.js +107 -0
  181. package/lib/commands/scim.js +88 -0
  182. package/lib/commands/scope.js +58 -0
  183. package/lib/commands/security.js +157 -0
  184. package/lib/commands/server.js +292 -0
  185. package/lib/commands/service.js +89 -0
  186. package/lib/commands/settings.js +99 -0
  187. package/lib/commands/setup.js +569 -0
  188. package/lib/commands/skillManifest.js +113 -0
  189. package/lib/commands/skillRightsFormat.js +38 -0
  190. package/lib/commands/skills.js +687 -0
  191. package/lib/commands/slot.js +1451 -0
  192. package/lib/commands/sso.js +229 -0
  193. package/lib/commands/status.js +137 -0
  194. package/lib/commands/tag.js +91 -0
  195. package/lib/commands/team.js +142 -0
  196. package/lib/commands/tg.js +240 -0
  197. package/lib/commands/tiktok.js +218 -0
  198. package/lib/commands/tips.js +23 -0
  199. package/lib/commands/tool.js +304 -0
  200. package/lib/commands/trace.js +530 -0
  201. package/lib/commands/trigger.js +223 -0
  202. package/lib/commands/update.js +46 -0
  203. package/lib/commands/vault.js +1447 -0
  204. package/lib/commands/vm.js +237 -0
  205. package/lib/commands/voice.js +213 -0
  206. package/lib/commands/volt.js +570 -0
  207. package/lib/commands/walkthrough.js +32 -0
  208. package/lib/commands/watch.js +167 -0
  209. package/lib/commands/webhook.js +137 -0
  210. package/lib/commands/welcome.js +14 -0
  211. package/lib/commands/workflow.js +660 -0
  212. package/lib/commands/x.js +448 -0
  213. package/lib/core/actingAs.js +136 -0
  214. package/lib/core/activeSummary.js +186 -0
  215. package/lib/core/aee.js +53 -0
  216. package/lib/core/agentAlias.js +163 -0
  217. package/lib/core/approvalContext.js +61 -0
  218. package/lib/core/aside.js +54 -0
  219. package/lib/core/askDecision.js +60 -0
  220. package/lib/core/auth.js +501 -0
  221. package/lib/core/banner.js +143 -0
  222. package/lib/core/brainPrime.js +90 -0
  223. package/lib/core/callerSource.js +129 -0
  224. package/lib/core/claudeLs.js +94 -0
  225. package/lib/core/cliBuild.js +62 -0
  226. package/lib/core/client.js +693 -0
  227. package/lib/core/commandRegistrars.js +121 -0
  228. package/lib/core/completions.js +163 -0
  229. package/lib/core/config.js +669 -0
  230. package/lib/core/confirm.js +37 -0
  231. package/lib/core/customHats.js +168 -0
  232. package/lib/core/deeplinks.js +76 -0
  233. package/lib/core/doneEngineFetch.js +227 -0
  234. package/lib/core/errors.js +441 -0
  235. package/lib/core/global-flags.js +190 -0
  236. package/lib/core/governedSession.js +402 -0
  237. package/lib/core/hyperion.js +102 -0
  238. package/lib/core/inboxResolution.js +65 -0
  239. package/lib/core/lazyCommands.js +122 -0
  240. package/lib/core/liveSessionsReport.js +112 -0
  241. package/lib/core/offload.js +504 -0
  242. package/lib/core/org-override.js +52 -0
  243. package/lib/core/orgResolve.js +70 -0
  244. package/lib/core/output.js +239 -0
  245. package/lib/core/peek.js +126 -0
  246. package/lib/core/prodEnv.js +7 -0
  247. package/lib/core/profileUsers.js +51 -0
  248. package/lib/core/promptCancel.js +18 -0
  249. package/lib/core/quoxglyph.js +158 -0
  250. package/lib/core/repoPath.js +11 -0
  251. package/lib/core/residentAlarm.js +282 -0
  252. package/lib/core/residentAuth.js +243 -0
  253. package/lib/core/residentPump.js +229 -0
  254. package/lib/core/residentSeed.js +106 -0
  255. package/lib/core/rootOptions.js +39 -0
  256. package/lib/core/router.js +168 -0
  257. package/lib/core/selfUpdate.js +188 -0
  258. package/lib/core/sessionHeartbeat.js +192 -0
  259. package/lib/core/sessionsOverview.js +224 -0
  260. package/lib/core/slotKeeper.js +527 -0
  261. package/lib/core/slotWake.js +120 -0
  262. package/lib/core/supervisorEngine.js +933 -0
  263. package/lib/core/systemdInstaller.js +256 -0
  264. package/lib/core/version.js +87 -0
  265. package/lib/core/vm.js +361 -0
  266. package/lib/core/vmTui.js +86 -0
  267. package/lib/core/walkthrough.js +35 -0
  268. package/lib/hyperion/deck.js +29 -0
  269. package/lib/lucid/LucidCard.jsx +68 -0
  270. package/lib/lucid/LucidIntro.jsx +92 -0
  271. package/lib/lucid/blind.js +71 -0
  272. package/lib/lucid/cards.js +86 -0
  273. package/lib/lucid/deck.js +32 -0
  274. package/lib/lucid/fmt.js +139 -0
  275. package/lib/lucid/markdown.js +81 -0
  276. package/lib/lucid/motion.js +56 -0
  277. package/lib/lucid/sitrep.js +173 -0
  278. package/lib/plugins/loader.js +93 -0
  279. package/lib/quoxon/activity.js +116 -0
  280. package/lib/quoxon/aocl.js +117 -0
  281. package/lib/quoxon/api.js +952 -0
  282. package/lib/quoxon/app.js +4032 -0
  283. package/lib/quoxon/approvalFacts.js +94 -0
  284. package/lib/quoxon/approvalsView.js +325 -0
  285. package/lib/quoxon/asideView.js +40 -0
  286. package/lib/quoxon/ask.js +121 -0
  287. package/lib/quoxon/away.js +52 -0
  288. package/lib/quoxon/bang.js +224 -0
  289. package/lib/quoxon/bulkRisk.js +17 -0
  290. package/lib/quoxon/capabilities.js +82 -0
  291. package/lib/quoxon/cells.js +53 -0
  292. package/lib/quoxon/commandShort.js +112 -0
  293. package/lib/quoxon/commands.js +479 -0
  294. package/lib/quoxon/connect.js +654 -0
  295. package/lib/quoxon/connectCard.js +45 -0
  296. package/lib/quoxon/consent.js +86 -0
  297. package/lib/quoxon/context.js +68 -0
  298. package/lib/quoxon/copy.js +34 -0
  299. package/lib/quoxon/countdownLine.js +11 -0
  300. package/lib/quoxon/demo.autoplay.js +29 -0
  301. package/lib/quoxon/demo.fixtures.js +24 -0
  302. package/lib/quoxon/demo.js +76 -0
  303. package/lib/quoxon/devObserver.js +20 -0
  304. package/lib/quoxon/dividerAocl.js +191 -0
  305. package/lib/quoxon/dividerRule.js +57 -0
  306. package/lib/quoxon/dragonModes.js +150 -0
  307. package/lib/quoxon/dragonView.js +41 -0
  308. package/lib/quoxon/earlyInput.js +40 -0
  309. package/lib/quoxon/echo.js +15 -0
  310. package/lib/quoxon/egg.js +36 -0
  311. package/lib/quoxon/events.js +677 -0
  312. package/lib/quoxon/feedback.js +50 -0
  313. package/lib/quoxon/gallery.js +237 -0
  314. package/lib/quoxon/handoffOffer.js +78 -0
  315. package/lib/quoxon/hatSwitch.js +37 -0
  316. package/lib/quoxon/headless.js +161 -0
  317. package/lib/quoxon/hostUrls.js +41 -0
  318. package/lib/quoxon/indicator.js +197 -0
  319. package/lib/quoxon/intent.js +52 -0
  320. package/lib/quoxon/keys.js +164 -0
  321. package/lib/quoxon/launcher.js +103 -0
  322. package/lib/quoxon/main.js +176 -0
  323. package/lib/quoxon/markdown.js +341 -0
  324. package/lib/quoxon/markdownSettle.js +19 -0
  325. package/lib/quoxon/matrixDomains.json +46 -0
  326. package/lib/quoxon/mentions.js +90 -0
  327. package/lib/quoxon/mind.js +103 -0
  328. package/lib/quoxon/mode.js +46 -0
  329. package/lib/quoxon/model.js +311 -0
  330. package/lib/quoxon/modelPrefs.js +345 -0
  331. package/lib/quoxon/motion/clock.js +70 -0
  332. package/lib/quoxon/motion/color.js +35 -0
  333. package/lib/quoxon/motion/frame.js +14 -0
  334. package/lib/quoxon/motion/level.js +91 -0
  335. package/lib/quoxon/motion/reveal.js +54 -0
  336. package/lib/quoxon/motion/ring.js +79 -0
  337. package/lib/quoxon/mouse.js +211 -0
  338. package/lib/quoxon/noai.js +57 -0
  339. package/lib/quoxon/objects/agent.js +61 -0
  340. package/lib/quoxon/objects/away.js +72 -0
  341. package/lib/quoxon/objects/crystal.js +100 -0
  342. package/lib/quoxon/objects/fetch.js +182 -0
  343. package/lib/quoxon/objects/fleet.js +149 -0
  344. package/lib/quoxon/objects/health.js +186 -0
  345. package/lib/quoxon/objects/index.js +106 -0
  346. package/lib/quoxon/objects/ladder.js +71 -0
  347. package/lib/quoxon/objects/memory.js +44 -0
  348. package/lib/quoxon/objects/mirror.js +137 -0
  349. package/lib/quoxon/objects/model.js +118 -0
  350. package/lib/quoxon/objects/peek.js +17 -0
  351. package/lib/quoxon/objects/proof.js +110 -0
  352. package/lib/quoxon/objects/prove.js +130 -0
  353. package/lib/quoxon/objects/replay.js +125 -0
  354. package/lib/quoxon/objects/sitrep.js +58 -0
  355. package/lib/quoxon/objects/skill.js +105 -0
  356. package/lib/quoxon/objects/view.js +87 -0
  357. package/lib/quoxon/objects/workflow.js +119 -0
  358. package/lib/quoxon/observers.js +42 -0
  359. package/lib/quoxon/onboarding/copy.js +263 -0
  360. package/lib/quoxon/onboarding/credential.js +108 -0
  361. package/lib/quoxon/onboarding/detect.js +22 -0
  362. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  363. package/lib/quoxon/onboarding/failureCause.js +33 -0
  364. package/lib/quoxon/onboarding/finish.js +134 -0
  365. package/lib/quoxon/onboarding/index.js +94 -0
  366. package/lib/quoxon/onboarding/installRunner.js +284 -0
  367. package/lib/quoxon/onboarding/intent.js +53 -0
  368. package/lib/quoxon/onboarding/journey.js +73 -0
  369. package/lib/quoxon/onboarding/preflight.js +156 -0
  370. package/lib/quoxon/onboarding/probe.js +65 -0
  371. package/lib/quoxon/onboarding/runner.js +58 -0
  372. package/lib/quoxon/onboarding/view.js +682 -0
  373. package/lib/quoxon/orgLabel.js +10 -0
  374. package/lib/quoxon/orgSwitch.js +33 -0
  375. package/lib/quoxon/ownBroadcast.js +22 -0
  376. package/lib/quoxon/palette.js +43 -0
  377. package/lib/quoxon/print.js +98 -0
  378. package/lib/quoxon/profileSwitch.js +53 -0
  379. package/lib/quoxon/promptHistory.js +41 -0
  380. package/lib/quoxon/providers.js +48 -0
  381. package/lib/quoxon/qart.js +348 -0
  382. package/lib/quoxon/qdragon.js +410 -0
  383. package/lib/quoxon/qdragonFly.js +61 -0
  384. package/lib/quoxon/qdragonScenes.js +282 -0
  385. package/lib/quoxon/qeyes.js +213 -0
  386. package/lib/quoxon/qglyph.js +143 -0
  387. package/lib/quoxon/qhello.js +58 -0
  388. package/lib/quoxon/qmotion.js +132 -0
  389. package/lib/quoxon/render.js +1167 -0
  390. package/lib/quoxon/resend.js +72 -0
  391. package/lib/quoxon/roster.js +307 -0
  392. package/lib/quoxon/route.js +39 -0
  393. package/lib/quoxon/rows.js +711 -0
  394. package/lib/quoxon/rowsStore.js +80 -0
  395. package/lib/quoxon/rules.js +130 -0
  396. package/lib/quoxon/screenContext.js +150 -0
  397. package/lib/quoxon/screenFrames.js +254 -0
  398. package/lib/quoxon/session.js +238 -0
  399. package/lib/quoxon/sessionDead.js +17 -0
  400. package/lib/quoxon/signin.js +125 -0
  401. package/lib/quoxon/skills.js +30 -0
  402. package/lib/quoxon/slash/agents.js +112 -0
  403. package/lib/quoxon/slash/argSources.js +65 -0
  404. package/lib/quoxon/slash/args.js +65 -0
  405. package/lib/quoxon/slash/automate.js +89 -0
  406. package/lib/quoxon/slash/broadcast.js +140 -0
  407. package/lib/quoxon/slash/broadcastRun.js +108 -0
  408. package/lib/quoxon/slash/card.js +30 -0
  409. package/lib/quoxon/slash/challenge.js +114 -0
  410. package/lib/quoxon/slash/cliCommand.js +53 -0
  411. package/lib/quoxon/slash/cliLeaves.json +948 -0
  412. package/lib/quoxon/slash/compact.js +90 -0
  413. package/lib/quoxon/slash/confirm.js +30 -0
  414. package/lib/quoxon/slash/crystallize.js +69 -0
  415. package/lib/quoxon/slash/dashboard.js +136 -0
  416. package/lib/quoxon/slash/draft.js +54 -0
  417. package/lib/quoxon/slash/hatWalk.js +206 -0
  418. package/lib/quoxon/slash/inverses.js +77 -0
  419. package/lib/quoxon/slash/killer.js +24 -0
  420. package/lib/quoxon/slash/killerApi.js +16 -0
  421. package/lib/quoxon/slash/ledger.js +88 -0
  422. package/lib/quoxon/slash/listView.js +30 -0
  423. package/lib/quoxon/slash/modelCmd.js +261 -0
  424. package/lib/quoxon/slash/palette.js +118 -0
  425. package/lib/quoxon/slash/paletteController.js +169 -0
  426. package/lib/quoxon/slash/panels.js +116 -0
  427. package/lib/quoxon/slash/picker.js +56 -0
  428. package/lib/quoxon/slash/restart.js +202 -0
  429. package/lib/quoxon/slash/resume.js +59 -0
  430. package/lib/quoxon/slash/rowCmd.js +157 -0
  431. package/lib/quoxon/slash/rowWalk.js +175 -0
  432. package/lib/quoxon/slash/settings.js +60 -0
  433. package/lib/quoxon/slash/spawn.js +177 -0
  434. package/lib/quoxon/slash/stepUp.js +274 -0
  435. package/lib/quoxon/slash/stepUpCard.js +60 -0
  436. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  437. package/lib/quoxon/slash/undoApi.js +38 -0
  438. package/lib/quoxon/slash/undoCmd.js +49 -0
  439. package/lib/quoxon/slash/undoWish.js +15 -0
  440. package/lib/quoxon/slash/watch.js +164 -0
  441. package/lib/quoxon/slash/wish.js +23 -0
  442. package/lib/quoxon/slash/wishCmd.js +32 -0
  443. package/lib/quoxon/slotChat.js +300 -0
  444. package/lib/quoxon/slotFormat.js +195 -0
  445. package/lib/quoxon/slotRun.js +131 -0
  446. package/lib/quoxon/sound/index.js +186 -0
  447. package/lib/quoxon/sound/mapping.js +120 -0
  448. package/lib/quoxon/sound/output.js +52 -0
  449. package/lib/quoxon/sound/synth.js +122 -0
  450. package/lib/quoxon/stray.js +22 -0
  451. package/lib/quoxon/suggestCommand.js +59 -0
  452. package/lib/quoxon/term.js +141 -0
  453. package/lib/quoxon/textBuffer.js +416 -0
  454. package/lib/quoxon/theme.js +286 -0
  455. package/lib/quoxon/tips.js +433 -0
  456. package/lib/quoxon/toolError.js +41 -0
  457. package/lib/quoxon/transcript.js +188 -0
  458. package/lib/quoxon/web/qface.js +89 -0
  459. package/lib/quoxon/welcome.js +52 -0
  460. package/lib/quoxon/whatsnew.js +136 -0
  461. package/lib/quoxon/whatsnew.jsonl +89 -0
  462. package/lib/quoxon/windowOrg.js +113 -0
  463. package/lib/server/diag.js +129 -0
  464. package/lib/server/doctor.js +71 -0
  465. package/lib/server/install.js +149 -0
  466. package/lib/server/pluginEnable.js +161 -0
  467. package/lib/server/skew.js +56 -0
  468. package/lib/server/snapshot.js +131 -0
  469. package/lib/server/status.js +128 -0
  470. package/lib/server/sys.js +65 -0
  471. package/lib/server/uninstall.js +63 -0
  472. package/lib/server/update.js +234 -0
  473. package/lib/toolOutputShape.js +315 -0
  474. package/lib/tui/app.jsx +289 -0
  475. package/lib/tui/components/Breadcrumb.jsx +42 -0
  476. package/lib/tui/components/ChatInput.jsx +68 -0
  477. package/lib/tui/components/Confirm.jsx +32 -0
  478. package/lib/tui/components/DataTable.jsx +270 -0
  479. package/lib/tui/components/DetailPanel.jsx +241 -0
  480. package/lib/tui/components/HelpOverlay.jsx +67 -0
  481. package/lib/tui/components/Layout.jsx +129 -0
  482. package/lib/tui/components/Palette.jsx +141 -0
  483. package/lib/tui/components/SearchInput.jsx +125 -0
  484. package/lib/tui/components/Sparkline.jsx +55 -0
  485. package/lib/tui/components/StatusBar.jsx +133 -0
  486. package/lib/tui/components/Toast.jsx +52 -0
  487. package/lib/tui/hooks/useApi.js +44 -0
  488. package/lib/tui/hooks/useHealth.js +90 -0
  489. package/lib/tui/hooks/usePolledApi.js +86 -0
  490. package/lib/tui/hooks/useTerminalSize.js +46 -0
  491. package/lib/tui/index.js +56 -0
  492. package/lib/tui/intro/Intro.jsx +332 -0
  493. package/lib/tui/jsx-loader.js +32 -0
  494. package/lib/tui/keys.js +87 -0
  495. package/lib/tui/registry.js +49 -0
  496. package/lib/tui/theme.js +103 -0
  497. package/lib/tui/views/AdminView.jsx +398 -0
  498. package/lib/tui/views/AgentView.jsx +204 -0
  499. package/lib/tui/views/BackupView.jsx +225 -0
  500. package/lib/tui/views/BrainView.jsx +223 -0
  501. package/lib/tui/views/ChatView.jsx +468 -0
  502. package/lib/tui/views/FilesView.jsx +218 -0
  503. package/lib/tui/views/FleetView.jsx +234 -0
  504. package/lib/tui/views/GovernanceView.jsx +453 -0
  505. package/lib/tui/views/HealthView.jsx +216 -0
  506. package/lib/tui/views/HyperionDeck.jsx +79 -0
  507. package/lib/tui/views/InboxView.jsx +228 -0
  508. package/lib/tui/views/LucidDeck.jsx +719 -0
  509. package/lib/tui/views/MainMenu.jsx +123 -0
  510. package/lib/tui/views/MemoryView.jsx +377 -0
  511. package/lib/tui/views/ObjectivesView.jsx +230 -0
  512. package/lib/tui/views/OrgView.jsx +229 -0
  513. package/lib/tui/views/ToolView.jsx +221 -0
  514. package/lib/tui/views/VaultView.jsx +422 -0
  515. package/lib/tui/views/WorkflowView.jsx +329 -0
  516. package/package.json +75 -4
@@ -0,0 +1,4032 @@
1
+ /**
2
+ * QUOXON app: composer + live turn. State lives in refs updated synchronously
3
+ * (so a stdin chunk carrying several keys applies in order) and mirrored into
4
+ * React state for rendering.
5
+ */
6
+ import React from 'react';
7
+ import { Box, Static, Text, measureElement, useApp, useStdin, useStdout } from 'ink';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { mkdtempSync, writeFileSync, writeSync, readFileSync, unlinkSync } from 'node:fs';
11
+ import { tmpdir, hostname as osHostname } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { bufferReducer, initialBuffer, sanitizeForSend, wordAt, selection, selectedText, atVisualEdge } from './textBuffer.js';
14
+ import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, screenHit, classifyClick, composerIndexAt } from './mouse.js';
15
+ import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
16
+ import { isHeldFromBulk, heldReason } from './bulkRisk.js';
17
+ import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, parseAnswer, parsePairs } from './approvalsView.js';
18
+ import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
19
+ import { reduce, initialState, approvalKeyAllowed, groupApprovals, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
20
+ import { turnTokens } from './indicator.js';
21
+ import {
22
+ Composer, RingComposer, Pending, ApprovalCard, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
23
+ , endsInRule } from './render.js';
24
+ import { c, mono } from './theme.js';
25
+ import { strayCardAnswer, strayQuestion, alreadyQueued } from './stray.js';
26
+ import { findEntry, helpModel, nearestCommands, joinWrappedSlash, commandNames, REGISTRY, visibleRegistry } from './commands.js';
27
+ import { osc52, pickCopy } from './copy.js';
28
+ import { detectSlotAgent } from './context.js';
29
+ import { rulesContext, rulesLabel, rulesLines } from './rules.js';
30
+ import { resolveCliSlash } from './slash/cliCommand.js';
31
+ import { parseBang, quoxArgv, needsTerminal, ttyFailure, runBang, footer as bangFooter, truncNote as bangTruncNote, shareText, withShared, cleanOutput, DEFAULT_TIMEOUT_MS } from './bang.js';
32
+ import { startupWhatsNew, listWhatsNew, unseenEntries, seenRecord, tryText, LIST_DEFAULT, unseenCommands, seenAfter, startupCommandLine, startupEnabled, toggleArg } from './whatsnew.js';
33
+ import { nextTip, normaliseMode, tipSettingsReply, tipsReply, DEFAULT_MODE, TIPS, tryCommand, tipText } from './tips.js';
34
+ import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
35
+ import { createScreenContext } from './screenContext.js';
36
+ import { askText, askQuestion, askAnswer, askChatMessage, settledAskLine, settledAskTone, askOutcomeOf, isAskClarification, askClock } from './ask.js';
37
+ import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
38
+ import * as selfUpdate from '../core/selfUpdate.js';
39
+ import { runRequest, runAnswer, handOverRun, runFooter } from './slotRun.js';
40
+ import { profileChooserRows, liveApi, switchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
41
+ import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
42
+ import { runObjectCommand } from './objects/index.js';
43
+ import { peekLines as peekIdLines } from '../core/peek.js';
44
+ import { turnsFromItems, asideNoteText } from './asideView.js';
45
+ import { createMentionCache, loadMentions, receiptMentions } from './mentions.js';
46
+ const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
47
+ import { intentHint } from './intent.js';
48
+ import { observerLines } from './observers.js';
49
+ import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
50
+ import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
51
+ import { packageVersion } from './welcome.js';
52
+ import { feedbackPayload, feedbackPreview } from './feedback.js';
53
+ import { vetSuggestion, namedCommand } from './suggestCommand.js';
54
+ import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // PLAN MODE (owner 2026-10-16)
55
+ import { echoLine } from './echo.js';
56
+ import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
57
+ import { pushHistory } from './promptHistory.js';
58
+ import { termRows } from './term.js';
59
+ import { cardTallerThanScreen } from './objects/view.js';
60
+ import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows, confirmLanAllow } from './connect.js';
61
+ import { ConnectCard } from './connectCard.js';
62
+ import { cmd as mark } from './palette.js';
63
+ import { createLoginController, LoginCard, SIGNED_OUT_LINE, signedOutCard, isSessionDead } from './signin.js';
64
+ import { createStepUp } from './slash/stepUp.js';
65
+ import { StepUpCard, PresentBar } from './slash/stepUpCard.js';
66
+ import { createStepUpCmds, declinedText } from './slash/stepUpCmds.js';
67
+ import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
68
+ import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
69
+ import { humanSessionLines } from '../core/sessionsOverview.js';
70
+ import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
71
+ import { capabilityLines, offerFor, findCapability } from './capabilities.js';
72
+ import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
73
+ import { DragonLive, playDragon, dragonActive, resetDragon } from './qmotion.js';
74
+ import { AUTOPLAY, AUTOPLAY_REQUEST, AUTOPLAY_PROOF, typingPlan } from './demo.autoplay.js';
75
+ import { motionLevel, levelFeatures, moodForEvent, ignitionGlow, normaliseLevel } from './motion/level.js';
76
+ import { CLIPS as DRAGON_CLIPS } from './qdragonScenes.js';
77
+ import { DRAGON_USAGE, DRAGON_EXPLAINS, DRAGON_ANSWERS, NOTHING_TO_EXPLAIN, ASK_PROMPT, WAKE_MS, explainPrompt, askPrompt, lastOutput, parseDragonArg, sourceLines } from './dragonModes.js';
78
+ import { SleepScreen, DragonTalk, FlyLive } from './dragonView.js';
79
+ import { FLY_PASS_MS, FLY_STILL_MS } from './qdragonFly.js';
80
+ import { createSound } from './sound/index.js';
81
+ import { createPalette } from './slash/paletteController.js';
82
+ import { capabilityMap } from './slash/palette.js';
83
+ import { createArgSources } from './slash/argSources.js';
84
+ import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
85
+ import { parseBroadcastText } from './slash/broadcast.js';
86
+ import { isOwnBroadcast } from './ownBroadcast.js';
87
+ import { orgLabel, isOrgId } from './orgLabel.js';
88
+ import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine } from './slotChat.js';
89
+ import { homedir as osHome } from 'node:os';
90
+ import { createRowCmd } from './slash/rowCmd.js';
91
+ import { createKillerCmds } from './slash/killer.js';
92
+ import { createUndoWish } from './slash/undoWish.js';
93
+ import { createHatWalk } from './slash/hatWalk.js';
94
+ import { createRowEngine, runShell } from './rows.js';
95
+ import { createModelCmd } from './slash/modelCmd.js';
96
+ import { createChallengeCmd } from './slash/challenge.js';
97
+ import { createSpawnCmd } from './slash/spawn.js';
98
+ import { normPrefs, parseModelSub, label as favLabel } from './modelPrefs.js';
99
+ import { ListView } from './slash/listView.js';
100
+ import { createLedger, costModel } from './slash/ledger.js';
101
+ import { statusModel, contextModel, doctorModel, chatChecks, modelText } from './slash/panels.js';
102
+ import { parseAgentsArgs, filterAgents, agentPickerRow, toolLines, whichReply, looseCommand } from './slash/agents.js';
103
+ import { createPicker } from './slash/picker.js';
104
+ import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
105
+ import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
106
+ import { countdownLine } from './countdownLine.js';
107
+ import { startBuildWatch, DIFFERENT_NOTICE } from './slash/restart.js';
108
+ import { dashboardLines } from './slash/dashboard.js';
109
+ import { supportsHyperlinks } from './markdown.js';
110
+ import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
111
+ import { intentPanel } from './intent.js';
112
+ import { awayDue, parseSince } from './away.js';
113
+ import { sitrepData } from './objects/sitrep.js';
114
+ import { mirrorErrorNote } from './objects/mirror.js';
115
+ import { launchEgg } from './egg.js';
116
+ import { INTRO_MS } from './qart.js';
117
+ import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motion/clock.js';
118
+ import { FrameContext, useFrame } from './motion/frame.js';
119
+ import { ringMode, ringAnimating } from './motion/ring.js';
120
+ import { TIMING, revealSpan } from './motion/reveal.js';
121
+ import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
122
+ import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
123
+ import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
124
+
125
+ const h = React.createElement;
126
+ const PASTE_GUARD_MS = 40;
127
+ const ASK_ECHO_MS = 1500; // TUI-INPUT: how long an Enter after a single-key ask answer is treated as the confirm habit
128
+ const MAX_ROSTER = 4;
129
+ const ESC_ESC_MS = 500;
130
+ const INTERRUPT_FALLBACK_MS = 8000;
131
+ const INTRO_FRAME_MS = 50; // intro frame period on the shared clock (the plan's length is INTRO_MS)
132
+ const HELD_WINDOW_MS = 1000; // a key seen this close before a card appears is treated as still held
133
+ const APPROVE_WORD = 'approve';
134
+ const CARD_GONE_MS = 400;
135
+
136
+
137
+ const STEER_OFF_NOTE = "This model can't take steering mid-turn; queued for next turn.";
138
+ const newTurnId = () => `QXN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
139
+
140
+ function openEditor(text) {
141
+ const dir = mkdtempSync(join(tmpdir(), 'quoxon-'));
142
+ const file = join(dir, 'prompt.md');
143
+ writeFileSync(file, text, 'utf8');
144
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
145
+ const r = spawnSync(editor, [file], { stdio: 'inherit' });
146
+ let out = null;
147
+ if (!r.error) out = readFileSync(file, 'utf8').replace(/\n+$/, '');
148
+ try { unlinkSync(file); } catch { /* best-effort */ }
149
+ return out;
150
+ }
151
+
152
+ export const friendlyError = (e, profile) =>
153
+ (e?.status === 401 || /valid jwt|service key|unauthor/i.test(e?.message || ''))
154
+ ? `Not signed in for chat on profile ${profile}. Run: quox login -p ${profile}` : (e?.message || String(e));
155
+
156
+ const byId = (id) => CHOICES.find(x => x.id === id) || null;
157
+ // the status line shows the new ask until the server answers
158
+ const NO_RAN = { provider: null, model: null, endpoint: null };
159
+
160
+ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api: apiProp, profile: profileProp = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked: blockedProp = null, signedOut: signedOutProp = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, slotsAvailable = true, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, restartSlot = null, restartAttach = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, autoTickMs = 1000, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
161
+ // Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
162
+ // one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
163
+ // PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
164
+ // profile this window switched to with a y or /profile (null = as launched). Same conversation, attach, slot, groups.
165
+ const APX = React.useRef(null);
166
+ if (!APX.current || APX.current.from !== apiProp) APX.current = { live: apiProp, from: apiProp };
167
+ const APIP = React.useRef(null); APIP.current ??= liveApi(APX); const api = APIP.current;
168
+ const [who, setWho] = React.useState(null);
169
+ const profile = who?.profile ?? profileProp;
170
+ const agentLogin = who ? who.agentLogin : agentLoginProp;
171
+ const WHO = React.useRef(null); WHO.current = { profile, agentLogin };
172
+ const [signedOut, setSignedOut] = React.useState(signedOutProp);
173
+ const blocked = blockedProp || (signedOut ? SIGNED_OUT_LINE : null);
174
+ const signedOutRef = React.useRef(null); signedOutRef.current = signedOut;
175
+ const BL = React.useRef(null); BL.current = blocked; // effects/timers created at mount read the live value
176
+ const { exit: inkExit } = useApp();
177
+ const { stdin, setRawMode, isRawModeSupported } = useStdin();
178
+ const { stdout } = useStdout();
179
+ const [cols, setCols] = React.useState(clampCols(columns ?? ((stdout?.columns || 80) - 1)));
180
+ const [, bump] = React.useState(0);
181
+ // Every exit path goes through here: settle all in-flight katakana (Working word, drop-in reveals, sleep screen)
182
+ // to plain text, let that frame paint, then unmount. Ink leaves its last frame in scrollback, so it must be plain.
183
+ const exit = (afterMs = 0) => {
184
+ const R0 = R.current;
185
+ if (R0.exiting) return undefined;
186
+ R0.exiting = true; R0.sleep = null; R0.fly = null;
187
+ R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
188
+ resetDragon();
189
+ bump(n => n + 1);
190
+ return setTimeout(() => inkExit(), 60 + afterMs);
191
+ };
192
+ const [epoch, setEpoch] = React.useState(0);
193
+
194
+ const introItem = { id: 'intro', kind: 'intro', text: `${version} · ${profile} · ${cwd}`, cols: clampCols(columns ?? ((process.stdout.columns || 80) - 1)) };
195
+ const introHistory = () => [introItem,
196
+ ...(welcome ? [{ id: 'welcome', kind: 'welcome', version: packageVersion() }] : []),
197
+ ...(demoLabel ? [{ id: 'demo', kind: 'note', text: demoLabel, tone: 'warn' }] : []),
198
+ ...(signedOutProp ? [{ id: 'signedOut', kind: 'note', text: signedOutCard(profile, signedOutProp.reason), tone: 'error' }] : []),
199
+ ...(introNote ? [{ id: 'introNote', kind: 'note', text: introNote, tone: 'warn' }] : []),
200
+ ...(invite ? [{ id: 'invite', kind: 'note', text: inviteText({ slots: slotsAvailable }) }] : [])];
201
+ // The animated intro lives in the live region (no flicker, nothing printed twice) and moves into
202
+ // <Static> history once settled. Any keypress skips it; the composer is usable throughout.
203
+ const introAnimating = animateIntro ?? introAnimates(stdout?.isTTY);
204
+ // Motion (decorative animation) is decided once: a real terminal and no QUOX_REDUCED_MOTION / LITE. Colour
205
+ // is a separate setting (NO_COLOR); one never implies the other.
206
+ const motionOn = motion ?? motionAllowed(stdout?.isTTY);
207
+ const isInteractive = interactive ?? Boolean(stdout?.isTTY); // /dragon sleep takes over the screen: a real terminal only, never piped or headless
208
+ // Motion level (motion/level.js): read live, so a /config change applies at once. calm = everything above.
209
+ const feats = () => levelFeatures(motionLevel(env));
210
+ const introStart = React.useRef(Date.now());
211
+ // Cinematic: Orbit & Lock on every launch, then the dragon greets you (open, look, wink). Launch egg: rarely the same
212
+ // dragon greeting on other levels (egg.js decides once per launch; never reduced motion).
213
+ const introDragon = React.useMemo(() => introAnimating && !mono() && (feats().introDragon || (animateIntro === undefined && feats().launchEgg && launchEgg({ tty: Boolean(stdout?.isTTY) }))), []);
214
+ const introMs = INTRO_MS + (introDragon ? DRAGON_CLIPS.greeting.dur + 300 : 0);
215
+ const introDone = React.useRef(!introAnimating);
216
+ const finishIntro = () => {
217
+ if (introDone.current) return;
218
+ introDone.current = true;
219
+ const intro = introHistory();
220
+ S.current = { ...S.current, history: [...intro, ...S.current.history] };
221
+ committedRef.current = intro.length; // the intro itself is already on screen; later items may still reveal
222
+ bump(n => n + 1);
223
+ };
224
+ const finishIntroRef = React.useRef(finishIntro);
225
+ finishIntroRef.current = finishIntro;
226
+
227
+ const S = React.useRef(initialState({
228
+ verbose,
229
+ history: introAnimating ? [] : introHistory()
230
+ }));
231
+ const B = React.useRef(initialBuffer());
232
+ // Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
233
+ // they render in the live region. committedRef = how many history items <Static> has been given.
234
+ const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
235
+ const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
236
+ const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
237
+ const K = React.useRef({ keys: initialKeyState(), lastKeyAt: null, lastKeyCh: null, lastDataAt: 0, lastPasteAt: 0, lastEscAt: 0, ctrlC: 0, guard: null, dismissed: new Set(), cardFocus: false, confirm: false, cardGoneAt: 0, typed: '', danglingTimer: null });
238
+ const chromeRef = React.useRef(null);
239
+ const chromeH = React.useRef(null);
240
+ const R = React.useRef({ abort: null, histIdx: -1, history: [], stash: [], reviewing: false, fallbackTimer: null, ctrlCTimer: null, banner: null, closed: false, pollTimers: new Set(), caps: { steer: true, interrupt: true }, confirmMulti: null, roster: { rows: [], all: false, allRows: [], alerts: [], sources: null, on: true, focus: false, sel: 0, peek: false, confirm: null, stopping: false } });
241
+ // QUOXON-HISTORY: Up recalls what was typed in earlier runs too (most recent first); main.js passes the file store.
242
+ if (!R.current.histLoaded) { R.current.histLoaded = true; try { R.current.history = historyStore?.load?.() || []; } catch { R.current.history = []; } }
243
+ const remember = (t) => { if (pushHistory(R.current.history, t)) { try { historyStore?.add?.(t); } catch { /* history is a convenience */ } } };
244
+ // E5: when this profile was last seen (read once at launch, before it is refreshed), and a throttled touch.
245
+ const AWAY = React.useRef({ last: null, touched: 0 });
246
+ const touchSeen = (force = false) => {
247
+ const now = Date.now();
248
+ if (!away || (!force && now - AWAY.current.touched < 60000)) return;
249
+ AWAY.current.touched = now;
250
+ try { away.write(profile, now); } catch { /* best effort */ }
251
+ };
252
+
253
+ const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
254
+ /** QUOXON-FW15: the one card shown: the oldest waiting approval (this conversation first, then other sessions/plugins) not dismissed with Tab. */
255
+ const queueOf = (st) => { const all = [...st.approvals, ...st.waiting]; const live = new Set(all.map(x => x.callId)); for (const id of K.current.dismissed) if (!live.has(id)) K.current.dismissed.delete(id); return all.filter(x => !K.current.dismissed.has(x.callId)); };
256
+ const headCard = (st) => queueOf(st)[0] || null;
257
+ const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
258
+ const dispatch = (a) => {
259
+ if (a.type === 'note') SCX.current.notice(a.text, a.tone);
260
+ else if (a.type === 'error') SCX.current.error(a.text);
261
+ else if (a.type === 'event' && a.ev) {
262
+ if (a.ev.type === 'error') SCX.current.error(a.ev.error || a.ev.message || 'error');
263
+ else if (a.ev.type === 'turn.finished' && a.ev.status && !['ok', 'success', 'completed'].includes(a.ev.status)) SCX.current.failedTurn(a.ev.status);
264
+ }
265
+ transcript?.action(a.redact !== undefined ? { ...a, text: a.redact } : a); // dev transcript capture (off by default; see transcript.js); `redact` is what the capture sees instead of a secret-bearing text
266
+ const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); SND.current.observe(prevS, S.current, a);
267
+ if (motionOn && !mono() && feats().moods) { const m = moodForEvent(prevS, a, S.current); if (m) playDragon(m); } // cinematic: the dragon reacts
268
+ // Per shown card: remember the key that was already down when it appeared, and restart the typed word.
269
+ const c0 = headCard(S.current);
270
+ if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
271
+ const gid = c0 ? `${c0.callId}:${c0.displayedAt}` : null;
272
+ if (gid !== K.current.guard?.id) {
273
+ const k = K.current;
274
+ const held = c0 && k.lastKeyAt != null && Date.now() - k.lastKeyAt < HELD_WINDOW_MS ? k.lastKeyCh : null;
275
+ k.guard = gid ? { id: gid, heldKey: held, since: Date.now() } : null;
276
+ k.confirm = false; k.cardFocus = false;
277
+ }
278
+ if (!R.current.closed) bump(n => n + 1);
279
+ };
280
+ const edit = (a) => { B.current = bufferReducer(B.current, a); if (!B.current.text) { R.current.multiWarned = false; R.current.confirmMulti = null; } bump(n => n + 1); };
281
+ // ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): an ask is a LIVE card
282
+ // (never printed to scrollback while open); reconcileAsks settles it to one line once it is no longer open.
283
+ const askSeq = React.useRef(0);
284
+ const say = (text, tone) => {
285
+ if (tone !== 'ask') return dispatch({ type: 'note', text, tone });
286
+ const id = `ask-${++askSeq.current}`;
287
+ R.current.lastAsk = { text, question: askQuestion(text), id };
288
+ K.current.composerClicked = false; // TUI-INPUT: a new card is the active prompt until the composer is clicked again
289
+ dispatch({ type: 'ask.open', id, text, at: Date.now() });
290
+ if (typeof queueMicrotask === 'function') queueMicrotask(() => reconcileAsks()); // bind it to its open state
291
+ return undefined;
292
+ };
293
+ // QUOXON-ASK: every question for the human is ONE shape (▶ reason. question? keys), highlighted. Never say a raw y/N.
294
+ const ask = (question, opts) => say(askText(question, opts), 'ask');
295
+ // PLAN MODE: this window's mode ('ask' | 'plan'). Window-scoped: rides /restart, never the shared profile.
296
+ const MD = React.useRef(null); MD.current ??= normMode(mode);
297
+ const askerName = () => String(SN.current?.agent || where.current?.agent || 'Quox').toUpperCase(); // a model-suggested command: who asks
298
+ /** Z2H-3c: add-a-host lines, after probing that the server can actually serve its installer (null = unknown). */
299
+ const hostLines = async () => {
300
+ const base = await (api.hostsBaseUrl?.() ?? api.serverUrl?.());
301
+ let check = null; try { check = base && api.hostInstallCheck ? await api.hostInstallCheck(base) : null; } catch { check = null; }
302
+ return addHostLines(base, check);
303
+ };
304
+ // /model: the --provider flag wins for this run ('auto' = ignore the saved choice), else the profile's saved choice.
305
+ const MC = React.useRef(modelChoice === 'auto' ? null : normChoice(modelChoice) || (api.modelChoice?.() ?? null));
306
+ // /connect card: owns its own secret box; the key never enters dispatch, the buffer or history.
307
+ const CN = React.useRef(null);
308
+ CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
309
+ // /login card: email (prefilled), masked password, MFA code if asked. Same login path as `quox login`.
310
+ const markSignedOut = (reason = null) => { if (BL.current) return; setSignedOut({ reason }); say(signedOutCard(profile, reason), 'error'); };
311
+ const LI = React.useRef(null);
312
+ LI.current ??= createLoginController({ api, note: (t, tone) => say(t, tone), refresh: () => bump(n => n + 1), onSignedIn: () => {
313
+ setSignedOut(null); BL.current = blockedProp || null;
314
+ R.current.notify = { fails: 0, authLost: false };
315
+ dispatch({ type: 'notify', patch: { error: null } });
316
+ refreshNotify(); refreshRoster();
317
+ if (away && api.away) { const last = AWAY.current.last; if (awayDue(last)) api.away(new Date(last).toISOString()).then((d) => dispatch({ type: 'object', obj: { type: 'away', data: { ...d, compact: true } } }), () => {}); }
318
+ } });
319
+ // QUOXON-STEPUP: the PIN card. The digits live in its closure box only: never dispatched, buffered, in history,
320
+ // undo, the transcript or any log. Agent logins never get a PIN field (slash/stepUp.js).
321
+ const SU = React.useRef(null);
322
+ SU.current ??= createStepUp({ api, agentLogin: () => WHO.current.agentLogin, onAgentLogin: () => offerSwitch(), refresh: () => bump(n => n + 1), note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), settingsUrl: async () => { try { const b = await api.dashboardBase?.(); return b ? `${String(b).replace(/\/$/, '')}/settings` : null; } catch { return null; } } });
323
+ const SUC = React.useRef(null);
324
+ SUC.current ??= createStepUpCmds({ api, stepUp: SU.current, say: (t, tone) => say(t, tone), lastBlock: () => R.current.lastBlock || null, orgOf: () => api.orgId?.() || null });
325
+ // L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
326
+ const PL = React.useRef(null);
327
+ // QUOXON-FW6: model favourites + default (server-side user preferences; profile = cache).
328
+ const MP = React.useRef(null);
329
+ MP.current ??= { prefs: normPrefs(api.localModelPrefs?.()), touched: false, warned: false };
330
+ const PK = React.useRef(null); // /resume, /config, /model
331
+ PK.current ??= createPicker({ refresh: () => bump(n => n + 1) });
332
+ const MM = React.useRef(null);
333
+ MM.current ??= createModelCmd({
334
+ api, say: (t, tone) => say(t, tone), command: (l) => command(l), mc: MC, mp: MP,
335
+ picker: PK.current, state: S, bump: () => bump(n => n + 1), cols: () => cols
336
+ });
337
+ const KC = React.useRef(null); KC.current ??= createKillerCmds({ api, say: (t, tone) => say(t, tone), getState: () => S.current, getFields: () => streamFields(MC.current) }); // /watch, /automate, /crystallize
338
+ // /undo last and /wish (slash/undoWish.js): one card, y/n on the next composer line.
339
+ const HW = React.useRef(null); // CUSTOM-HATS: /hat new|edit|delete walk
340
+ HW.current ??= createHatWalk({ api, say: (t, tone) => say(t, tone), bump: () => bump(n => n + 1) });
341
+ const UW = React.useRef(null);
342
+ UW.current ??= createUndoWish({
343
+ api, say: (t, tone) => say(t, tone), getItems: () => [...S.current.history, ...S.current.blocks],
344
+ runTurn: (t) => runTurn(t),
345
+ draft: async (prompt) => { // the spec is drafted by a normal governed turn; its answer is read back
346
+ if (S.current.turn) throw new Error('wait for the current turn or press Esc first');
347
+ const before = (S.current.answers || []).slice(-1)[0]?.id ?? null;
348
+ await runTurn(prompt);
349
+ const last = (S.current.answers || []).slice(-1)[0];
350
+ return last && last.id !== before ? last.text : null;
351
+ }
352
+ });
353
+ const CH = React.useRef(null);
354
+ CH.current ??= createChallengeCmd({ api, say: (t, tone) => say(t, tone), getState: () => S.current, sendTurn: (t) => submit(t) });
355
+ const SPN = React.useRef(null);
356
+ SPN.current ??= createSpawnCmd({ api, say: (t, tone) => say(t, tone), session: () => SN.current.name || S.current.ctx.conversationId || 'quox', from: () => senderLabel({ slot: where.current.slot || null, display: myDisplay() }), statePath: spawnStatePath });
357
+ React.useEffect(() => { if (!blocked && !demoLabel && api.slotSay) SPN.current.sweep().catch(() => {}); }, []); // release this session's overdue VMs
358
+ // QUOXON-FW17: custom rows above the status line (rows.js engine, slash/rowCmd.js).
359
+ const RW = React.useRef(null);
360
+ RW.current ??= createRowCmd({
361
+ api, say: (t, tone) => say(t, tone), bump: () => bump(n => n + 1), cols: () => cols,
362
+ engine: createRowEngine({ fetchUrl: (u) => (api.rowFetch ? api.rowFetch(u) : Promise.reject(new Error('not available'))), fetchQuox: (src) => (api.rowFetchQuox ? api.rowFetchQuox(src) : Promise.reject(new Error('not available'))), runCmd: runShell, onChange: () => bump(n => n + 1) })
363
+ });
364
+ // Rows load once the window is usable: a window started signed out loads them after /login (owner lost the QPE bar 2026-10-15).
365
+ const rowsLoaded = React.useRef(false);
366
+ React.useEffect(() => { if (!blocked && !demoLabel && !rowsLoaded.current) { rowsLoaded.current = true; RW.current.load(); } }, [blocked]);
367
+ React.useEffect(() => () => RW.current.stopEngine(), []);
368
+ PL.current ??= createPalette({
369
+ getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
370
+ run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
371
+ ctx: () => ({ api, caps: R.current.contract, blocked: BL.current }),
372
+ source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all() }),
373
+ refresh: () => bump(n => n + 1),
374
+ // MENTIONS: agents, hosts and tasks fetched once per session on the first @; receipts are this session's own tool envelopes
375
+ mentions: blocked ? null : { cache: createMentionCache({ load: () => loadMentions(api), refresh: () => bump(n => n + 1) }), local: () => receiptMentions([...S.current.history, ...S.current.blocks]) }
376
+ });
377
+ React.useEffect(() => MM.current.startOnDefault({
378
+ flagged: modelChoice != null, off: Boolean(blocked || demoLabel),
379
+ dispatchCtx: () => dispatch({ type: 'ctx', ctx: NO_RAN })
380
+ }), []);
381
+ const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
382
+ // /restart auto: saved per profile (profile.quoxon.restartAuto). `newId` = the newest installed build seen; `cancelledId` = the build whose countdown a key cancelled.
383
+ const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0 };
384
+ // Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
385
+ const countdownMs = () => { if (autoRestartMs != null) return autoRestartMs; const n = Number(env.QUOX_RESTART_COUNTDOWN); return (env.QUOX_RESTART_COUNTDOWN != null && env.QUOX_RESTART_COUNTDOWN !== '' && Number.isFinite(n) && n >= 0 ? n : 10) * 1000; };
386
+ const cdLog = (phase, seconds = null) => transcript?.action({ type: 'restart.countdown', phase, seconds, build: AR.current.newId }); // lifecycle in the dev transcript
387
+ const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; };
388
+ React.useEffect(() => (startSha ? buildWatch({ startSha, repeat: true, onNotice: (t, id) => {
389
+ AR.current.newId = id || t;
390
+ if (!AR.current.on) say(t === DIFFERENT_NOTICE ? t : 'a newer quox is installed: /restart (or /restart auto on)', 'dim');
391
+ } }) : undefined), []);
392
+ const autoIdle = () => { // nothing for a restart to interrupt
393
+ const s = S.current;
394
+ return !s.turn && !s.approvals.length && !s.pending.length && B.current.text === '' && !R.current.pendingWalk && !R.current.bang && !R.current.sleep
395
+ && !R.current.restarting && !R.current.exiting && !LI.current.isOpen() && !SU.current.isOpen() && !CN.current.isOpen() && !PK.current.isOpen() && !blocked;
396
+ };
397
+ React.useEffect(() => {
398
+ const id = setInterval(() => {
399
+ const a = AR.current;
400
+ if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
401
+ if (a.timer) { if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); if (R.current.pendingWalk) say('Restart held: it waits until the walkthrough ends.', 'dim'); } else bump(n => n + 1); return; } // ticks the visible countdown
402
+ if (!autoIdle()) return;
403
+ const ms = countdownMs();
404
+ if (ms <= 0) { cdLog('timed-out', 0); doRestart(); return; }
405
+ a.endsAt = Date.now() + ms;
406
+ cdLog('started', Math.round(ms / 1000));
407
+ a.timer = setTimeout(() => { stopCountdown(); if (AR.current.on && autoIdle()) { cdLog('timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); } }, ms);
408
+ bump(n => n + 1);
409
+ }, autoTickMs);
410
+ return () => { clearInterval(id); stopCountdown(); };
411
+ }, []);
412
+ // /restart carried a slot: re-check it out under this window's label before the old one is gone. /restart auto tends to
413
+ // fire just as a new build lands, which is often while the collector redeploys, so a timeout or 5xx is retried; if it
414
+ // never answers, the "Reattach to slot X? y/N" ask is offered instead of leaving the window silently slotless.
415
+ React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) carrySlot(restartSlot); }, []);
416
+ // /restart carried an /attach (agent or slot): re-attach once the window can act (owner 2026-10-15 "why dont u reattach").
417
+ const attachCarried = React.useRef(false);
418
+ React.useEffect(() => {
419
+ if (!restartAttach || blocked || attachCarried.current) return;
420
+ attachCarried.current = true;
421
+ if (S.current.turn) { R.current.pendingAttach = restartAttach; say(`Re-attaching to ${restartAttach.replace(/^(agent|slot):/, '')} when this turn ends.`, 'dim'); return; } // a resumed turn would refuse it
422
+ command(`/attach ${restartAttach}`);
423
+ }, [blocked]);
424
+ React.useEffect(() => { // any other way the resumed turn ends (reattach path, Esc): carry the attach then
425
+ if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
426
+ });
427
+ async function carrySlot(nm) {
428
+ for (let i = 0; ; i++) {
429
+ if (R.current.closed || SC.current.slot) return; // closed, or this window checked a slot out meanwhile (/slot); a slot merely inherited from a parent Claude session does not count
430
+ const r = await takeSlot(nm, true, { quietTransient: true });
431
+ if (!r?.transient) return;
432
+ if (i >= slotRetryDelays.length) {
433
+ if (R.current.closed || SC.current.slot) return;
434
+ SC.current.reattach = { slot: nm, take: true, at: Date.now() };
435
+ say(`slot ${nm}: could not reattach after the restart (${r.message}).`, 'warn');
436
+ ask(`Reattach to slot ${nm}?`, { action: `/slot ${nm} --take` });
437
+ return;
438
+ }
439
+ if (i === 0) say(`slot ${nm}: reattaching after the restart is slow (${r.message}); retrying.`, 'dim');
440
+ await new Promise((res) => { const t = setTimeout(res, slotRetryDelays[i]); t.unref?.(); });
441
+ }
442
+ }
443
+ React.useEffect(() => { // a TUI-held slot lapses to stale after 15m without a heartbeat
444
+ if (!api.slotHeartbeat) return undefined;
445
+ const id = setInterval(() => { const sl = where.current.slot; if (sl) api.slotHeartbeat(sl, slotSession()); }, 5 * 60 * 1000);
446
+ id.unref?.();
447
+ return () => clearInterval(id);
448
+ }, []);
449
+ // ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
450
+ const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
451
+ const realCwd = () => (cwd ? String(cwd).replace(/^~(?=\/|$)/, osHome()) : process.cwd());
452
+ const myDisplay = () => { try { return api.displayName?.() || null; } catch { return null; } };
453
+ const myLabel = () => senderLabel({ slot: where.current.slot || null, display: myDisplay() });
454
+ /** Listen on the held slot's live stream; every message lands as a chat line above the composer. */
455
+ function listenSlot(nm) {
456
+ SC.current.listener?.stop(); SC.current.listener = null; SC.current.slot = nm || null;
457
+ if (!nm || !api.slotStream || R.current.closed) return;
458
+ const label = myLabel();
459
+ let paused = false;
460
+ const l = createSlotListener({
461
+ open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: label }, signal),
462
+ wait: api.slotWait ? ({ since }) => api.slotWait(nm, { since, excludeFrom: label }) : null,
463
+ onMessage: (m0) => {
464
+ // a broadcast carries the sender's session, not its slot: look the slot up so the header shows the reply address
465
+ if (m0.from_slot || !api.slots || !parseBroadcastText(m0.text)) return handle(m0);
466
+ Promise.resolve().then(() => api.slots()).then(rows => handle({ ...m0, from_slot: slotOfHolder(rows, m0.from) || undefined }), () => handle(m0));
467
+ },
468
+ onState: (st) => {
469
+ if (st.error && st.failures === 1 && !R.current.closed) { paused = true; streamPausedNote(`slot ${nm}`, st.error); }
470
+ else if (st.connected && paused && !R.current.closed) { paused = false; say(`✔ slot ${nm}: reconnected`, 'dim'); }
471
+ },
472
+ });
473
+ function handle(m) {
474
+ {
475
+ if (typeof m.text === 'string') m = { ...m, text: decodeEntities(m.text) };
476
+ noteReply(m); noteSender(m);
477
+ if (R.current.closed || SC.current.slot !== nm || (label && m.from === label)) return;
478
+ if (isOwnBroadcast(m, { label, display: myDisplay(), userId: api.userId?.() || null })) return; // your own broadcast is not news: the "BROADCAST sent" card told you
479
+ { const ln = slotLine(m); const bc = parseBroadcastText(m.text); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() }); if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: bc ? { ...ln, urgent: bc.urgent, broadcast: true } : ln }); }
480
+ rememberSender(m);
481
+ if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
482
+ }
483
+ }
484
+ SC.current.listener = l; l.start({});
485
+ }
486
+ React.useEffect(() => () => { SC.current.listener?.stop(); SC.current.listener = null; for (const t of SC.current.waits?.values() || []) clearTimeout(t); }, []);
487
+ // ---- SLOT-RUN B: a slot sender asks the human to run ONE command here (slotRun.js) ------------------
488
+ /** Show the request (the full command, always) and, when it may run, arm the y/N/c ask for RUN_TTL_MS. */
489
+ function armRun(rq, ln) {
490
+ if (rq.runnable) {
491
+ const prev = SC.current.runAsk;
492
+ if (prev && Date.now() < prev.expiresAt) say(`${prev.who}'s earlier run request was replaced by this one. It was not run.`, 'warn');
493
+ if (prev) settleRun(prev, { kind: 'closed' });
494
+ rq = { ...rq, askId: `run-${++askSeq.current}` };
495
+ SC.current.runAsk = { ...rq, at: Date.now() };
496
+ (SC.current.runCards ??= new Map()).set(rq.askId, rq); // held live until settled (see commitLimit)
497
+ }
498
+ const show = () => dispatch({ type: 'slotmsg', line: { ...ln, run: rq } });
499
+ show();
500
+ if (rq.runnable) R.current.lastAsk = { text: '', question: `Run \`${rq.cmd}\` (${rq.who} asked: ${rq.why || 'no reason given'})?`, redo: show };
501
+ }
502
+ const runAskOpen = () => Boolean(SC.current.runAsk && runNow() < SC.current.runAsk.expiresAt); // one clock: the run-ask clock decides open and expired alike
503
+ /** ASK-SETTLE: history may go to <Static> (scrollback, immutable) only up to the first armed run card. */
504
+ function commitLimit(s0) {
505
+ const held = SC.current.runCards;
506
+ if (held?.size) for (let i = committedRef.current; i < s0.history.length; i++) { const r = s0.history[i]?.line?.run; if (r?.askId && !r.settled && held.has(r.askId)) return i; }
507
+ return s0.history.length;
508
+ }
509
+ /** y: hand the terminal to the user's own shell for the command (like /restart's hand-over), then resume. Never the model. */
510
+ function runSlotCommand(ra) {
511
+ if (R.current.bang) { SC.current.runAsk = ra; return say('A command is still running. Ctrl+C stops it, then answer again.', 'warn'); }
512
+ say(`$ ${ra.cmd}`, 'accent');
513
+ say(`Running ${ra.who}'s command in your shell, with this terminal handed over. Quox comes back when it ends.`, 'dim');
514
+ setTimeout(() => {
515
+ if (R.current.closed || R.current.exiting) return;
516
+ const r = handOverRun({ cmd: ra.cmd, env, cwd: realCwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
517
+ transcript?.bang?.(ra.cmd, r.error ? 'error' : (r.signal || r.code));
518
+ say(`${ra.who}'s command: ${runFooter(r)}`, !r.error && !r.signal && r.code === 0 ? 'ok' : 'warn');
519
+ bump(n => n + 1);
520
+ }, runHandOverDelayMs);
521
+ return undefined;
522
+ }
523
+ /** Say whether the recipient is live; if it is, one gold line when no reply comes. Older servers send no presence. */
524
+ function announceDelivery(slot, r) {
525
+ const p = presenceLine(slot, r.presence, agoText);
526
+ if (!p) return say(`✔ delivered to ${slot}`, 'ok');
527
+ say(p.text, p.tone);
528
+ const wait = SC.current.waits ??= new Map();
529
+ clearTimeout(wait.get(slot)); wait.delete(slot);
530
+ if (p.listening) {
531
+ const t = setTimeout(() => { wait.delete(slot); if (!R.current.closed) say(`no reply from ${slot} yet`, 'warn'); }, noReplyMs);
532
+ t.unref?.(); wait.set(slot, t);
533
+ } else if (r.presence?.state === 'not_listening') {
534
+ // Transcript fp 6a482d60fe83 (2026-10-16): an attached window got nothing for 4 minutes after "not listening".
535
+ // No reply within the wait: ONE wake ask for this message; y runs /poke <slot> (the keeper wake).
536
+ const seenAt = r.presence.last_checked_in_at || null;
537
+ const t = setTimeout(() => {
538
+ wait.delete(slot);
539
+ if (R.current.closed) return;
540
+ R.current.wakeAsk = { slot, at: Date.now() };
541
+ ask('Wake it now?', { action: `/poke ${slot}`, reason: `${slot} hasn't replied (not listening, ${seenAt ? `last checked in ${agoText(seenAt)}` : 'no check-in on record'})` });
542
+ }, noReplyMs);
543
+ t.unref?.(); wait.set(slot, t);
544
+ }
545
+ return undefined;
546
+ }
547
+ /** SLOTS-REPLY2: after a send into slot X, also show X's holder's lines arriving in X (30 min). Own slot is already listened. */
548
+ function watchReplies(slot, sent) {
549
+ if (!api.slotStream || R.current.closed) return;
550
+ SC.current.replyWatch ??= createReplyWatches({
551
+ open: (nm, q, signal) => api.slotStream(nm, { since: q.since }, signal),
552
+ wait: api.slotWait ? (nm, { since }) => api.slotWait(nm, { since }) : null,
553
+ ttlMs: replyWatchMs,
554
+ skipSlot: (nm) => nm === where.current.slot || SC.current.rooms?.has(nm),
555
+ isMine: (m) => { const l = myLabel(); return Boolean(l && m?.from === l); },
556
+ onReply: (nm, m) => {
557
+ if (R.current.closed) return;
558
+ noteReply(m); noteSender(m);
559
+ const ln = slotLine(m); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() });
560
+ if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: ln });
561
+ rememberSender(m);
562
+ },
563
+ });
564
+ SC.current.replyWatch.arm(slot, sent);
565
+ }
566
+ React.useEffect(() => () => { SC.current.replyWatch?.stop(); }, []);
567
+ // SLOTS-REPLY2: every slotSay names the session this window checked its slot out as, so the server can stamp from_slot from its hold record.
568
+ api.bindHolderSession?.(() => slotSession());
569
+ /** Any inbound line from a slot we are waiting on cancels its "no reply" nudge. */
570
+ function noteReply(m) {
571
+ const waits = SC.current.waits; if (!waits?.size) return;
572
+ for (const [slot, t] of waits) if (isHolderLine(m, slot)) { clearTimeout(t); waits.delete(slot); } // case-insensitive: LUCID is the holder of lucid
573
+ }
574
+ /** @slot, /say, /send: straight to the slot, no model turn, signed as this window; prints once delivered. */
575
+ // A typed slot name resolved against the org's slots ignoring case, hyphens, underscores and spaces; else as typed.
576
+ async function canonSlot(nm, { senders = false } = {}) {
577
+ return (await sayTarget(nm, { senders })).slot;
578
+ }
579
+ /** SLOTS-REPLY: names that recently spoke here with a verified slot (from_slot); /say <that name> reaches the slot. */
580
+ function noteSender(m) {
581
+ if (!m?.from || !m?.from_slot) return;
582
+ const list = SC.current.senders ??= [];
583
+ list.push({ from: m.from, from_slot: m.from_slot });
584
+ if (list.length > 200) list.splice(0, list.length - 200);
585
+ }
586
+ // -> { slot, label }: the slot to post to and how to show it (slot first, the name it was reached by in brackets).
587
+ // A slot name, or the session holding one ("/say SITE-CLI"), or (senders) a name that recently spoke with a slot.
588
+ async function sayTarget(nm, { senders = false } = {}) {
589
+ if (!nm) return { slot: nm, label: nm };
590
+ let rows = [];
591
+ if (api.slots) { try { rows = await api.slots(); } catch { rows = []; } }
592
+ const t = resolveSayTarget(nm, Array.isArray(rows) ? rows : [], senders ? SC.current.senders : null);
593
+ if (!t) return { slot: nm, label: nm };
594
+ return { slot: t.slot, label: t.via === 'slot' ? t.slot : addressLabel(t.slot, t.name) };
595
+ }
596
+ /** A dropped slot stream: a server restart (connection cut) is routine and says so calmly; anything else keeps the error. */
597
+ function streamPausedNote(where, err) {
598
+ if (/terminated|ECONNRESET|ECONNREFUSED|socket hang up|fetch failed|other side closed|502|503/i.test(String(err || ''))) return say(`${where}: Quox server restarting, reconnecting (nothing is lost).`, 'dim');
599
+ return say(`${where}: live messages paused (${err}); retrying.`, 'warn');
600
+ }
601
+ /** QUOXON-REPLY (owner 2026-10-15: "click the sender to reply"; no mouse tracking, so /r): who last messaged me. */
602
+ function rememberSender(m) {
603
+ const who = m?.from_slot || String(m?.from || '').replace(/\s*\(.*\)\s*$/, '').trim();
604
+ if (!who) return;
605
+ R.current.lastSender = who;
606
+ if (m?.from_slot && !R.current.replyHinted) { // no hint for a sender with no reply address: it would contradict "(no reply address)"
607
+ R.current.replyHinted = true;
608
+ say(`Tab (empty prompt) or /r <text> replies to ${who}`, 'dim');
609
+ }
610
+ }
611
+ /** /slots, /slot list: every slot, who holds it and how recently, one line each (held first). */
612
+ async function listSlots(all = false) {
613
+ if (!api.slots) return say('Slots are not available in this mode.', 'warn');
614
+ let rows; try { rows = await api.slots(); } catch (e) { return say(`Couldn't list slots: ${e?.message || 'failed'}`, 'warn'); }
615
+ const list = (Array.isArray(rows) ? rows : []).filter(r => r?.name);
616
+ if (!list.length) return say('No slots yet. /slot <name> gives this session one.');
617
+ const held = list.filter(r => r.holder_session && r.state !== 'free'), free = list.filter(r => !(r.holder_session && r.state !== 'free'));
618
+ const lines = [...slotsTable(list, { mine: where.current.slot, cols: Math.max(20, cols - 2), all }), `${held.length} held, ${free.length} free${where.current.slot ? ' · * this window' : ''}. /say <slot or session> <text> reaches a held one.`];
619
+ for (const n of new Set(list.map(r => r.human_presence?.away && r.human_presence.notice).filter(Boolean))) lines.push(`⏸ ${n}`); // PRESENCE
620
+ return say(lines.join('\n'));
621
+ }
622
+ async function sendToSlot(slot, text) {
623
+ const target = await sayTarget(slot, { senders: true });
624
+ slot = target.slot;
625
+ if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
626
+ if (SC.current.rooms.has(slot)) return postRoom(slot, text); // a joined room posts as a member
627
+ const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
628
+ if (!r.ok && r.status === 404 && api.slots) { let rows = []; try { rows = await api.slots(); } catch { /* the list is a hint only */ } return say(sayMissText(slot, rows), 'warn'); }
629
+ if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
630
+ const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
631
+ dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
632
+ announceDelivery(slot, r);
633
+ watchReplies(slot, r.message);
634
+ if (r.humanPresence?.away && r.humanPresence.notice) say(`⏸ ${r.humanPresence.notice}`, 'warn'); // PRESENCE
635
+ if (r.roomPending) say(`${slot} is new: listening windows have it; its chat room copy is queued, delivering when the room is ready.`, 'dim');
636
+ return undefined;
637
+ }
638
+ // ---- QUOXON-ROOMS: a slot as a group room. Joining subscribes (no hold); `#room text` posts; rooms list with unread. ----
639
+ const roomMe = () => slotSession();
640
+ const roomSigned = () => { const d = myDisplay(); return d ? `${roomMe()} (${d})` : roomMe(); };
641
+ const isMine = (m) => { const f = String(m?.from || ''); return f === roomMe() || f.startsWith(`${roomMe()} (`); };
642
+ async function joinRoom(nm, { resume = false } = {}) {
643
+ if (!api.slotJoin || !api.slotStream) return say('Rooms are not available in this mode.', 'warn');
644
+ const rooms = SC.current.rooms;
645
+ if (rooms.has(nm)) { if (!resume) say(`Already in #${nm}. #${nm} <text> posts; #${nm} alone makes it the default.`, 'dim'); return true; }
646
+ const me = roomMe();
647
+ if (!resume) {
648
+ const r = await api.slotJoin(nm, me, myDisplay());
649
+ if (!r.ok) { say(`#${nm}: ${r.message}`, 'warn'); return false; }
650
+ }
651
+ let since = null;
652
+ try { // a one-line-each backlog of the last ~10, so a join lands in the middle of the conversation
653
+ const rows = await (api.slotMessages ? api.slotMessages(nm, 10) : []);
654
+ for (const m of rows) { dispatch({ type: 'slotmsg', line: roomLine(nm, m) }); since = m.created_at || since; }
655
+ } catch { /* the live stream still starts; the backlog is a courtesy */ }
656
+ if (R.current.closed) return false;
657
+ const entry = { listener: null, unread: 0 };
658
+ entry.listener = createSlotListener({
659
+ open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: me, session: me }, signal),
660
+ wait: api.slotWait ? ({ since: sn }) => api.slotWait(nm, { since: sn, excludeFrom: me }) : null,
661
+ onMessage: (m) => {
662
+ noteReply(m); noteSender(m);
663
+ if (R.current.closed || !SC.current.rooms.has(nm) || isMine(m)) return;
664
+ if (SC.current.active !== nm) entry.unread += 1; // before the dispatch, so the status line's count is current
665
+ dispatch({ type: 'slotmsg', line: roomLine(nm, m) });
666
+ rememberSender(m);
667
+ if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
668
+ },
669
+ onState: (st) => { if (st.error && st.failures === 1 && !R.current.closed) streamPausedNote(`#${nm}`, st.error); },
670
+ });
671
+ rooms.set(nm, entry); bump(n => n + 1); // the status line's groups update now, even on a quiet resume
672
+ entry.listener.start({ since });
673
+ if (!resume) say(`Joined #${nm}. #${nm} <text> posts as ${roomSigned()}; #${nm} alone makes it the default target (a bare # undoes it); /leave ${nm} leaves.`, 'ok');
674
+ return true;
675
+ }
676
+ async function leaveRoom(nm) {
677
+ const e = SC.current.rooms.get(nm);
678
+ if (!e) return say(`You are not in #${nm}. /rooms lists the rooms you are in.`, 'dim');
679
+ e.listener?.stop(); SC.current.rooms.delete(nm);
680
+ if (SC.current.active === nm) SC.current.active = null;
681
+ bump(n => n + 1);
682
+ const r = await api.slotLeave?.(nm, roomMe());
683
+ say(r && !r.ok ? `Left #${nm} here; the server did not confirm (${r.message}).` : `Left #${nm}.`, r && !r.ok ? 'warn' : 'ok');
684
+ }
685
+ function showRooms() {
686
+ const rooms = SC.current.rooms;
687
+ if (!rooms.size) return say('No rooms joined. /join <slot> joins one (any slot in this org).', 'dim');
688
+ for (const [nm, e] of rooms) {
689
+ const live = e.listener?.state?.().connected ? '' : ' (reconnecting)';
690
+ say(`${SC.current.active === nm ? '> ' : ' '}#${nm} ${e.unread ? `${e.unread} unread` : 'no unread'}${SC.current.active === nm ? ' (default target)' : ''}${live}`, e.unread ? 'accent' : 'dim');
691
+ }
692
+ }
693
+ async function setActiveRoom(nm) {
694
+ if (!(await joinRoom(nm))) return;
695
+ SC.current.active = nm; const e = SC.current.rooms.get(nm); if (e) e.unread = 0;
696
+ say(`Default target is #${nm}: plain lines now post there. A bare # puts it back.`, 'ok');
697
+ }
698
+ async function postRoom(nm, text) {
699
+ if (!api.slotSay) return say('Rooms are not available in this mode.', 'warn');
700
+ if (!SC.current.rooms.has(nm) && !(await joinRoom(nm))) return;
701
+ const r = await api.slotSay(nm, text, { roomSession: roomMe(), fast: true });
702
+ if (!r.ok) return say(`#${nm}: ${r.message}`, 'warn');
703
+ const e = SC.current.rooms.get(nm); if (e) e.unread = 0;
704
+ dispatch({ type: 'slotmsg', line: { ...roomLine(nm, { from: roomSigned(), text, created_at: r.message?.created_at || new Date().toISOString() }, { out: true }), delivered: true } });
705
+ }
706
+ /** `/group-chat join|leave|<text>` and /join, /leave: a typed slot name is checked BEFORE "Ask Quox". */
707
+ async function slotNameCommand(cmdWord, rest) {
708
+ let nm = roomName(cmdWord.replace(/^\//, ''));
709
+ if (!nm || !api.slots) return false;
710
+ let rows = null;
711
+ try { rows = await api.slots(); } catch { return false; }
712
+ const hit = uniqueSquash(nm, (Array.isArray(rows) ? rows : []).map(x => x.name));
713
+ if (!hit) return false;
714
+ nm = hit.toLowerCase();
715
+ const t = String(rest || '').trim();
716
+ if (/^join$/i.test(t)) await joinRoom(nm);
717
+ else if (/^leave$/i.test(t)) await leaveRoom(nm);
718
+ else if (!t) await setActiveRoom(nm);
719
+ else await postRoom(nm, t);
720
+ return true;
721
+ }
722
+ React.useEffect(() => { // rooms are server truth: a restarted named window rejoins quietly
723
+ // keyed like roomMe(): the name, else the conversation an unnamed window joined under (restart resumes it)
724
+ const me = SN.current.name || resumeConversation;
725
+ if (blocked || demoLabel || !api.slotRooms || !me) return;
726
+ api.slotRooms(me).then((rows) => { for (const r of rows) joinRoom(String(r.slot_name), { resume: true }); }).catch(() => {});
727
+ }, []);
728
+
729
+ async function slotLog(nArg) {
730
+ const nm = where.current.slot;
731
+ if (!nm) return say('No slot held. /slot <name> gives this session one.', 'warn');
732
+ if (!api.slotMessages) return say('Slots are not available in this mode.', 'warn');
733
+ const n = Math.min(Math.max(parseInt(nArg, 10) || 20, 1), 100);
734
+ let rows;
735
+ try { rows = await api.slotMessages(nm, n); } catch (e) { return say(`slot ${nm}: ${e?.message || 'failed'}`, 'warn'); }
736
+ if (!rows.length) return say(`No messages in ${nm} yet.`);
737
+ say(`Last ${rows.length} in ${nm} (seen by Quox):`);
738
+ for (const m of rows) { // SLOT-RUN: an old run request is shown, never armed (only a live one can be answered)
739
+ noteSender(m); const rq = runRequest(m);
740
+ dispatch({ type: 'slotmsg', line: { ...slotLine(m), full: true, ...(rq ? { run: { ...rq, runnable: false, danger: null, refuse: 'it was sent earlier, and only a live request can be answered here' } } : {}) } });
741
+ }
742
+ return undefined;
743
+ }
744
+ async function sendFile(slot, p) {
745
+ const f = prepareFile(p, { cwd: realCwd() });
746
+ if (!f.ok) return say(f.message, 'warn');
747
+ if (f.hits) {
748
+ SC.current.pendingFile = { slot, f, at: Date.now() };
749
+ return ask(`Send it to ${slot} with ${f.hits === 1 ? 'that' : 'those'} redacted?`, { action: `send ${f.name} to slot ${slot}, ${f.hits} value${f.hits === 1 ? '' : 's'} replaced by [redacted]`, reason: `${f.name} has ${f.hits} secret-shaped value${f.hits === 1 ? '' : 's'}.` });
750
+ }
751
+ return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }));
752
+ }
753
+ // Startup: the slot this profile last held in this directory, free or stale (or still under our old label), is offered once.
754
+ React.useEffect(() => {
755
+ if (blocked || demoLabel || restartSlot || !api.checkoutSlot || !api.slots) return;
756
+ const rec = loadLastSlot({ profile, cwd: realCwd(), path: slotRecordPath });
757
+ if (!rec) return;
758
+ api.slots().then((rows) => {
759
+ const row = (Array.isArray(rows) ? rows : []).find(x => String(x.name) === rec.slot);
760
+ if (!row || SC.current.slot || R.current.closed) return; // only this window's own checkout suppresses the ask, not an inherited one
761
+ const mine = row.holder_session && rec.session && row.holder_session === rec.session;
762
+ if (row.state === 'free' || row.state === 'stale' || !row.holder_session || mine) {
763
+ SC.current.reattach = { slot: rec.slot, take: Boolean(mine || row.state === 'stale'), at: Date.now() };
764
+ ask(`Reattach to slot ${rec.slot}?`, { action: `/slot ${rec.slot}` });
765
+ }
766
+ }).catch(() => { /* no ask when the server cannot say */ });
767
+ }, []);
768
+ React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
769
+ React.useEffect(() => { if (!blocked && !demoLabel) aiConnected().then((st) => { R.current.noAi = st === false ? true : st === true ? false : undefined; bump(n => n + 1); }); }, []);
770
+ // Tips (tips.js): mode, rotation and "already used" persist on the profile (quoxon.tips / tipsSeen / tipsUsed).
771
+ // QUOXON-MOUSE: optional mouse (default off, saved per profile). `tty` = tracking is on at the terminal right now.
772
+ const MO = React.useRef(null);
773
+ MO.current ??= { on: (api.settings?.() || {}).mouse === 'on', tty: false, chain: Promise.resolve(), drag: null, last: null, onCursor: null, exitFn: null };
774
+ const TP = React.useRef(null);
775
+ TP.current ??= (() => { const q = api.settings?.() || {}; const arr = (v) => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : []); return { mode: normaliseMode(q.tips) || DEFAULT_MODE, seen: arr(q.tipsSeen), used: arr(q.tipsUsed) }; })();
776
+ const showTip = async ({ startup: atLaunch = false } = {}) => {
777
+ const t = TP.current;
778
+ if (t.mode === 'off') return atLaunch ? undefined : say('Tips are off. /tip-settings all turns them back on.');
779
+ let hosts; try { hosts = await api.hostCount?.(); } catch { hosts = undefined; } // unknown never skips a tip
780
+ if (atLaunch && S.current.approvals.length) return undefined; // never over an approval
781
+ let plugins; try { plugins = await api.installedPlugins?.(); } catch { plugins = undefined; } // unreadable = plugin tips hidden
782
+ const signals = { hosts, modelFavs: MP.current.prefs.favourites.length, rows: RW.current.all().length };
783
+ const contract = R.current.contract || null;
784
+ const r = nextTip({ mode: t.mode, contract, plugins, signals, used: t.used, seen: t.seen });
785
+ if (!r.tip) return atLaunch ? undefined : say('No tips to show right now for this install.');
786
+ t.seen = r.seen; api.saveSetting?.('tipsSeen', t.seen);
787
+ if (R.current.sleep) return undefined; // no tip over the sleep screen
788
+ return dispatch({ type: 'tip', text: tipText(r.tip), cmd: r.tip.try });
789
+ };
790
+ const tipStarted = React.useRef(false);
791
+ // What's new (whatsnew.js): once per newer build; first ever run only records. Wins the startup slot over the tip.
792
+ const showWhatsNew = () => {
793
+ if (!whatsNewStartup || blocked || demoLabel) return false;
794
+ const st = api.settings?.() || {};
795
+ const r = startupWhatsNew({ build: version, last: st.whatsNew, entries: whatsNewEntries });
796
+ if (r.record) api.saveSetting?.('whatsNew', r.record);
797
+ if (welcome || !startupEnabled(st)) return false;
798
+ if (r.show.length) { dispatch({ type: 'whatsnew', items: r.show.map(e => ({ title: e.title, cmd: tryText(e) })), more: r.more }); return true; }
799
+ const line = startupCommandLine(unseenCommands({ registry: visibleRegistry(), seen: st.whatsNewSeen })); // /whatsnew marks them seen
800
+ if (!line) return false;
801
+ dispatch({ type: 'whatsnew', head: "What's new", pad: true, items: [{ title: line, cmd: '' }], more: 0 });
802
+ return true;
803
+ };
804
+ const startupTip = () => {
805
+ if (tipStarted.current || blocked) return;
806
+ tipStarted.current = true;
807
+ const shown = showWhatsNew();
808
+ if (tipStartup && !welcome && !shown) showTip({ startup: true });
809
+ };
810
+ React.useEffect(() => {
811
+ if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); startupTip(); }, () => startupTip());
812
+ else startupTip();
813
+ }, []);
814
+
815
+ React.useEffect(() => {
816
+ if (columns != null) { setCols(clampCols(columns)); return undefined; }
817
+ // Only the live region repaints at the new width (R3 D2): the stdout proxy (term.js) erases the old frame
818
+ // by its reflowed height, so no ghost composers, and history is never reprinted into scrollback.
819
+ const onResize = () => setCols(clampCols((stdout.columns || 80) - 1));
820
+ stdout?.on?.('resize', onResize);
821
+ return () => stdout?.off?.('resize', onResize);
822
+ }, [stdout, columns]);
823
+
824
+ R.current.startedAt ??= Date.now();
825
+ const where = React.useRef(slotAgent || detectSlotAgent());
826
+ // Session name (top rule) and the attached agent. The name is saved locally once a conversation id exists.
827
+ const SN = React.useRef({ name: cleanName(sessionName), saved: null, agent: null, baseAgent: where.current.agent ?? null });
828
+ React.useEffect(() => {
829
+ const id = S.current.ctx.conversationId; const key = `${id}|${SN.current.name}`;
830
+ if (SN.current.name && id && SN.current.saved !== key) { SN.current.saved = key; saveName(id, SN.current.name, namesPath, { profile, org: R.current.orgName ?? org ?? null }); }
831
+ });
832
+
833
+ // Live approvals count: refreshed from the inbox every 30 s. A failed refresh keeps the last
834
+ // known count and records the error; it never reads as "0 waiting".
835
+ // The org's name, or "org …H398" while it is unknown; never a raw ULID. A lookup that failed at startup (server down)
836
+ // is retried on the next successful poll (owner 2026-10-18).
837
+ const orgShown = () => orgLabel(R.current.orgName ?? org, R.current.orgId ?? api.orgId?.() ?? null);
838
+ const retryOrgName = async () => {
839
+ const cur = R.current.orgName ?? org;
840
+ if ((cur && !isOrgId(cur)) || !api.orgs || R.current.orgLookup) return;
841
+ const id = R.current.orgId ?? api.orgId?.() ?? null; if (!id) return;
842
+ R.current.orgLookup = true;
843
+ try {
844
+ const hit = (await api.orgs()).find((o) => o?.id === id);
845
+ const nm = hit?.name || hit?.slug;
846
+ if (nm && !isOrgId(nm)) { R.current.orgName = nm; dispatch({ type: 'notify', patch: {} }); }
847
+ } catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
848
+ };
849
+ const refreshNotify = async () => {
850
+ if (BL.current) return;
851
+ const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
852
+ try {
853
+ const items = await api.approvals();
854
+ if (N.authLost) say('Signed in again: approvals are back.', 'ok');
855
+ else if (N.slow) say('Quox server is back.', 'ok');
856
+ N.fails = 0; N.authLost = false; N.slow = false;
857
+ dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
858
+ retryOrgName();
859
+ dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
860
+ R.current.openIds = { ids: new Set(items.map(i => i.id)), at: Date.now() }; // a walk skips a card answered elsewhere since it started
861
+ // Provider paths that emit no approval.requested still surface here, as the same card.
862
+ for (const item of items) dispatch({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
863
+ } catch (e) {
864
+ N.fails++; // counted apart from an empty inbox
865
+ const msg = e?.message || 'failed';
866
+ if (!e?.transient && (e?.status === 401 || isSessionDead(e) || /No authentication token|service key|unauthori[sz]ed/i.test(msg))) {
867
+ N.authLost = true;
868
+ markSignedOut(e?.reason || null); // one card, then the pollers go quiet until /login
869
+ dispatch({ type: 'notify', patch: { error: null } });
870
+ return;
871
+ }
872
+ if (e?.transient && !N.slow) { N.slow = true; say('Quox server is slow or unreachable, retrying.', 'warn'); } // QUOXON-FW12: not signed out
873
+ dispatch({ type: 'notify', patch: { error: msg } });
874
+ }
875
+ };
876
+ React.useEffect(() => {
877
+ // Back off on repeated failure (x2 per miss, capped at 60 s); the normal cadence returns after one success.
878
+ let timer = null, stopped = false;
879
+ const tick = async () => {
880
+ // Stale sign-in: the credentials file changed (another window refreshed, or `quox login` ran). While this
881
+ // window reads as signed out, re-read them and retry once; a still-dead session just marks itself again.
882
+ try {
883
+ if (api.credentialsChanged?.() && BL.current && signedOutRef.current && api.reloadCredentials?.()) {
884
+ setSignedOut(null); BL.current = blockedProp || null;
885
+ R.current.notify = { fails: 0, authLost: false };
886
+ refreshRoster();
887
+ }
888
+ } catch { /* a failed stat never blocks the poll */ }
889
+ await refreshNotify();
890
+ if (stopped) return;
891
+ const f = R.current.notify?.fails || 0;
892
+ timer = setTimeout(tick, f ? Math.min(60000, notifyMs * 2 ** Math.min(f, 12)) : notifyMs);
893
+ };
894
+ tick();
895
+ return () => { stopped = true; clearTimeout(timer); };
896
+ }, []);
897
+
898
+ // #904 part 3: a provider refusing turns (weekly limit, no credits) shows in the status area before
899
+ // you type into it. Polled once a minute; a failed read keeps the last answer (never "fine").
900
+ React.useEffect(() => {
901
+ if (!api.providerHealth) return undefined;
902
+ let timer = null, stopped = false;
903
+ const tick = async () => {
904
+ if (!BL.current) {
905
+ const list = await api.providerHealth();
906
+ // Only the provider this window uses (owner 2026-10-15: "only need to know if using openai"): the chosen
907
+ // model's provider, else the provider the last turn ran on. Unknown yet: say nothing; a failing turn says why.
908
+ const mine = String(MC.current?.provider || S.current?.ctx?.provider || '').toLowerCase();
909
+ const hits = list ? list.filter(e => mine && String(e.provider || '').toLowerCase() === mine) : null;
910
+ if (hits) dispatch({ type: 'notify', patch: { providerLines: hits.map(e => `⚠ ${e.line || e.provider + ' ' + e.state}: chat on it fails until then`) } });
911
+ }
912
+ if (!stopped) timer = setTimeout(tick, 60000);
913
+ };
914
+ tick();
915
+ return () => { stopped = true; clearTimeout(timer); };
916
+ }, []);
917
+
918
+ // Roster: refreshed from real list endpoints. A failing source adds no rows and is named in /roster.
919
+ const refreshRoster = async () => {
920
+ if (BL.current || !api.roster) return;
921
+ try {
922
+ let r = await api.roster(Date.now());
923
+ const R0 = R.current.roster;
924
+ const prevKey = R0.rows[R0.sel]?.key;
925
+ applyProgress(r.rows, (R0.progress ||= new Map()), Date.now());
926
+ R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
927
+ r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
928
+ R0.rows = r.rows;
929
+ const at = r.rows.findIndex(x => x.key === prevKey);
930
+ R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, r.rows.length - 1));
931
+ if (!r.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
932
+ if (!R.current.closed) bump(n => n + 1);
933
+ } catch { /* api.roster handles per-source failures; nothing to show */ }
934
+ };
935
+ React.useEffect(() => {
936
+ refreshRoster();
937
+ const id = setInterval(refreshRoster, rosterMs);
938
+ return () => clearInterval(id);
939
+ }, []);
940
+
941
+ // Roster clock: finished rows fade out and drop locally at their expiry (runs on the motion clock's ticks).
942
+ const expireRoster = () => {
943
+ const ro = R.current.roster;
944
+ if (!ro.rows.length) return false;
945
+ const now = Date.now();
946
+ if (ro.rows.some(r => isExpired(r, now))) {
947
+ const selKey = ro.rows[ro.sel]?.key;
948
+ ro.rows = ro.rows.filter(r => !isExpired(r, now)); ro.allRows = ro.allRows.filter(r => !isExpired(r, now));
949
+ const at = ro.rows.findIndex(r => r.key === selKey);
950
+ ro.sel = at >= 0 ? at : Math.min(ro.sel, Math.max(0, ro.rows.length - 1));
951
+ if (!ro.rows.length) { ro.focus = false; ro.peek = false; ro.confirm = null; }
952
+ return true;
953
+ }
954
+ return false;
955
+ };
956
+
957
+ // ---- motion: ONE clock for every animation and visible tick (motion/clock.js). Each render declares what it
958
+ // still needs (frames, a second tick, a one-shot wake); with nothing declared there is no timer at all.
959
+ const clockRef = React.useRef(null);
960
+ const framesRef = React.useRef(null);
961
+ if (!framesRef.current) framesRef.current = createFrames();
962
+ const lastAppRender = React.useRef(0);
963
+ const ringRef = React.useRef(null);
964
+ const dividerSlowRef = React.useRef({}); // TUI-DIVIDER: per-turn slow-redraw marker (dividerRule.noteFrameGap)
965
+ const onClockTick = React.useRef(null);
966
+ onClockTick.current = () => {
967
+ if (R.current.closed) return;
968
+ R.current.tickPerf = performance.now();
969
+ const now = Date.now();
970
+ if (!introDone.current && now - introStart.current >= introMs) { finishIntro(); return; }
971
+ framesRef.current.emit(now); // animated leaves re-render themselves
972
+ if (expireRoster() || appDue(now)) bump(n => n + 1); // structural change: the whole view
973
+ else clockRef.current.schedule(demands(), now);
974
+ };
975
+ // The newest protocol stage stays live (its icon pulses) while it is the last thing that happened in the turn.
976
+ const isActiveStage = (st, i) => i === st.history.length - 1 && Boolean(st.turn) && !st.blocks.length && st.history[i].kind === 'trace';
977
+ // The app itself re-renders on a tick only when something it lays out is due (not for every frame).
978
+ function appDue(now) {
979
+ const s0 = S.current;
980
+ if (!introDone.current) return true;
981
+ const sec = (t) => Math.floor(t / 1000);
982
+ if (R.current.roster.rows.length && sec(now) !== sec(lastAppRender.current)) return true; // roster ages
983
+ if (motionOn && !mono() && R.current.roster.rows.some((x) => x.state === 'running')) return true; // pulse spinner frames
984
+ if (motionOn) {
985
+ if (commitLimit(s0) > committedRef.current && !fxActive(s0.history[committedRef.current], now) && !isActiveStage(s0, committedRef.current)) return true; // commit to Static
986
+ for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null || !rosterSeen.current.has(r.key)) return true;
987
+ }
988
+ const card0 = s0.approvals[0];
989
+ if (card0) {
990
+ const age = now - (card0.displayedAt ?? card0.shownAt);
991
+ if (motionOn && (age < 1000 || R.current.cardMidAnim)) return true; // arrival animation, plus one settled render if the last draw was mid-animation (a late tick on a loaded box left the title scrambled for good)
992
+ if (!R.current.armedShown && age >= 300) return true; // the card arms
993
+ }
994
+ return false;
995
+ }
996
+ if (!clockRef.current) clockRef.current = new Clock(() => onClockTick.current());
997
+ if (motionStats) motionStats.clock = clockRef.current;
998
+ React.useEffect(() => () => clockRef.current.stop(), []);
999
+ const demands = () => {
1000
+ const s0 = S.current, now = Date.now(), d = [];
1001
+ if (!introDone.current) d.push({ every: INTRO_FRAME_MS });
1002
+ if (s0.turn) d.push(motionOn ? { every: FRAME_MS } : { every: SECOND_MS, since: s0.turn.startedAt });
1003
+ const card0 = s0.approvals[0];
1004
+ if (card0) { const at = (card0.displayedAt ?? card0.shownAt) + 320; if (now < at) d.push(motionOn ? { every: FRAME_MS } : { at }); }
1005
+ if (R.current.roster.rows.length) d.push({ every: motionOn && !mono() && R.current.roster.rows.some((x) => x.state === 'running') ? SPIN_MS : SECOND_MS }); // SPIN_MS: the roster pulse spinner
1006
+ if (ringAnimating(ringRef.current)) d.push({ every: FRAME_MS }); // the ring settling after the turn
1007
+ if (motionOn && !mono() && dividerLive(s0.divider, now)) { // TUI-DIVIDER: the AOCL strip, then its green/red hold; nothing once it is over
1008
+ d.push({ every: FRAME_MS });
1009
+ if (s0.divider.endedAt != null) d.push({ at: s0.divider.endedAt + (s0.divider.phase === 'done' ? GREEN_MS : RED_MS) + 1 });
1010
+ }
1011
+ if (dragonActive(now, !motionOn || mono())) d.push({ every: motionOn && !mono() ? 40 : 500 }); // /dragon and the cinematic moods (qmotion DragonLive)
1012
+ if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
1013
+ const sl = R.current.sleep;
1014
+ if (sl) d.push({ every: sl.wakeAt != null ? 50 : sl.still ? 1000 : 120 }); // sleep screen: low frame rate (shimmer, slow sway)
1015
+ if (dragonTalkVisible(now)) d.push({ every: motionOn && !mono() ? 60 : 500 }); // /dragon explain|ask
1016
+ if (flyActive(now)) d.push({ every: R.current.fly.still ? 500 : 60 }); // /dragon-fly
1017
+ return d;
1018
+ };
1019
+ React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
1020
+
1021
+ const fxEnd = (e) => Math.max(e.at + revealSpan(e.timing) * 1000, e.settleAt != null ? e.settleAt + revealSpan(TIMING.settle) * 1000 : 0);
1022
+ const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
1023
+ const fxFor = (item, now) => {
1024
+ const e = fxRef.current.get(item.id);
1025
+ if (!e || now >= fxEnd(e) || R.current.exiting) return null;
1026
+ return { e: (now - e.at) / 1000, timing: e.timing, settle: e.settleAt != null ? (now - e.settleAt) / 1000 : null };
1027
+ };
1028
+ const registerFx = (item, now) => {
1029
+ const t = revealTimingFor(item);
1030
+ if (!t) return;
1031
+ if (item.kind === 'object' && !fxRef.current.has(item.id) && cardTallerThanScreen(item, cols, termRows(stdout) ?? 40, now)) return; // TUI-UX #5: a card taller than the screen never decodes in (it would leave a garbled copy in scrollback)
1032
+ const e = fxRef.current.get(item.id);
1033
+ if (!e) { fxRef.current.set(item.id, { at: now, timing: t, running: item.kind === 'tool' && item.status === 'running', settleAt: null }); if (motionStats) (motionStats.reveals ||= []).push({ id: item.id, kind: item.kind, text: item.text ?? null, timing: t }); } // motionStats: which items started a decode-in, for a test that cannot rely on sampling a 0.28 s window
1034
+ else if (e.running && item.status !== 'running' && e.settleAt == null) e.settleAt = now;
1035
+ };
1036
+ const rosterFx = (key, now) => { const at = rosterSeen.current.get(key); const e = at == null ? null : (now - at) / 1000; return e != null && e < revealSpan(TIMING.roster) ? e : null; };
1037
+ function fxBusy(now) {
1038
+ const s0 = S.current;
1039
+ if (commitLimit(s0) > committedRef.current) return true;
1040
+ if (s0.blocks.some(b => fxActive(b, now))) return true;
1041
+ const card0 = s0.approvals[0];
1042
+ if (card0 && (now - (card0.displayedAt ?? card0.shownAt) < 1000 || R.current.cardMidAnim)) return true; // owed a settled draw
1043
+ for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null) return true;
1044
+ return false;
1045
+ }
1046
+
1047
+ // After approving from the terminal the tool runs asynchronously: poll its stored result.
1048
+ const pollResult = async (run) => {
1049
+ if (!run.approvalId || !api.approvalResult) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
1050
+ const t0 = Date.now();
1051
+ while (Date.now() - t0 < resultTimeoutMs) {
1052
+ await new Promise(r => { const t = setTimeout(() => { R.current.pollTimers.delete(t); r(); }, resultPollMs); R.current.pollTimers.add(t); R.current.pollWake = r; });
1053
+ if (R.current.closed) return undefined;
1054
+ if (!S.current.running.some(x => x.callId === run.callId)) return undefined; // resolved elsewhere
1055
+ try {
1056
+ const r = await api.approvalResult(run.approvalId);
1057
+ if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
1058
+ if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
1059
+ const text = r.result == null ? null : clip(typeof r.result === 'string' ? r.result : JSON.stringify(r.result), cols - 20);
1060
+ return dispatch({ type: 'approval.result', callId: run.callId, outcome: r.status === 'error' ? 'failed' : 'done', text });
1061
+ }
1062
+ } catch { /* keep polling until the deadline */ }
1063
+ }
1064
+ return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
1065
+ };
1066
+ /** Where an answer came from, for the approve comment: the session name, else the conversation id, else a marker. */
1067
+ const slotSession = () => SN.current.name || S.current.ctx.conversationId || 'quox';
1068
+ const agoText = (iso) => { const m = iso ? Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000)) : null; return m == null || Number.isNaN(m) ? 'a while ago' : m < 1 ? 'just now' : m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`; };
1069
+ // Check a slot out for this window. A hold by another session of the SAME user is offered as a takeover (y/N, or --take).
1070
+ const takeSlot = async (nm, take, { quietTransient = false } = {}) => {
1071
+ const r = await api.checkoutSlot(nm, slotSession(), { take });
1072
+ if (!r.ok && r.transient && quietTransient) return r; // carrySlot retries and speaks for itself
1073
+ if (r.ok) {
1074
+ where.current = { ...where.current, slot: nm }; bump(x => x + 1);
1075
+ listenSlot(nm);
1076
+ saveLastSlot({ profile, cwd: realCwd(), slot: nm, session: slotSession(), path: slotRecordPath });
1077
+ const from = r.tookOver?.from;
1078
+ return say(from ? `Slot ${nm} checked out (took over your ${r.tookOver.state === 'stale' ? 'stale ' : ''}session '${from}'). Messages to ${nm} show here as they arrive.` : `Slot ${nm} checked out. Messages to ${nm} show here as they arrive; @<slot> <text> sends.`, 'ok');
1079
+ }
1080
+ const h = r.held;
1081
+ if (h?.same_user) {
1082
+ R.current.slotAsk = { nm, at: Date.now() };
1083
+ return ask('Take it over?', { action: `/slot ${nm} --take`, reason: `${nm} is held by your other session '${h.holder_session}' (last seen ${agoText(h.last_seen)}); /slot ${nm} --take also works.` });
1084
+ }
1085
+ if (h) return say(`slot ${nm} is held by ${h.holder_session || 'another user'}${h.since ? ` since ${h.since}` : ''}. It is not yours to take.`, 'warn');
1086
+ if (r.unlicensed) return say(r.message, 'warn');
1087
+ return say(`slot ${nm}: ${r.message}`, 'warn');
1088
+ };
1089
+ // QUOXON-STEPUP: the server decides what needs a person. We try with any live step-up token; on 403 step_up_required
1090
+ // we ask for the PIN (masked card) and retry once. `proactive` (/authorise) asks first. Deny never needs a PIN.
1091
+ const respondWithStepUp = async (card, action, { proactive = false, reason = null, bulk = false } = {}) => {
1092
+ const via = `tty ${sessionLabel()}`;
1093
+ const subject = { line1: `${action === 'approve' ? 'approve' : 'reject'} ${String(card.tool || card.inboxItemId || 'item')}${card.target ? ` · ${String(card.target)}` : ''}`, raisedBy: card.raisedBy || null, risk: card.risk || null };
1094
+ const ask = async () => {
1095
+ const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject });
1096
+ if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; }
1097
+ };
1098
+ if (action !== 'approve') return api.respond(card.inboxItemId, action, { via, reason });
1099
+ if (proactive && !SU.current.token('approve')) await ask();
1100
+ try { return await api.respond(card.inboxItemId, action, { via, stepUp: SU.current.headers('approve'), bulk }); }
1101
+ catch (e) {
1102
+ if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
1103
+ SU.current.drop('approve'); // a stale or mismatched token must not be reused
1104
+ await ask();
1105
+ return api.respond(card.inboxItemId, action, { via, stepUp: SU.current.headers('approve'), bulk });
1106
+ }
1107
+ };
1108
+ // QUOXON-STEPUP: an override id is single-use; the next turn carries it once, then it is gone.
1109
+ const takeOverride = () => { const o = R.current.override; R.current.override = null; return o && o.until > Date.now() ? o.id : undefined; };
1110
+ const sessionLabel = () => String(SN.current.name || (S.current.ctx.conversationId ? String(S.current.ctx.conversationId).slice(0, 12) : 'new session')).replace(/\s+/g, ' ').slice(0, 40);
1111
+ // APPROVALS-AUDIT: a typed number is a position. If the list moved since the owner last saw it, refuse and show it again.
1112
+ const listChanged = async () => {
1113
+ if (R.current.walkSend || !R.current.shownIds) return false;
1114
+ let items; try { items = await api.approvals(); } catch { return false; }
1115
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
1116
+ const ids = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item.id));
1117
+ const seen = R.current.shownIds;
1118
+ if (ids.length === seen.length && ids.every((x, i) => x === seen[i])) return false;
1119
+ say('The list changed since you last saw it, so nothing was sent. Here it is again:', 'warn');
1120
+ await command('/approvals');
1121
+ return true;
1122
+ };
1123
+ // WALKTHROUGH: decisions one card at a time. Identity is the decision id (items are held by id), never a list position.
1124
+ // WALK-FIXES (owner 2026-10-17: "escape to exit survey. two came at once. didnt submit each answer as i gave it, exited
1125
+ // halfway thru and tried again, answered question returned"): ONE walk and ONE card on screen at a time (a second start
1126
+ // joins the walk already running); each answer is sent the moment it is given and the next card shows as soon as the
1127
+ // server took it; a line typed while an answer is still on its way is never applied to the card just answered; Esc or any
1128
+ // command ends the walk with a summary; an id answered in this window never comes back, and a list held from earlier
1129
+ // (a bulk card, a range) is re-read from the server before its first card.
1130
+ const WALK_TTL = 1800000;
1131
+ const walkAnsweredIds = () => (R.current.walkAnswered ||= new Set());
1132
+ const walkCloseCard = (W, why = 'next') => { if (W?.card) { W.card.at = Date.now(); W.card.walkWhy = why; W.card = null; } }; // why: next/done settle silently; stop = interrupted
1133
+ const walkKeysText = (it) => { const ks = it.payload.options.map(o => o.key); return ks.length > 1 ? `${ks.slice(0, -1).join(', ')} or ${ks[ks.length - 1]}` : ks.join(''); };
1134
+ // EXPIRED-ASK (owner 2026-10-17 21:23): a question whose time ran out is gone: say so plainly and move on, never "the question stays".
1135
+ const askExpiry = (it) => { const t = Date.parse(it?.due_at ?? it?.payload?.expires_at); return Number.isFinite(t) ? t : null; };
1136
+ const expiredText = (it) => { const t = askExpiry(it); return t ? `That question expired at ${askClock(t)}, so there's nothing to answer.` : "That question is no longer open, so there's nothing to answer."; };
1137
+ const walkGoneElsewhere = (W, id) => { const o = R.current.openIds; return Boolean(o && o.at > W.startedAt && !o.ids.has(id)); };
1138
+ const walkSummaryLines = (W, head) => {
1139
+ const sending = R.current.walkSend && W.current && !W.done.has(W.current) ? W.current : null;
1140
+ const left = W.ids.filter(id => !W.done.has(id) && id !== sending).map(id => W.items[id].title || id);
1141
+ const lines = walkSummary({ answered: W.answered, skipped: W.skipped.map(id => W.items[id].title || id), left });
1142
+ if (head) lines[0] = lines[0].replace(/^Walkthrough done/, head);
1143
+ if (sending) lines.push(` … still sending: ${W.items[sending].title || sending}`);
1144
+ return { lines, open: left.length + W.skipped.length };
1145
+ };
1146
+ const walkEnd = (why) => { // Esc, a command, or a card left too long: the walk stops here and says where it stands
1147
+ const W = R.current.pendingWalk; if (!W) return false;
1148
+ R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); walkCloseCard(W, 'stop');
1149
+ const { lines, open } = walkSummaryLines(W, `Walkthrough stopped (${why})`);
1150
+ say(' ');
1151
+ lines.forEach((l, k) => say(l, k === 0 ? 'ok' : undefined));
1152
+ if (open) say('Nothing else was sent. /walkthrough picks up what is still waiting.', 'dim');
1153
+ queueMicrotask(() => reconcileAsks());
1154
+ return true;
1155
+ };
1156
+ const walkShow = () => {
1157
+ endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
1158
+ const W = R.current.pendingWalk; if (!W) return;
1159
+ walkCloseCard(W); // the card just answered or skipped settles; only the next one is open
1160
+ const gone = walkAnsweredIds();
1161
+ let nextId = null;
1162
+ for (const id of W.ids) {
1163
+ if (W.done.has(id)) continue;
1164
+ if (gone.has(id)) { W.done.add(id); continue; } // answered in this window already: never shown again
1165
+ { const ex = askExpiry(W.items[id]); if (ex && ex <= Date.now()) { W.done.add(id); (W.dropped ||= new Set()).add(id); refreshNotify(); say(`"${clip(W.items[id].title || id, 60)}": ${expiredText(W.items[id])}`, 'accent'); continue; } }
1166
+ if (walkGoneElsewhere(W, id)) { W.done.add(id); say(`"${clip(W.items[id].title || id, 70)}" is no longer waiting (answered elsewhere or expired). Moving on.`, 'dim'); continue; }
1167
+ nextId = id; break;
1168
+ }
1169
+ if (!nextId) { R.current.pendingWalk = null; R.current.focusedDecision = null; K.current.walkEndAt = Date.now(); say(' '); for (const l of walkSummaryLines(W).lines) say(l, l.startsWith('Walkthrough') ? 'ok' : undefined); queueMicrotask(() => reconcileAsks()); return; }
1170
+ const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
1171
+ const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
1172
+ W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
1173
+ say(' ');
1174
+ c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
1175
+ ask('Your choice, with an optional note', { who: it.payload.session || '', action: '', reason: '', keys: '· Esc stops', plain: true });
1176
+ };
1177
+ const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
1178
+ let decs = (list || []).filter(isDecision);
1179
+ if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
1180
+ try { const open = new Set((await api.approvals()).map(x => x.id)); decs = decs.filter(x => open.has(x.id)); } catch { /* slow server: the answered-in-this-window set below still applies */ }
1181
+ }
1182
+ const gone = walkAnsweredIds();
1183
+ decs = decs.filter(x => !gone.has(x.id));
1184
+ const W0 = R.current.pendingWalk;
1185
+ if (W0) { // one walk at a time: a second start joins the walk on screen, never a second card
1186
+ const add = decs.filter(x => !W0.ids.includes(x.id));
1187
+ for (const x of add) { W0.ids.push(x.id); W0.items[x.id] = x; }
1188
+ if (add.length) say(`${add.length} more question${add.length === 1 ? '' : 's'} added to this walkthrough (now ${W0.ids.length}).`, 'dim');
1189
+ return undefined;
1190
+ }
1191
+ if (!decs.length) return say('No question is waiting for you.');
1192
+ R.current.pendingWalk = { ids: decs.map(x => x.id), items: Object.fromEntries(decs.map(x => [x.id, x])), done: new Set(), answered: [], skipped: [], current: null, card: null, at: Date.now(), startedAt: Date.now() };
1193
+ if (!quiet) say(`${decs.length} question${decs.length === 1 ? '' : 's'} waiting for you. One at a time; answer with a choice, then an optional note; Esc stops. Risky ones (high risk, test changes, restarts) always come here, one at a time.`, 'dim'); // /approvals already said it once in its own header
1194
+ walkShow();
1195
+ return undefined;
1196
+ };
1197
+ // NOTE-CONFIRM (transcript review 2026-10-17): an answer with a note can contradict its option, so every note is confirmed first, unless it starts with "fyi".
1198
+ const noteNeedsConfirm = (note) => { const t = String(note ?? '').trim(); return Boolean(t && !/^fyi\b/i.test(t) && /\b(not|no|never|without|only|except|instead|unless|limit\w*|max\w*|at most|at least)\b|n't\b|\d/i.test(t)); }; // only a note that could change the choice asks first; a plain comment goes through with the note attached
1199
+ const noteCard = (st) => {
1200
+ say(' ');
1201
+ say(st.batch ? `You are answering ${st.count} questions (${st.label}), and your note says: "${clip(st.note, Math.max(30, cols - 50))}".` : `You chose (${st.key}) ${st.label}, and your note says: "${clip(st.note, Math.max(30, cols - 40))}".`, 'warn');
1202
+ say(`A note can change what you mean${st.batch ? ', and this one would go with every answer' : ''}. Send it as:`);
1203
+ say(` [1] the note wins (recommended) [2] the option${st.batch ? 's' : ''} as written${st.batch ? '' : `, (${st.key}) ${clip(st.label, 40)}`} [3] edit`);
1204
+ say(' y or Enter sends 1 · n sends 2 · e edits');
1205
+ R.current.noteAsk = { ...st, at: Date.now() };
1206
+ bump(n => n + 1);
1207
+ };
1208
+ const noteResolve = async (choice, st) => {
1209
+ if (choice === '3') { edit({ type: 'set', text: st.walk ? `${st.key} ${st.note}` : `/answer ${st.cmd} "${st.note}"` }); return; }
1210
+ const note2 = choice === '1' ? `[note overrides the option as written (${st.label})] ${st.note}` : `[sent as the option as written; the note did not change it] ${st.note}`;
1211
+ R.current.noteBypass = true;
1212
+ try { if (st.walk) await walkHandle(`${st.key} ${note2}`); else await command(`/answer ${st.cmd} "${note2}"`); }
1213
+ finally { R.current.noteBypass = false; }
1214
+ };
1215
+ const walkHandle = async (text) => {
1216
+ const W = R.current.pendingWalk; const it = W?.items[W.current];
1217
+ if (!it) { R.current.pendingWalk = null; return false; }
1218
+ dispatch({ type: 'walk.reply', text, id: it.id }); // the transcript keeps what was typed at each card
1219
+ if (R.current.walkSend) { // the last answer is still on its way: this line must not land on the card just answered
1220
+ edit({ type: 'set', text });
1221
+ say(`Still sending your answer to "${clip(it.title || it.id, 60)}". Your line is kept in the box; press Enter when the next card shows.`, 'warn');
1222
+ return true;
1223
+ }
1224
+ if (Date.now() - W.at >= WALK_TTL) { say('That card sat open for 30 minutes, so nothing was sent. Here it is again:', 'warn'); walkShow(); return true; }
1225
+ const rep = walkReply(it, text);
1226
+ if (!rep) { say(walkNoCatch(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1227
+ if (rep.notYesNo) { say(walkNotYesNo(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1228
+ if (rep.defer) { W.done.add(it.id); W.skipped.push(it.id); walkShow(); return true; }
1229
+ if (rep.ask) { W.at = Date.now(); submit(`Explain this decision to me in two plain sentences and say what you would do, about THIS one only. Title: ${it.title}. Asked by: ${it.payload.session || 'a session'}. Why: ${it.payload.why || 'not given'}. Options: ${it.payload.options.map(o => `${o.label} (${o.key})`).join('; ')}. Decision id: ${it.id}.`); return true; }
1230
+ const n = S.current.inbox.findIndex(x => x.id === it.id) + 1; // routed by id; the position is only looked up at the moment of sending
1231
+ if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
1232
+ if (askExpiry(it) && askExpiry(it) <= Date.now()) { W.done.add(it.id); (W.dropped ||= new Set()).add(it.id); refreshNotify(); say(expiredText(it), 'accent'); walkShow(); return true; }
1233
+ if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
1234
+ const o = it.payload.options.find(x => x.key === rep.key);
1235
+ noteCard({ walk: true, key: rep.key, label: o?.label || rep.key, note: rep.note, id: it.id });
1236
+ return true;
1237
+ }
1238
+ R.current.lastAnswered = null;
1239
+ R.current.walkSend = true;
1240
+ try { await command(`/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
1241
+ if (R.current.lastAnswered === it.id) {
1242
+ walkAnsweredIds().add(it.id);
1243
+ W.done.add(it.id); W.answered.push({ title: it.title, word: `${it.payload.options.find(o => o.key === rep.key).label}${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
1244
+ if (R.current.pendingWalk === W) walkShow(); // stopped while it was sending: the answer stands, no further card
1245
+ } else if (R.current.goneAsk === it.id) { // the server says it is gone: nothing to answer, on to the next card
1246
+ R.current.goneAsk = null; W.done.add(it.id); walkAnsweredIds().add(it.id); refreshNotify();
1247
+ if (R.current.pendingWalk === W) walkShow();
1248
+ } else if (R.current.pendingWalk === W) { say('Not answered; the question stays. Say a choice again, s to skip, or Esc to stop.', 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; }
1249
+ return true;
1250
+ };
1251
+ const answer = async (card, action, { proactive = false, batch = false, reason = null, bulk = false } = {}) => {
1252
+ if (R.current.answering?.has(card.callId)) return;
1253
+ (R.current.answering ||= new Set()).add(card.callId);
1254
+ try {
1255
+ if (!card.inboxItemId && card.approvalId && !batch) { // an event card that never learned its inbox id: find it, never answer null
1256
+ try { card = { ...card, inboxItemId: (await api.approvals()).find(i => (i.payload?.approval_id || i.approval_id || i.aee_correlation_id) === card.approvalId)?.id || null }; } catch { /* falls through to the 404 path */ }
1257
+ }
1258
+ await respondWithStepUp(card, action, { proactive, reason, bulk });
1259
+ // The demo's stream carries on to its own tool result, so no "running, result later" note, no poll, no stale count.
1260
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1261
+ if (demoLabel) { B.current = initialBuffer(); dispatch({ type: 'notify', patch: { approvals: 0, error: null } }); } // (a stray early keystroke is not left in the composer)
1262
+ if (action === 'approve' && card.tool !== 'hat.switch' && !card.foreign && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1263
+ return { ok: true };
1264
+ } catch (e) {
1265
+ if (e?.status === 404 || (e?.status === 400 && /not open for response/i.test(`${e?.code || ''} ${e?.body?.error || ''} ${e?.message || ''}`))) { // already resolved elsewhere (another window, /approve all, the dashboard)
1266
+ if (!batch) dispatch({ type: 'approval.gone', callId: card.callId, inboxItemId: card.inboxItemId || null });
1267
+ return { gone: true };
1268
+ }
1269
+ if (batch) throw e;
1270
+ dispatch({ type: 'error', text: `approval: ${e.message}` });
1271
+ }
1272
+ finally { R.current.answering.delete(card.callId); }
1273
+ };
1274
+
1275
+ // ---- turn runner ---------------------------------------------------------------------------
1276
+ // Z2H-R2B: with no AI connected a turn is answered here, whole and wrapped, without a server call.
1277
+ async function aiConnected() {
1278
+ if (!api.connections) return null;
1279
+ try {
1280
+ const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
1281
+ return anyProviderConnected(vault, caps);
1282
+ } catch { return null; }
1283
+ }
1284
+ // Z2H-3B: a successful /connect makes the new provider the active choice when the current one is not a working
1285
+ // connected provider (session + the saved default). Another working provider stays active.
1286
+ async function adoptConnected(id, { host = null, model = null } = {}) {
1287
+ try {
1288
+ if (!api.connections) return;
1289
+ const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
1290
+ const a = adoptAfterConnect({ vault, caps, current: MC.current, id, host, model });
1291
+ if (!a.adopt) return;
1292
+ MC.current = a.choice; api.setModelChoice?.(a.choice);
1293
+ R.current.noAi = false;
1294
+ dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null, auth: null } });
1295
+ say(a.line, 'ok');
1296
+ bump(n => n + 1);
1297
+ } catch { /* the connect itself succeeded; the hint line already says /model */ }
1298
+ }
1299
+ async function noAiAnswer(text) {
1300
+ if (R.current.noAi === false) return false;
1301
+ const st = await aiConnected();
1302
+ R.current.noAi = st === false ? true : st === true ? false : undefined;
1303
+ if (!R.current.noAi) { bump(n => n + 1); return false; }
1304
+ if (asksAddHost(text)) for (const l of await hostLines()) say(l);
1305
+ else if (asksWatchHosts(text)) {
1306
+ // Z2H-R4: watching starts with a host on the list; that part needs no AI, the answers about it do.
1307
+ for (const l of await hostLines()) say(l);
1308
+ for (const l of NO_AI_CARD) say(l, 'warn');
1309
+ } else for (const l of NO_AI_CARD) say(l, 'warn');
1310
+ bump(n => n + 1);
1311
+ return true;
1312
+ }
1313
+
1314
+ // QUOXON-FW4: a queued follow-up reaches the model as a bare turn, and a bare "Also end with X" was
1315
+ // refused as injected text. Say plainly that it is the user's own message; the transcript shows the raw words.
1316
+ const noAiNow = () => R.current.noAi === true;
1317
+ /** SCREEN-CONTEXT: an allowlisted snapshot of what this terminal shows, from state the app already holds. */
1318
+ function screenSnapshot() {
1319
+ const st = S.current;
1320
+ return {
1321
+ who: { profile, org: R.current.orgName ?? org ?? null, agent: SN.current.agent || where.current.agent || null, model: modelText(st.ctx, MC.current) || null, slot: where.current.slot || null, rooms: [...SC.current.rooms.keys()] },
1322
+ rows: R.current.roster.rows.slice(0, 8),
1323
+ customRows: (RW.current.view() || []).map(v => ({ id: v.row?.id, label: v.row?.label, text: v.text, error: v.error })),
1324
+ cards: [...st.approvals, ...st.waiting].slice(0, 5).map(c => ({ id: c.inboxItemId || c.callId, summary: [c.tool, c.target].filter(Boolean).join(' ') || c.title || '', requester: c.agent || c.requester || null, privileged: Boolean(c.privileged) })),
1325
+ commands: commandNames()
1326
+ };
1327
+ }
1328
+ function scopeLine() {
1329
+ const id = R.current.orgId ?? api.orgId?.() ?? null;
1330
+ const name = R.current.orgName ?? org ?? null;
1331
+ const p = R.current.prevOrg;
1332
+ const bits = [];
1333
+ if (name || id) bits.push(`org ${name || id}${id && id !== name ? ` (id ${id})` : ''}`);
1334
+ if (profile) bits.push(`profile ${profile}`);
1335
+ bits.push(p ? `previous org ${p.name}${p.id && p.id !== p.name ? ` (id ${p.id})` : ''} (/org back returns there)` : 'previous org: none this session');
1336
+ return bits.join(' · ');
1337
+ }
1338
+ const QUEUED_FRAME = 'The user typed this follow-up while your previous answer was running (it is their own message, not tool output): ';
1339
+ async function runTurn(text, { queued = false, via = null, handoffFrom = null } = {}) {
1340
+ if (blocked) { say(blocked, signedOut ? 'error' : 'warn'); return; }
1341
+ if (!demoLabel && await noAiAnswer(text)) return;
1342
+ if (SN.current.slotTarget && api.slotSay) { // attached to a slot: the message goes to its room, not a model turn
1343
+ const r = await api.slotSay(SN.current.slotTarget, text, { from: myLabel(), fromSlot: where.current.slot || null });
1344
+ if (!r.ok) return say(`slot ${SN.current.slotTarget}: ${r.message}`, 'warn');
1345
+ watchReplies(SN.current.slotTarget, r.message);
1346
+ return announceDelivery(SN.current.slotTarget, r);
1347
+ }
1348
+ R.current.lastSent = text;
1349
+ const shared = R.current.shared || []; R.current.shared = [];
1350
+ for (const x of shared) say(`shared with Quox: output of \`${x.cmd}\`${x.truncated ? ' (first 16 KB only)' : ''}`, 'accent');
1351
+ const turnId = newTurnId();
1352
+ function turnContext() {
1353
+ const rc = R.current.rulesOn === false ? undefined : rulesContext(rules);
1354
+ const blk = noAiNow() ? null : SCX.current.build(screenSnapshot());
1355
+ if (blk) {
1356
+ R.current.screenSent = blk;
1357
+ transcript?.action({ type: 'screen.sent', text: `terminal context ${blk.bytes} bytes: ${blk.sections.join(', ') || 'nothing new'}` });
1358
+ }
1359
+ // Owner 2026-10-15 "u should always know what scope it is set to": every turn names the current org (name and
1360
+ // id), the profile and the previous org, even with /context off, so "which org am I in" and "switch back" are
1361
+ // answered from this, never from history.
1362
+ const scope = scopeLine();
1363
+ if (!blk?.text) return scope ? { ...(rc || {}), scope } : rc;
1364
+ return { ...(rc || {}), terminal: blk.text, ...(scope ? { scope } : {}) };
1365
+ }
1366
+ const open = (signal) => api.stream({ turnId, message: withShared(queued ? QUEUED_FRAME + text : text, shared), agent: via || SN.current.agent || 'quox', conversationId: S.current.ctx.conversationId, ...(handoffFrom ? { handoffFrom } : {}), heldSlot: where.current.slot ? { name: where.current.slot, session: slotSession(), display: myDisplay() } : null, context: turnContext(), overrideId: takeOverride(), ...(MD.current === 'plan' ? { mode: 'plan' } : {}), ...streamFields(MC.current) }, signal);
1367
+ // QUOXON-FW3: the server was unreachable before it said anything (a redeploy). Keep the message, wait for it
1368
+ // to come back, and send once. Only a send that produced NO event is ever retried, so nothing runs twice.
1369
+ let r = await consumeTurn(turnId, open, { keepOnDown: true });
1370
+ if (!r?.down) return r;
1371
+ say(SERVER_RESTARTING, 'warn');
1372
+ for (let round = 0; r?.down && round < 3; round++) {
1373
+ const ctrl = new AbortController();
1374
+ R.current.abort = ctrl; R.current.waitingServer = { text };
1375
+ const res = await waitForServer({ health: (ms) => api.health(ms), signal: ctrl.signal, delays: resendDelays });
1376
+ R.current.waitingServer = null; R.current.abort = null;
1377
+ if (res !== 'up') {
1378
+ dispatch({ type: 'turn.deferred' });
1379
+ edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text });
1380
+ return say(res === 'cancelled' ? 'Cancelled. Your message is back in the composer; it was not sent.' : 'The Quox server did not come back in time. Your message is back in the composer; it was not sent.', 'warn');
1381
+ }
1382
+ r = await consumeTurn(turnId, open, { keepOnDown: true });
1383
+ }
1384
+ if (r?.down) { dispatch({ type: 'turn.deferred' }); edit({ type: 'set', text: B.current.text ? `${text}\n${B.current.text}` : text }); say('The server keeps dropping the connection. Your message is back in the composer; it was not sent.', 'warn'); }
1385
+ return r;
1386
+ }
1387
+
1388
+ // QUOXON C5: render a turn that is already running on the server (after /resume, --conversation or a
1389
+ // restart). Replays it from the start, then follows it live; exactly once by seq.
1390
+ async function followTurn(conversationId, turnId) {
1391
+ if (S.current.turn || !api.reattach) return;
1392
+ say('A turn is still running in this conversation; reattaching to it.', 'dim');
1393
+ return consumeTurn(turnId, (signal) => api.reattach({ conversationId, turnId, after: 0 }, signal));
1394
+ }
1395
+ async function reattachIfRunning(conversationId) {
1396
+ if (!conversationId || !api.runningTurn || S.current.turn) return;
1397
+ const r = await api.runningTurn(conversationId);
1398
+ if (r?.running && r.turnId && !S.current.turn && S.current.ctx.conversationId === conversationId) followTurn(conversationId, r.turnId);
1399
+ }
1400
+
1401
+ // The assistant handed us a slash command (terminal_suggest_command). Vet it here; the y/N answer runs it as if typed.
1402
+ function offerSuggestion(ev) {
1403
+ const seen = (R.current.suggestSeen ||= new Set());
1404
+ if (ev.call_id && seen.has(ev.call_id)) return; // a replayed event must not ask twice
1405
+ if (ev.call_id) seen.add(ev.call_id);
1406
+ const v = vetSuggestion(ev.suggest_command);
1407
+ if (!v.ok) { say(`Not offered: ${clip(String(ev.suggest_command).slice(0, 60), 60)} (${v.reason}).`, 'warn'); return; }
1408
+ if (v.readOnly && ev.suggest_explicit === true) { say(`ran ${v.line}`, 'dim'); command(v.line); return; }
1409
+ R.current.suggestAsk = { line: v.line, at: Date.now() };
1410
+ // Asked AFTER the answer, last thing on screen (owner 2026-10-15: mid-stream it scrolled away above the reply).
1411
+ R.current.suggestLine = askText(`Run ${clip(v.line, cols - 20)}?`, { who: askerName(), action: v.line, reason: ev.suggest_reason ? String(ev.suggest_reason).replace(/[.\s]+$/, '') : '' });
1412
+ }
1413
+ // QUOXON-ASK safety net: the answer named ONE runnable command but the model did not offer it: offer it here, same ask.
1414
+ function offerNamed(text) {
1415
+ const v = namedCommand(text);
1416
+ if (!v) return;
1417
+ R.current.suggestAsk = { line: v.line, at: Date.now() };
1418
+ R.current.suggestLine = askText(`Run ${clip(v.line, cols - 20)}?`, { who: askerName(), action: v.line, reason: 'Quox named this command in its answer' });
1419
+ }
1420
+ function flushSuggestion() {
1421
+ const t = R.current.suggestLine; if (!t) return;
1422
+ R.current.suggestLine = null;
1423
+ if (R.current.suggestAsk) R.current.suggestAsk.at = Date.now(); // the answer window starts when it is shown
1424
+ say(t, 'ask');
1425
+ }
1426
+
1427
+ // QUOXON handoff offer: the SAME question to the suggested agent for this one turn; the window stays attached.
1428
+ async function runHandoff(a) {
1429
+ say(`↪ Asking ${a.h.name} this one: ${clip(a.text, Math.max(20, cols - 30))}`, 'dim');
1430
+ R.current.handoffTurn = true; // the handed-over turn answers without a suggestion; that must not break the streak
1431
+ try { await runTurn(a.text, { via: a.h.agent, handoffFrom: a.h.from }); } finally { R.current.handoffTurn = false; }
1432
+ say(handoffAnsweredNote(a.h), 'dim');
1433
+ bump(n => n + 1);
1434
+ }
1435
+ async function consumeTurn(turnId, open, { keepOnDown = false } = {}) {
1436
+ R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
1437
+ R.current.durable = false; // learned from the server: v1.1 events carry a seq
1438
+ dispatch({ type: 'turn.begin', turnId });
1439
+ const ctrl = new AbortController();
1440
+ R.current.abort = ctrl;
1441
+ let offered = false, sawEvent = false, down = false;
1442
+ let answerText = '', suggested = false, gate = null; // the safety net reads the answer when no suggestion came
1443
+ try {
1444
+ for await (const ev of open(ctrl.signal)) {
1445
+ if (ctrl.signal.aborted) break;
1446
+ sawEvent = true;
1447
+ if (typeof ev?.seq === 'number') R.current.durable = true;
1448
+ LG.current.observe(ev);
1449
+ dispatch({ type: 'event', ev });
1450
+ if (ev.type === 'tool.finished' && ev.suggest_command) { suggested = true; offerSuggestion(ev); }
1451
+ if (ev.type === 'turn.handoff_suggested') { const ho = handoffFromEvent(ev, SN.current.agent); if (ho) R.current.handoffPending = { h: ho, text: R.current.lastSent || '' }; } // QUOXON handoff offer
1452
+ if (ev.type === 'chunk' || ev.type === 'text.delta') { const g = textGate(gate, ev); gate = g.st; if (g.append && answerText.length < 20000) answerText += String(ev.content); }
1453
+ // Z2H-3B: a server default that is not connected must not linger in the status bar.
1454
+ if (ev.type === 'error' && /No AI is connected/i.test(String(ev.error || ev.message || ''))) {
1455
+ R.current.noAi = true;
1456
+ dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null, auth: null } });
1457
+ }
1458
+ const fb = fallbackNote(MC.current, ev);
1459
+ if (fb) say(fb, 'warn');
1460
+ const offer = connectOfferFor(ev);
1461
+ if (offer && !offered) { offered = true; say(offer); }
1462
+ if (isConsentError(ev)) { // the words are in the error block; here the link and the way back
1463
+ R.current.consent = ev.retryable ? null : { provider: ev.provider || null, text: R.current.lastSent || null };
1464
+ let base = null; try { base = await api.dashboardBase?.(); } catch { base = null; }
1465
+ for (const l of consentHintLines(ev, base)) say(l, 'dim');
1466
+ }
1467
+ }
1468
+ if (S.current.turn?.id === turnId) {
1469
+ // stream closed without a terminal event
1470
+ // never claim completion the server did not confirm
1471
+ dispatch({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
1472
+ }
1473
+ } catch (e) {
1474
+ if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
1475
+ else if (!ctrl.signal.aborted) {
1476
+ // FW4: settle in-flight tools explicitly (never leave a row "running"), then explain the cut with a way forward.
1477
+ if (e?.status === 423 && typeof e?.body?.block_id === 'string') { // QUOXON-STEPUP: a policy block; a person may override it once
1478
+ R.current.lastBlock = { blockId: e.body.block_id, agentId: e.body.safety?.agent || null, text: String(e.body.message || e.message || '').slice(0, 200) };
1479
+ say(`Blocked by policy (${e.body.block_id}). A person can override this one action: /override last <reason>`, 'warn');
1480
+ }
1481
+ // TUI-DIVIDER: a refusal before the stream opened names the layers it really evaluated; the rule lights them.
1482
+ if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatch({ type: 'event', ev: { type: 'aocl.layer', ...l } });
1483
+ const why = sawEvent ? explainTermination(e) : null;
1484
+ if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatch({ type: 'event', ev: { type: 'tool.finished', call_id: b.callId, tool: b.tool, status: 'interrupted', result_summary: 'connection lost before a result; verify before retrying' } });
1485
+ const dead = !why && (e?.status === 401 || isSessionDead(e));
1486
+ dispatch({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
1487
+ if (dead) markSignedOut(e?.reason || null);
1488
+ if (why) say(RETRY_HINT, 'warn');
1489
+ // A lost reattach is not a failed turn: the server may still be finishing it (C5).
1490
+ if (S.current.turn?.id === turnId) dispatch({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
1491
+ }
1492
+ } finally {
1493
+ clearTimeout(R.current.fallbackTimer);
1494
+ LG.current.end();
1495
+ if (!down) R.current.abort = null;
1496
+ if (!down && S.current.turn?.id === turnId) dispatch({ type: 'stopped.local' });
1497
+ const planDone = MD.current === 'plan' && !down && !ctrl.signal.aborted && Boolean(answerText);
1498
+ if (!suggested && !down && !ctrl.signal.aborted && answerText && MD.current !== 'plan') offerNamed(answerText); // plan mode: the plan card below is the one next step
1499
+ // QUOXON handoff offer: the attached agent lacks the tools for this ask. The handoff card answers the actual question,
1500
+ // so it wins over the agent's own suggested command (owner live 22:42: "Run /vm list?" took the slot and the handoff
1501
+ // card was dropped silently). Nothing is dropped silently: a superseded or skipped card says so in one line.
1502
+ const hp = R.current.handoffPending; R.current.handoffPending = null;
1503
+ const hpOk = Boolean(hp && !down && !ctrl.signal.aborted && hp.text);
1504
+ if (hpOk && !planDone && R.current.suggestLine) {
1505
+ const line = R.current.suggestAsk?.line || '';
1506
+ R.current.suggestLine = null; R.current.suggestAsk = null;
1507
+ say(handoffSupersededNote(hp.h, line), 'dim');
1508
+ }
1509
+ const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone);
1510
+ flushSuggestion();
1511
+ if (hpOk && planDone) say(handoffSkippedNote(hp.h), 'dim');
1512
+ else if (hpOk) {
1513
+ const hs = (R.current.handoffStreak ??= createHandoffStreak()).offered(hp.h.from);
1514
+ if (hs.escalate) { R.current.handoffAsk = { ...hp, at: Date.now(), bigger: true, count: hs.count }; say(handoffBiggerAskText(hp.h, hs.count), 'ask'); } // same agent, 2+ turns in a row: offer to move the window
1515
+ else { R.current.handoffAsk = { ...hp, at: Date.now() }; say(handoffAskText(hp.h, hp.text, cols), 'ask'); }
1516
+ }
1517
+ if (!hpOk && !down && !ctrl.signal.aborted && !R.current.handoffTurn) R.current.handoffStreak?.clear(); // a turn with no suggestion breaks the streak
1518
+ if (planDone) { R.current.planAsk = { at: Date.now() }; ask('Run this plan now?', { who: askerName(), action: `switch to /mode ask and send "${PLAN_GO}"` }); }
1519
+ if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
1520
+ const ra = R.current.reask; R.current.reask = null; // y/N/c: the chat about an ask is over: the same ask again, fresh window
1521
+ if (ra && !newOffer && askOpen()) { touchAsks(); if (ra.redo) ra.redo(); else say(ra.text, 'ask'); }
1522
+ }
1523
+ return down ? { down: true } : undefined;
1524
+ }
1525
+
1526
+ React.useEffect(() => {
1527
+ if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length) wakeDragon(); // an approval always wakes it
1528
+ const s = S.current;
1529
+ if (!s.turn && s.autoDrain && s.pending.length) {
1530
+ const next = s.pending[0];
1531
+ dispatch({ type: 'queue.shift' });
1532
+ runQueued(next.text);
1533
+ }
1534
+ });
1535
+
1536
+ // A queued or steered draft that is a slash command runs as that command when its turn comes; it is never sent as chat.
1537
+ function runQueued(text, { checked = false } = {}) {
1538
+ if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
1539
+ const stray = checked ? null : strayCardAnswer(text, commandNames()); // TUI-INPUT: "n/help" = a card answer that leaked into the composer
1540
+ if (stray) { R.current.strayAsk = { text, command: stray.command, at: Date.now() }; ask(strayQuestion(stray)); return; }
1541
+ dispatch({ type: 'user', text, queued: true });
1542
+ runTurn(text, { queued: true });
1543
+ }
1544
+
1545
+ function echoCmd(text) { // a command the human typed (or confirmed): shown like any prompt line, before its output
1546
+ const e = echoLine(text, { cols });
1547
+ dispatch({ type: 'user', text: e.shown, full: e.full, echo: true });
1548
+ }
1549
+
1550
+ // PRESENCE (owner 2026-10-16): /afk and /sleep tell the server, so sessions and LLMs know you are not at this
1551
+ // terminal (approvals still queue; nothing is approved). /back or any typed turn marks you present.
1552
+ async function setPresence(state, note) {
1553
+ if (!api.setPresence) return { ok: true, local: true }; // demo / offline: the local mark is all there is
1554
+ try { return await api.setPresence(state, { note: note || undefined, slots: where.current.slot ? [where.current.slot] : undefined }); }
1555
+ catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
1556
+ }
1557
+ const presenceFail = (r) => say(`Sessions can't see that (${r.status ? `HTTP ${r.status}` : r.message || 'failed'}): this window still collects for /back, but other sessions do not know.`, 'warn');
1558
+ function markPresent(action = 'chat') {
1559
+ if (/^\/(afk|sleep|back|away)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
1560
+ if (AWAY.current.afk) { // this window set the flag: a typed turn here is the owner back (explicit, as before)
1561
+ AWAY.current.afk = null;
1562
+ setPresence('present').then((r) => { if (!r.ok) presenceFail(r); }, () => {});
1563
+ return;
1564
+ }
1565
+ // PRESENCE (owner 2026-10-19): a window that never set the flag (restarted TUI) still clears the server's stale one,
1566
+ // but only from a real terminal (api.noteActivity is a no-op for --print, pipes and proof runs). Welcome shows once.
1567
+ if (!api.noteActivity) return;
1568
+ api.noteActivity(action).then((r) => {
1569
+ if (r?.welcome?.away_text && !AWAY.current.welcomed) { AWAY.current.welcomed = true; say(`✔ Welcome back, you were away ${r.welcome.away_text}`, 'ok'); }
1570
+ }, () => {});
1571
+ }
1572
+ function submit(text) {
1573
+ touchSeen();
1574
+ markPresent(/^\//.test(text.trim()) ? `slash ${text.trim().split(/\s+/)[0]}` : 'chat');
1575
+ dispatch({ type: 'user', text });
1576
+ const tip = intentHint(text); // a hint only: the turn still goes to the model
1577
+ if (tip) say(tip);
1578
+ const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
1579
+ if (panel) showPanel(panel, { local: true });
1580
+ remember(text);
1581
+ R.current.histIdx = -1;
1582
+ runTurn(text);
1583
+ }
1584
+
1585
+ async function steer(text) {
1586
+ const id = `steer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1587
+ const s = S.current;
1588
+ dispatch({ type: 'steer.add', id, text });
1589
+ const r = await api.steer(s.ctx.conversationId, s.turn.id, text, id);
1590
+ if (r.ok) return;
1591
+ // Put the text back in the draft (in front of anything typed since) instead of losing or replaying it.
1592
+ const restore = () => { const cur = B.current.text; edit({ type: 'set', text: cur ? `${text}\n${cur}` : text }); };
1593
+ if (r.reason === 'ambiguous') {
1594
+ dispatch({ type: 'steer.failed', id, reason: 'Steer may not have been applied; resend? Your text is back in the draft.' });
1595
+ restore();
1596
+ } else if (r.reason === 'denied') {
1597
+ dispatch({ type: 'steer.failed', id, reason: `Steering was denied by policy (${r.message || 'steer_denied_by_policy'}). Your text is back in the draft; it was not run.` });
1598
+ restore();
1599
+ } else {
1600
+ if (r.reason === 'unsupported') { R.current.caps.steer = false; R.current.steerNoted = true; bump(n => n + 1); }
1601
+ dispatch({ type: 'steer.converted', id, reason: r.reason === 'turn_finished'
1602
+ ? 'That turn already finished; queued your message as the next turn.'
1603
+ : STEER_OFF_NOTE });
1604
+ }
1605
+ }
1606
+
1607
+ async function interrupt() {
1608
+ if (R.current.waitingServer) { R.current.abort?.abort(); return; } // QUOXON-FW3: Esc cancels the wait; runTurn restores the text
1609
+ const s = S.current;
1610
+ if (!s.turn || s.turn.status === 'interrupting') return;
1611
+ dispatch({ type: 'interrupt.requested' });
1612
+ const r = await api.interrupt(s.ctx.conversationId, s.turn.id);
1613
+ const turnId = s.turn.id;
1614
+ const local = () => { if (S.current.turn?.id === turnId) { R.current.abort?.abort(); dispatch({ type: 'stopped.local' }); } };
1615
+ if (!r.ok) { R.current.caps.interrupt = false; return local(); }
1616
+ R.current.fallbackTimer = setTimeout(local, INTERRUPT_FALLBACK_MS);
1617
+ }
1618
+
1619
+ // ---- L1 slash commands (models in slash/*.js) ------------------------------------------------
1620
+ const help = (model) => dispatch({ type: 'help', model });
1621
+ const leaveArmed = (kind) => { // a running turn would be disconnected: ask once, a second ask within 5 s goes ahead
1622
+ if (!S.current.turn) return true;
1623
+ // C5: a durable turn (the server sends seq) keeps running when we leave; /resume reattaches.
1624
+ if (R.current.durable) { say('Quox keeps working; it will be here when you come back. /resume reattaches to the turn.', 'ok'); return true; }
1625
+ const t = R.current.leaveAsk;
1626
+ if (t && t.kind === kind && Date.now() - t.at < 5000) return true;
1627
+ R.current.leaveAsk = { kind, at: Date.now() };
1628
+ say(`A turn is running. ${kind === 'restart' ? 'Restarting' : 'Leaving'} disconnects it, and the server stops it before its next step (a tool already running finishes and is recorded). /${kind === 'restart' ? 'restart' : 'quit'} again within 5 s to go ahead, or wait for the turn.`, 'warn');
1629
+ return false;
1630
+ };
1631
+ async function agentsPicker(filter) {
1632
+ if (!api.agents) return say('Agents are not available in this mode.', 'warn');
1633
+ let list;
1634
+ try { list = await api.agents(); } catch (e) { return say(`Couldn't check agents: ${listFailure(e, profile)}`, 'warn'); }
1635
+ const rows = filterAgents(list, filter);
1636
+ if (!rows.length) return say(filter ? `No agent matches "${filter}". /agents list shows them all.` : 'No agents found.', 'warn');
1637
+ PK.current.open({ title: filter ? `Agents matching "${filter}"` : 'Agents', rows: rows.map(agentPickerRow), hint: '↑↓ select · Enter show card · Esc cancel', onPick: (_, i) => command(`/agents ${rows[i].agent_id}`) });
1638
+ return undefined;
1639
+ }
1640
+ async function agentTools(name) {
1641
+ if (!api.objects?.agent) return say('Object views are not available in this mode.', 'warn');
1642
+ let r;
1643
+ try { r = await api.objects.agent(name); } catch (e) { return say(`agent: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}`, 'warn'); }
1644
+ if (r?.error) return say(clip(r.error, cols - 4), r.tone || 'warn');
1645
+ for (const [t, tone] of toolLines(r.data)) say(clip(t, cols - 4), tone === 'plain' ? undefined : tone);
1646
+ return undefined;
1647
+ }
1648
+ function showWhich(arg) {
1649
+ const sl = where.current.slot || null;
1650
+ const r = whichReply(arg, {
1651
+ profile, agentLogin, org: R.current.orgName ?? org ?? null, agent: SN.current.agent || where.current.agent,
1652
+ hat: S.current.ctx.hat, model: modelText(S.current.ctx, MC.current),
1653
+ slot: SN.current.slotTarget ? `${SN.current.slotTarget} (chatting)` : sl, slotLive: Boolean(sl && SC.current.listener?.state?.().connected),
1654
+ conversationId: S.current.ctx.conversationId, sessionName: SN.current.name
1655
+ });
1656
+ if (r.error) return say(r.error, 'warn');
1657
+ if (r.rows.length === 1) return say(`${r.rows[0][0]}: ${r.rows[0][1]}`);
1658
+ return help([{ kind: 'head', text: 'Which' }, ...r.rows.map(([key, text]) => ({ kind: 'row', key, text }))]);
1659
+ }
1660
+ async function showPanel(cmd, { local = false } = {}) {
1661
+ const base = { ctx: S.current.ctx, choice: MC.current, agent: SN.current.agent || where.current.agent, conversationId: S.current.ctx.conversationId };
1662
+ if (cmd === '/cost') return help(costModel(LG.current));
1663
+ if (cmd === '/status') {
1664
+ let hl = R.current.lastHealth || null;
1665
+ if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; }
1666
+ return help(statusModel({ ...base, profile, org: orgShown(), serverUrl: api.serverUrl?.() || hl?.url || null, health: hl, slot: where.current.slot, sessionName: SN.current.name, version, local: local && !hl }));
1667
+ }
1668
+ if (cmd === '/context') {
1669
+ let skills = R.current.skillsInfo || null; let memory = R.current.memoryInfo || null;
1670
+ if (!local) {
1671
+ [skills, memory] = await Promise.all([
1672
+ api.skills ? api.skills().then(l => ({ ok: true, count: l.length }), e => ({ ok: false, error: e?.status ? `HTTP ${e.status}` : (e?.message || 'failed') })) : { ok: false, error: 'not available in this mode' },
1673
+ api.memoryStatus ? api.memoryStatus() : { ok: false, error: 'not available in this mode' }
1674
+ ]);
1675
+ R.current.skillsInfo = skills; R.current.memoryInfo = memory;
1676
+ }
1677
+ let server = null; let serverError = null;
1678
+ if (!local && base.conversationId && api.conversationContext) {
1679
+ try { server = contextFacts(await api.conversationContext(base.conversationId)); }
1680
+ catch (e) { serverError = e?.status === 404 ? 'no stored turns yet' : e?.status ? `HTTP ${e.status}` : (e?.message || 'failed'); }
1681
+ }
1682
+ return help(contextModel({ ...base, server, serverError, tokens: LG.current.conversationTokens(S.current.ctx.conversationId), rules, rulesOn: R.current.rulesOn !== false, skills, memory, screen: { on: SCX.current.on, last: R.current.screenSent || null } }));
1683
+ }
1684
+ return undefined;
1685
+ }
1686
+ /** gh #1157: a resume that meets a restarting collector says one calm line and retries with backoff (about 2 min); past the cap it rethrows marked gaveUp. */
1687
+ async function reconnecting(fn, st = {}) {
1688
+ for (let i = 0; ; i++) {
1689
+ try { return await fn(); } catch (e) {
1690
+ if (!isResumeRetryable(e) || R.current.closed) throw e;
1691
+ if (i >= resumeRetryDelays.length) { if (e && typeof e === 'object') e.reconnectGaveUp = true; throw e; }
1692
+ if (!st.noted) { st.noted = true; say(RESUME_RECONNECTING, 'dim'); }
1693
+ await new Promise(r => setTimeout(r, resumeRetryDelays[i]));
1694
+ if (R.current.closed) throw e;
1695
+ }
1696
+ }
1697
+ }
1698
+ function resumeGaveUp(id) { return say(`Quox server is still not answering after about 2 minutes. ${id ? `/resume ${id}` : '/resume'} to try again.`, 'warn'); }
1699
+ async function resumeTo(row, st = {}) {
1700
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
1701
+ let turns;
1702
+ try { turns = await reconnecting(() => api.conversationTurns(row.id), st); } catch (e) { if (R.current.closed) return undefined; if (e?.reconnectGaveUp) return resumeGaveUp(row.id); if (e?.status === 404) return say('That conversation is not on the server any more (it keeps them 7 days).', 'warn'); return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
1703
+ const t = turnsToHistory(turns);
1704
+ dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
1705
+ SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
1706
+ if (!t.total && SN.current.name) SN.current.saved = `${row.id}|${SN.current.name}`; // nothing found: do not re-stamp the saved name
1707
+ say(`Resumed "${clip(row.title, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
1708
+ dispatch({ type: 'history.add', items: t.items, answers: t.answers });
1709
+ if (!t.total && agentLogin && row.byName) {
1710
+ const h = humanProfiles.find(x => x !== profile);
1711
+ return say(`${row.byName} was saved by another profile. Start with quox -p ${h || '<human profile>'} to resume it.`, 'warn');
1712
+ }
1713
+ say(t.total ? `Showing the last ${t.shown} of ${t.total} stored messages. Your next message continues this conversation.` : 'The server has no stored messages for it (it keeps them 7 days). Your next message continues it anyway.', 'dim');
1714
+ return reattachIfRunning(row.id);
1715
+ }
1716
+ async function resumeCmd(q) {
1717
+ if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
1718
+ let rows; const st = {};
1719
+ try {
1720
+ const [list, index] = await reconnecting(() => Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]), st);
1721
+ rows = resumeRows(list, index, loadNames(namesPath));
1722
+ } catch (e) { if (R.current.closed) return undefined; if (e?.reconnectGaveUp) return resumeGaveUp(q ? String(q).trim() : null); return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
1723
+ if (q) {
1724
+ const m = matchConversation(rows, q);
1725
+ if (m.row) return resumeTo(m.row, st);
1726
+ if (!m.ambiguous) {
1727
+ // Beyond the last 30 listed: a full id, or an exact saved local name, resumes directly.
1728
+ const names = loadNames(namesPath); const want = String(q).trim();
1729
+ if (/^QXC_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(want)) return resumeTo({ id: want, title: names[want]?.name || want, agent: null }, st);
1730
+ const hit = pickNamed(names, want, profile);
1731
+ if (hit?.ambiguous) return say(`"${q}" matches ${hit.ambiguous.length} conversations (saved by different profiles): ${hit.ambiguous.map(x => x.slice(0, 12)).join(', ')}. Use more of the id.`, 'warn');
1732
+ if (hit) return resumeTo({ id: hit.id, title: hit.entry.name, agent: null, byName: want }, st);
1733
+ }
1734
+ return say(m.ambiguous ? `"${q}" matches ${m.ambiguous.length} conversations: ${m.ambiguous.map(x => x.slice(0, 12)).join(', ')}. Use more of the id.` : `No recent conversation matches "${q}". /resume lists them.`, 'warn');
1735
+ }
1736
+ if (!rows.length) return say('No recent conversations to resume.');
1737
+ const now = Date.now();
1738
+ PK.current.open({ title: 'Resume a conversation', rows: rows.map(r => pickerRow(r, now)), hint: '↑↓ select · Enter resume · Esc cancel', onPick: (_, i) => resumeTo(rows[i]) });
1739
+ return undefined;
1740
+ }
1741
+ // ---- /dragon explain | ask | sleep (dragonModes.js) -----------------------------------------------
1742
+ const dragonTalkVisible = (now = Date.now()) => {
1743
+ const d = R.current.dragonTalk;
1744
+ return Boolean(d) && (d.armed || !d.endedAt || now - d.endedAt < 4000);
1745
+ };
1746
+ /** One governed turn on whichever provider is selected, framed for the simplest possible answer. */
1747
+ function dragonTurn(label, prompt) {
1748
+ const d = R.current.dragonTalk = { since: Date.now(), endedAt: null, armed: false, label };
1749
+ say(label, 'ok');
1750
+ Promise.resolve(runTurn(prompt)).catch(() => {}).finally(() => { d.endedAt = Date.now(); bump(n => n + 1); });
1751
+ bump(n => n + 1);
1752
+ return true;
1753
+ }
1754
+ function dragonExplain() {
1755
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
1756
+ const o = lastOutput(S.current);
1757
+ if (!o) return say(NOTHING_TO_EXPLAIN);
1758
+ return dragonTurn(DRAGON_EXPLAINS, explainPrompt(o.text));
1759
+ }
1760
+ function dragonAsk(question) {
1761
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
1762
+ if (!question) { R.current.askArmed = true; R.current.dragonTalk = { since: Date.now(), endedAt: Date.now(), armed: true, label: ASK_PROMPT }; bump(n => n + 1); return true; }
1763
+ dispatch({ type: 'user', text: question });
1764
+ return dragonTurn(DRAGON_ANSWERS, askPrompt(question));
1765
+ }
1766
+ function dragonSleep() {
1767
+ if (!isInteractive) return say('/dragon sleep needs an interactive terminal.', 'warn');
1768
+ if (S.current.approvals.length) return say('An approval is waiting. Answer it first; the dragon will not sleep over it.', 'warn');
1769
+ R.current.sleep = { startedAt: Date.now(), wakeAt: null, still: !motionOn || mono() };
1770
+ bump(n => n + 1); return true;
1771
+ }
1772
+ function dragonFly() { // one pass of the dragon in flight in the live region; Esc stops it, a new call restarts it
1773
+ R.current.fly = { startedAt: Date.now(), still: !motionOn || mono() };
1774
+ bump(n => n + 1); return true;
1775
+ }
1776
+ const flyActive = (now = Date.now()) => { const f = R.current.fly; return Boolean(f) && now - f.startedAt < (f.still ? FLY_STILL_MS : FLY_PASS_MS + 400); };
1777
+ function wakeDragon() {
1778
+ const sl = R.current.sleep;
1779
+ if (!sl || sl.wakeAt != null) return;
1780
+ sl.wakeAt = Date.now();
1781
+ const end = () => { R.current.sleep = null; bump(n => n + 1); };
1782
+ if (sl.still) end(); else R.current.wakeTimer = setTimeout(end, WAKE_MS + 60);
1783
+ bump(n => n + 1);
1784
+ }
1785
+
1786
+ function openConfig() {
1787
+ const list = settingsList();
1788
+ const saved = api.settings ? api.settings() : (R.current.sessionSettings ||= {});
1789
+ const vals = { verbose: S.current.verbose, ...saved, sound: SND.current.settings?.().profile };
1790
+ // R3 D2: changes are staged in the card; Enter saves them, Esc (or typing) discards them.
1791
+ let staged = {};
1792
+ const apply = (it, v) => {
1793
+ vals[it.id] = v;
1794
+ if (it.id === 'sound') { SND.current.command(v, say); return; } // the sound controller saves its own profile
1795
+ const ok = api.saveSetting ? api.saveSetting(it.id, v) : ((R.current.sessionSettings ||= {})[it.id] = v, false);
1796
+ if (it.id === 'verbose') dispatch({ type: 'verbose', mode: v });
1797
+ // Theme and motion apply to what is drawn from now on; history already in scrollback keeps its look
1798
+ // (repainting it would print the whole transcript again).
1799
+ if (it.id === 'theme' || it.id === 'motion') applySettings(vals, env);
1800
+ R.current.configSaved = ok;
1801
+ };
1802
+ const commit = () => {
1803
+ const ids = Object.keys(staged);
1804
+ if (!ids.length) return;
1805
+ for (const id of ids) apply(list.find(x => x.id === id), staged[id]);
1806
+ const what = ids.map(id => `${id} ${staged[id]}`).join(', ');
1807
+ staged = {};
1808
+ say(`${api.saveSetting ? `Saved to profile ${profile}` : 'Applied for this session'}: ${what}.${ids.includes('theme') ? ' The theme applies to new output; earlier lines keep their colours.' : ''}`, 'ok');
1809
+ };
1810
+ PK.current.open({
1811
+ title: api.saveSetting ? `Settings · Enter saves to profile ${profile} · Esc cancels` : 'Settings · Enter applies for this session · Esc cancels',
1812
+ rowsFn: () => configRows(list, { ...vals, ...staged }, { model: modelLabel(S.current.ctx, MC.current) || 'auto' }).map((r, i) => (list[i].id in staged ? { ...r, note: 'unsaved · Enter saves' } : r)),
1813
+ hint: '↑↓ select · ←→ or Space change · Enter save · Esc cancel · typing goes to the composer',
1814
+ onAdjust: (i, dir) => {
1815
+ const it = list[i]; if (it.action) return;
1816
+ const now = it.id === 'motion' ? (normaliseLevel(vals.motion) || it.def) : (vals[it.id] ?? it.def); // F3: a pre-levels full/reduced profile
1817
+ const v = cycle(it, staged[it.id] ?? now, dir);
1818
+ if (v === now) delete staged[it.id]; else staged[it.id] = v;
1819
+ },
1820
+ onClose: () => { if (Object.keys(staged).length) say('Settings closed; nothing saved.', 'dim'); staged = {}; },
1821
+ onPick: (_, i) => { commit(); if (list[i].action === 'model') command('/model'); }
1822
+ });
1823
+ }
1824
+
1825
+ // ---- QUOXON-FW8: ! commands ---------------------------------------------------------------------
1826
+ function bangCommand(bang, { echoed = false } = {}) {
1827
+ if (bang.kind === 'reserved') return say('!! is reserved. Use ! followed by a command, e.g. !ls or !quox inbox list.', 'warn');
1828
+ if (bang.kind === 'empty') return say('Usage: !<command> runs it here, !quox <args> runs this quox as you. Output stays on your screen; /share sends it to Quox.', 'warn');
1829
+ let plan = { cmd: bang.cmd };
1830
+ if (bang.kind === 'quox') {
1831
+ plan = quoxArgv(bang.args, { profile });
1832
+ if (plan.refuse && !plan.tty) return say(plan.refuse, 'warn');
1833
+ }
1834
+ const cmd = bang.cmd;
1835
+ const timeoutMs = Number(env.QUOX_BANG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
1836
+ if (!echoed) say(`$ ${cmd}`, 'accent');
1837
+ // BANG-TTY (owner 2026-10-16): a known interactive command or an interactive quox leaf gets the terminal
1838
+ if (plan.tty || needsTerminal(bang)) return bangHandOver(cmd, plan.tty || plan);
1839
+ let pending = ''; let timer = null;
1840
+ const flush = (all) => {
1841
+ if (R.current.exiting) { pending = ''; return; }
1842
+ let out = pending;
1843
+ if (!all) { const i = out.lastIndexOf('\n'); if (i < 0) return; out = out.slice(0, i + 1); }
1844
+ pending = pending.slice(out.length);
1845
+ out = cleanOutput(out).replace(/\n+$/, '');
1846
+ if (out) dispatch({ type: 'bang.out', text: out });
1847
+ };
1848
+ const run = runBang(plan, { cwd: process.cwd(), env, timeoutMs, onData: (t) => { pending += t; if (!timer) timer = setTimeout(() => { timer = null; flush(false); }, 80); } });
1849
+ const slow = setTimeout(() => { if (R.current.bang?.run === run && !R.current.exiting) say('still running (Ctrl+C stops it)', 'accent'); }, 5000);
1850
+ R.current.bang = { run, cmd };
1851
+ run.done.then((r) => {
1852
+ clearTimeout(timer); clearTimeout(slow); timer = null; flush(true);
1853
+ R.current.bang = null;
1854
+ const results = R.current.bangResults ??= [];
1855
+ results.push({ cmd, text: r.text, truncated: r.truncated });
1856
+ if (results.length > 10) results.shift();
1857
+ transcript?.bang?.(cmd, r.timedOut ? 'timeout' : r.killed ? 'killed' : r.code);
1858
+ if (R.current.exiting) return;
1859
+ if (r.truncated) say(bangTruncNote(r), 'warn');
1860
+ if (ttyFailure(r)) { say(bangFooter(r, { timeoutMs }), 'warn'); return bangHandOver(cmd, plan); } // it wanted a terminal: retry handed over
1861
+ if (r.stdinNote) say(r.stdinNote, 'warn');
1862
+ say(bangFooter(r, { timeoutMs }), r.code === 0 && !r.timedOut && !r.killed ? 'ok' : 'warn');
1863
+ bump(n => n + 1);
1864
+ });
1865
+ }
1866
+
1867
+ /** BANG-TTY: run a ! command with the terminal handed over (the run-request path: raw mode and mouse off, back after). */
1868
+ function bangHandOver(cmd, plan) {
1869
+ say('This needs a real terminal: handing it over. Quit it to come back.', 'dim');
1870
+ const hold = { run: { kill: () => {}, done: null }, cmd, handover: true };
1871
+ R.current.bang = hold; // nothing else starts in the gap before the hand-over
1872
+ setTimeout(() => {
1873
+ if (R.current.bang === hold) R.current.bang = null;
1874
+ if (R.current.closed || R.current.exiting) return;
1875
+ const target = plan.argv ? { file: process.execPath, args: plan.argv } : { cmd: plan.cmd ?? cmd };
1876
+ const r = handOverRun({ ...target, env, cwd: process.cwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
1877
+ transcript?.bang?.(cmd, r.error ? 'error' : (r.signal || r.code));
1878
+ say(runFooter(r), !r.error && !r.signal && r.code === 0 ? 'ok' : 'warn');
1879
+ bump(n => n + 1);
1880
+ }, runHandOverDelayMs);
1881
+ return undefined;
1882
+ }
1883
+
1884
+ function cliSlashRun(hit) {
1885
+ if (hit.cls === 'never') return say(`${hit.display} will not run from here (credentials, install or other never-from-chat commands). Run it in your own terminal if you mean it.`, 'warn');
1886
+ const bang = { kind: 'quox', args: hit.args, cmd: hit.display };
1887
+ if (hit.cls === 'write') { R.current.cliAsk = { bang, at: Date.now() }; say(hit.display, 'accent'); return ask('Run it?', { action: hit.display, reason: 'This quox command writes.' }); }
1888
+ if (R.current.bang) return say('A command is still running. Ctrl+C stops it.', 'warn');
1889
+ say(`ran ${hit.display}`, 'dim');
1890
+ return bangCommand(bang);
1891
+ }
1892
+
1893
+ function shareCommand(arg) {
1894
+ const res = R.current.bangResults || [];
1895
+ if (!res.length) return say('Nothing to share yet. Run a command with ! first, e.g. !quox inbox list, then /share.', 'warn');
1896
+ const n = arg ? Number(arg) : 1;
1897
+ if (!Number.isInteger(n) || n < 1 || n > res.length) return say(`Usage: /share [N] where N is 1 (latest) to ${res.length}.`, 'warn');
1898
+ const r = res[res.length - n];
1899
+ const sh = shareText(r.text);
1900
+ R.current.shared = [...(R.current.shared || []).filter(x => x.cmd !== r.cmd), { cmd: r.cmd, text: sh.text, truncated: sh.truncated }];
1901
+ say(`Output of \`${r.cmd}\` will be shared with Quox with your next message${sh.truncated ? ' (first 16 KB only, it was longer)' : ''}.`, 'accent');
1902
+ }
1903
+
1904
+ /** /update (owner 2026-10-16): the shared updater (core/selfUpdate.js) checks, the y answer hands the terminal to it. */
1905
+ async function updateCommand() {
1906
+ if (!onRestart) return say('Update is not available in this mode.', 'warn');
1907
+ if (S.current.turn) return say('A turn is running. Wait for it, then /update.', 'warn');
1908
+ if (R.current.updateChecking) return say('Already checking for updates…', 'dim');
1909
+ R.current.updateChecking = true;
1910
+ say('Checking for updates…', 'dim');
1911
+ let plan;
1912
+ try {
1913
+ const u = selfUpdate;
1914
+ plan = updater ? await updater.check() : await u.checkUpdate(u.detectInstall({}));
1915
+ const lines = u.updateLines(plan);
1916
+ const tone = !plan.ok || plan.refused ? 'warn' : plan.upToDate ? 'ok' : undefined;
1917
+ for (const l of lines) say(l.startsWith(' · ') ? clip(l, cols - 4) : l, tone); // a refusal or failure is said in full
1918
+ if (!plan.ok || plan.upToDate || plan.refused) return undefined;
1919
+ R.current.updateAsk = { plan, at: Date.now() };
1920
+ return say(u.updateAsk(plan), 'ask');
1921
+ } catch (e) {
1922
+ return say(`Could not check for updates: ${e?.message || e}`, 'warn');
1923
+ } finally { R.current.updateChecking = false; }
1924
+ }
1925
+
1926
+ // PROFILE-SWITCH: an agent-login window that needs a person offers the human login (y switches, never automatic);
1927
+ // with no human login on this machine it offers `quox login -p <name>` as a run card instead.
1928
+ function offerSwitch() {
1929
+ const { profile: me, agentLogin: isAgent } = WHO.current;
1930
+ if (!isAgent) return undefined;
1931
+ const human = humanProfiles.find(x => x !== me && okProfileName(x));
1932
+ if (human) { R.current.profileAsk = { name: human, at: Date.now() }; return say(switchAskLine(me, human), 'ask'); }
1933
+ const cand = loginCandidates.find(x => x !== me && okProfileName(x)) || 'me';
1934
+ return offerLogin(cand, `This window is an agent login (${me}) and no human login is signed in on this machine.`);
1935
+ }
1936
+ function offerLogin(name, lead) {
1937
+ return armRun(loginRunRequest(name, `${lead} Sign ${name} in, then /profile ${name} switches this window to it.`, runNow()), { who: 'Quox', glyph: 'Ⓠ', text: '' });
1938
+ }
1939
+ /** Switch this window to `name`: in place when main can build its api (keeps everything), else /restart into -p name. */
1940
+ async function switchTo(name) {
1941
+ const me = WHO.current.profile;
1942
+ if (!okProfileName(name)) return say(`"${clip(String(name), 40)}" is not a profile name.`, 'warn');
1943
+ if (name === me) return say(`This window is already ${me}.`, 'dim');
1944
+ if (S.current.turn) return say('A turn is running. Wait for it (or press Esc), then switch.', 'warn');
1945
+ if (switchProfile && !where.current.slot) { // a held slot belongs to the old identity: the restart hand-over carries it properly
1946
+ say(`Switching this window to ${name}…`, 'dim');
1947
+ let r;
1948
+ try { r = await switchProfile(name, { org: api.windowOrgId?.() || null }); } catch (e) { r = { ok: false, reason: e?.message || 'failed' }; }
1949
+ if (r?.ok && r.api) {
1950
+ APX.current.live = r.api;
1951
+ const next = { profile: name, agentLogin: Boolean(r.agentLogin) };
1952
+ WHO.current = next; setWho(next);
1953
+ if (r.orgName !== undefined) R.current.orgName = r.orgName;
1954
+ R.current.notify = { fails: 0, authLost: false };
1955
+ dispatch({ type: 'notify', patch: { error: null } });
1956
+ refreshNotify(); refreshRoster();
1957
+ return say(`✔ Switched this window to ${name}. Same conversation${SN.current.agent ? `, still attached to ${SN.current.agent}` : ''}.${r.note ? ` ${r.note}` : ''}`, 'ok');
1958
+ }
1959
+ if (r?.reason === 'not_signed_in') return offerLogin(name, `${name} is not signed in on this machine.`);
1960
+ if (!onRestart) return say(`Could not switch to ${name} (${r?.reason || 'failed'}). Still ${me}.`, 'warn');
1961
+ say(`Could not switch in place (${r?.reason || 'failed'}). Restarting into ${name} instead.`, 'warn');
1962
+ } else if (!switchProfile && !humanProfiles.includes(name)) return offerLogin(name, `${name} is not a signed-in profile on this machine.`);
1963
+ if (!onRestart) return say(`Switching profile is not available in this mode. Quit and run: quox -p ${name}`, 'warn');
1964
+ return doRestart({ profile: name });
1965
+ }
1966
+
1967
+ async function doRestart(extra = {}) {
1968
+ if (R.current.restarting) return say('Already restarting…', 'dim');
1969
+ R.current.restarting = true; stopCountdown();
1970
+ let target;
1971
+ if (restartCheck) { // never let go of the terminal for a quox that cannot start: say so and stay
1972
+ say('Checking the installed quox…', 'dim');
1973
+ const c = await restartCheck();
1974
+ if (!c.ok) { R.current.restarting = false; return say(`Could not restart: ${c.bin} did not start (${c.error}). Still in this client.`, 'warn'); }
1975
+ target = c.onPath || undefined;
1976
+ }
1977
+ say(`Restarting${S.current.ctx.conversationId ? ' into this conversation' : ''}…`, 'dim');
1978
+ onRestart({ conversationId: S.current.ctx.conversationId || null, name: SN.current.name || null, slot: where.current.slot || null, attach: SN.current.agent ? `agent:${SN.current.agent}` : (SN.current.slotTarget ? `slot:${SN.current.slotTarget}` : null), org: api.windowOrgId?.() || null, ...(MD.current === 'plan' ? { mode: 'plan' } : {}), bin: target, ...extra });
1979
+ return exit(30);
1980
+ }
1981
+
1982
+ async function command(line) {
1983
+ if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/note');
1984
+ const [cmd0, arg] = line.trim().split(/\s+/);
1985
+ const cmd = cmd0 === '/agent' ? '/agents' : cmd0; // /agent is the same command
1986
+ if (!['/afk', '/sleep', '/back', '/away'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
1987
+ if (cmd !== '/note' && cmd !== '/context') SCX.current.command(line.trim());
1988
+ if (cmd !== '/note') transcript?.command(/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, '')) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
1989
+ const restArg = line.trim().replace(/^\S+\s*/, '');
1990
+ touchSeen();
1991
+ if (TIPS.some(x => tryCommand(x) === cmd) && !TP.current.used.includes(cmd)) { TP.current.used = [...TP.current.used, cmd]; api.saveSetting?.('tipsUsed', TP.current.used); } // using a thing retires its tip
1992
+ let objArg = restArg;
1993
+ if (cmd === '/agents') { // list picker, tools list, or the card (agents.js: list/tools/show are verbs, never names)
1994
+ const p = parseAgentsArgs(restArg);
1995
+ if (p.mode === 'usage') return say(p.text, 'warn');
1996
+ if (p.mode === 'list') return agentsPicker(p.filter);
1997
+ if (p.mode === 'tools') return agentTools(p.name);
1998
+ objArg = p.name;
1999
+ }
2000
+ if (OBJECT_CMDS.has(cmd) && !(cmd === '/rules' && arg)) { // inline object views (objects/): a card into history, or an honest note
2001
+ const r = await runObjectCommand(cmd, objArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks], conversationId: S.current.ctx?.conversationId });
2002
+ if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : say(clip(r.note, cols - 4), r.tone);
2003
+ }
2004
+ switch (cmd) {
2005
+ case '/copy': {
2006
+ const r = pickCopy(S.current.answers, arg || '');
2007
+ if (r.error) return say(r.error, 'warn');
2008
+ if (stdout?.isTTY) stdout.write(osc52(r.text));
2009
+ return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
2010
+ }
2011
+ case '/share': return shareCommand(arg);
2012
+ case '/note': {
2013
+ if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
2014
+ if (!restArg) return say('Usage: /note <text> (or // text). Nothing to note.', 'warn');
2015
+ if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
2016
+ transcript.note(restArg.slice(0, 2000));
2017
+ return say('noted for the observer', 'dim');
2018
+ }
2019
+ case '/rename': {
2020
+ if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
2021
+ const n = cleanName(restArg);
2022
+ SN.current.name = n; SN.current.saved = null; bump(x => x + 1);
2023
+ if (where.current.slot && api.checkoutSlot) { // the slot's holder label follows the name, or this window would be locked out of its own slot
2024
+ const r = await api.checkoutSlot(where.current.slot, n, { take: true });
2025
+ if (!r.ok) return say(`Session named "${n}", but slot ${where.current.slot} still carries the old label: ${r.message}`, 'warn');
2026
+ }
2027
+ return say(`Session named "${n}".`, 'ok');
2028
+ }
2029
+ case '/challenge': return CH.current.run(restArg);
2030
+ case '/spawn': return SPN.current.run(restArg);
2031
+ case '/ask': // owner typed /ask hyperion <question> (2026-10-16): same as /say
2032
+ case '/say': { // QUOXON-MESH: straight to a slot, no model turn
2033
+ const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(restArg || '');
2034
+ if (!m) return say('Usage: /say <slot>[,<slot>...] <text>', 'warn');
2035
+ const to = [...new Set(m[1].split(',').map(x => x.trim().toLowerCase()).filter(Boolean))];
2036
+ if (to.length < 2) return sendToSlot(to[0] || m[1].toLowerCase(), m[2].trim());
2037
+ // QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
2038
+ const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
2039
+ const head = `(to ${slots.join(', ')}) `;
2040
+ for (const sl of slots) await sendToSlot(sl, head + m[2].trim());
2041
+ return undefined;
2042
+ }
2043
+ case '/join': { const nm = roomName(restArg); if (!nm) return say('Usage: /join <slot> (or #<slot>)', 'warn'); return joinRoom(nm); }
2044
+ case '/leave': { const nm = roomName(restArg); if (!nm) return say('Usage: /leave <slot>', 'warn'); return leaveRoom(nm); }
2045
+ case '/rooms': case '/groups': return showRooms();
2046
+ case '/broadcast': case '/page':
2047
+ return runBroadcast({ api, kind: cmd === '/page' ? 'page' : 'broadcast', arg: restArg, from: myLabel(), say, card: (m) => dispatch({ type: 'bcard', card: m }), isClosed: () => R.current.closed });
2048
+ case '/ack': return runAck({ api, arg: restArg, slot: where.current.slot || null, say });
2049
+ case '/send': { // QUOXON-MESH: a local text file into a slot (inline, 64 KB cap, secrets scrubbed on a yes)
2050
+ const m = /^(\S+)\s+(.+)$/.exec(restArg || '');
2051
+ if (!m) return say('Usage: /send <slot> <file>', 'warn');
2052
+ if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
2053
+ return sendFile(m[1].toLowerCase(), m[2]);
2054
+ }
2055
+ case '/slot': {
2056
+ if (!restArg) return say(where.current.slot ? `Slot: ${where.current.slot}` : 'No slot. Use /slot <name> to give this session an address.');
2057
+ if (/^log(\s+\d+)?$/.test(restArg.trim())) return slotLog(restArg.trim().split(/\s+/)[1]);
2058
+ if (/^(?:list|ls|help|all)$/i.test(restArg.trim())) return listSlots(/^all$/i.test(restArg.trim())); // owner 2026-10-15: "/slot help" must not check out a slot called help
2059
+ if (!api.checkoutSlot) return say('Slots are not available in this mode.', 'warn');
2060
+ const toks = restArg.split(/\s+/);
2061
+ const forceCreate = toks.includes('--create');
2062
+ const take = toks.includes('--take');
2063
+ let nm = toks.find(t => !t.startsWith('--')) || '';
2064
+ if (!nm) return say('Usage: /slot <name> [--create] [--take]', 'warn');
2065
+ if (!forceCreate) nm = await canonSlot(nm);
2066
+ if (!forceCreate && api.slots) { // a name that is not a real slot but is close to one is a typo, not a request to create
2067
+ let slots = null;
2068
+ try { slots = await api.slots(); } catch { slots = null; }
2069
+ if (Array.isArray(slots) && !slots.some(x => String(x.name) === nm)) {
2070
+ const near = await nearbyTargets(nm, slots);
2071
+ if (near.length) { // owner 2026-10-16: offer, don't instruct. The near misses stay as the line above the card.
2072
+ say(`Slot "${nm}" not found. Did you mean: ${near.join(', ')}?`, 'warn');
2073
+ R.current.slotCreateAsk = { nm, at: Date.now() };
2074
+ return say(slotCreateAskLine(nm), 'ask');
2075
+ }
2076
+ }
2077
+ }
2078
+ return takeSlot(nm, take);
2079
+ }
2080
+ case '/attach': {
2081
+ if (!restArg) return say(SN.current.agent ? `Attached to ${SN.current.agent}. /detach returns to CommanderQ.` : 'Talking to CommanderQ. Use /attach <agent> to pick one.');
2082
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2083
+ if (!api.agents) return say('Agents are not available in this mode.', 'warn');
2084
+ const pt = parseTarget(restArg);
2085
+ let list;
2086
+ try { list = await api.agents(); } catch (e) { return say(`Couldn't check agents: ${listFailure(e, profile)}`, 'warn'); }
2087
+ let slots = null;
2088
+ if (pt.kind !== 'agent' && api.slots) {
2089
+ try { slots = await api.slots(); } catch (e) { if (pt.kind === 'slot') return say(`Couldn't check slots: ${listFailure(e, profile)}`, 'warn'); slots = null; }
2090
+ }
2091
+ const slotList = Array.isArray(slots) ? slots : [];
2092
+ const slotKey = pt.kind === 'agent' ? null : uniqueSquash(pt.name, slotList.map(x => x.name));
2093
+ const slotRow = slotKey ? slotList.find(x => String(x.name) === slotKey) : null;
2094
+ const m = pt.kind === 'slot' ? { matches: [] } : matchAgent(pt.name, list);
2095
+ const slotLive = !!(slotRow && slotRow.holder_session);
2096
+ let choice = null; // 'slot' | 'agent'
2097
+ if (slotRow && m.agent) choice = pt.kind === 'slot' || slotLive ? 'slot' : 'agent';
2098
+ else if (slotRow) choice = 'slot';
2099
+ else if (m.agent) choice = 'agent';
2100
+ if (!choice) {
2101
+ // A miss is only "no such agent" when the lists answered; a failed list is the failure, not a not-found.
2102
+ if (list?.listError) return say(`Couldn't check agents: ${listFailure(list.listError, profile)}`, 'warn');
2103
+ if (api.slots && slots === null && pt.kind !== 'agent') { try { slots = await api.slots(); } catch (e) { return say(`Couldn't check slots: ${listFailure(e, profile)}`, 'warn'); } }
2104
+ const near = await nearbyTargets(pt.name, slots, list);
2105
+ return say(`No ${pt.kind || 'agent or slot'} called "${pt.name}".${near.length ? ` Did you mean: ${near.join(', ')}?` : ' Try quox agent list.'}`, 'warn');
2106
+ }
2107
+ const both = !!(slotRow && m.agent);
2108
+ if (choice === 'slot') {
2109
+ if (!api.slotSay) return say('Talking to a slot is not available in this mode.', 'warn');
2110
+ SN.current.agent = null; SN.current.slotTarget = slotRow.name;
2111
+ where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
2112
+ const host = slotRow.holder_identity ? String(slotRow.holder_identity).replace(/^resident:/, '') : (slotRow.host_name || slotRow.hostname || null); // RESTORE-TUI: a named host from an older server row still shows
2113
+ const lbl = `slot ${slotRow.name}${host ? ` (${host})` : ''}`;
2114
+ const alt = both ? ` To pick the agent instead: /attach agent:${pt.name}` : '';
2115
+ if (!pt.rest) return say(`Attached to ${lbl}. Your next messages go to it. /detach returns to CommanderQ.${alt}`, 'ok');
2116
+ const r = await api.slotSay(slotRow.name, pt.rest, { from: myLabel(), fromSlot: where.current.slot || null });
2117
+ if (!r.ok) return say(`Attached to ${lbl}, but the message was not sent: ${r.message}`, 'warn');
2118
+ return say(`Attached to ${lbl}. Sent: ${pt.rest}${alt}`, 'ok');
2119
+ }
2120
+ SN.current.slotTarget = null;
2121
+ SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
2122
+ const alt = both ? ` To pick the slot instead: /attach slot:${pt.name}` : '';
2123
+ say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.${alt}`, 'ok');
2124
+ if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
2125
+ return undefined;
2126
+ }
2127
+ case '/peek': { // PEEK: the same lookup as `quox peek <id>`, in this window's org
2128
+ const pid = restArg.trim().split(/\s+/)[0] || '';
2129
+ if (!api.peek) return say('Peek is not available in this mode. In a terminal: quox peek <id>', 'warn');
2130
+ if (!pid) return say('Usage: /peek <id>. An envelope, inbox item, objective, workflow run, WARD receipt or agent id.', 'warn');
2131
+ let pr;
2132
+ try { pr = await api.peek(pid); } catch (e) { return say(`Peek failed: ${listFailure(e, profile)}`, 'warn'); }
2133
+ if (pr?.hit) return dispatch({ type: 'object', obj: { type: 'peek', data: pr } }); // a boxed card: the label column survives
2134
+ for (const l of peekIdLines(pr)) say(clip(l.text, cols - 4), l.tone === 'ok' ? undefined : l.tone || undefined);
2135
+ return undefined;
2136
+ }
2137
+ case '/aside': { // ASIDE: no tools, no conversation id; the answer is a separate read-only block
2138
+ const q = restArg.trim();
2139
+ if (!api.aside) return say('Asides are not available in this mode. In a terminal: quox aside "<question>"', 'warn');
2140
+ if (!q) return say('Usage: /aside <question>. A read-only side question: no tools, not added to this conversation.', 'warn');
2141
+ say('Asking on the side (read only, no tools)…', 'dim');
2142
+ let ar;
2143
+ try { ar = await api.aside(q, turnsFromItems([...S.current.history, ...S.current.blocks])); } catch (e) { return say(`Aside failed: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}. Nothing was changed.`, 'warn'); }
2144
+ dispatch({ type: 'note', text: asideNoteText(q, ar.text, ar), tone: 'aside' });
2145
+ return undefined;
2146
+ }
2147
+ case '/hosts': {
2148
+ if (/^add\b/i.test(restArg || '')) { for (const l of await hostLines()) say(l); return undefined; }
2149
+ say('To see the hosts your Quox knows: quox fleet hosts (in a terminal).');
2150
+ return say('To add one, no AI needed: /hosts add');
2151
+ }
2152
+ case '/login': { // /login <name> for a profile that is already signed in: switch this window to it (PROFILE-SWITCH)
2153
+ const ln = restArg.trim().split(/\s+/)[0] || '';
2154
+ if (ln && ln !== profile && humanProfiles.includes(ln)) return switchTo(ln);
2155
+ LI.current.open(); return undefined;
2156
+ }
2157
+ case '/profile': {
2158
+ const pn = restArg.trim().split(/\s+/)[0] || '';
2159
+ if (pn && pn !== 'list') return switchTo(pn);
2160
+ const others = humanProfiles.filter(x => x !== profile);
2161
+ say(`This window is ${profile} (${agentLogin ? 'agent login' : 'human login'}).${others.length ? ` Switch with ${others.map(o => `/profile ${o}`).join(' or ')}.` : ' No other signed-in profile on this machine: /profile <name> offers to sign one in.'}`);
2162
+ // PROFILE-CHOOSER: one list of profiles and this login's orgs; a pick changes THIS window only.
2163
+ let orgList = [], orgNote = null;
2164
+ if (api.orgs) { try { orgList = await Promise.race([api.orgs(), new Promise((_, rej) => setTimeout(() => rej(new Error('timed out')), 5000))]); } catch (e) { orgNote = `Orgs not listed: ${listFailure(e, profile)}`; } }
2165
+ const prow = profileChooserRows({ current: WHO.current.profile, humanProfiles, loginCandidates, orgs: orgList, orgId: R.current.orgId ?? api.orgId?.() ?? null });
2166
+ if (orgNote) say(orgNote, 'warn');
2167
+ if (prow.length < 2) return undefined;
2168
+ PK.current.open({ title: 'Switch profile or org (this window only)', rows: prow, sel: 0, hint: '↑↓ select · Enter switch (an org asks first) · Esc cancel', onPick: (row) => {
2169
+ const p = row?.pick || {};
2170
+ if (p.profile) return p.profile === WHO.current.profile ? say(`This window is already ${p.profile}.`, 'dim') : switchTo(p.profile);
2171
+ if (p.login) return switchTo(p.login);
2172
+ if (p.org) return p.org.id === (R.current.orgId ?? api.orgId?.() ?? null) ? say(`Already in ${p.org.name}.`, 'dim') : command(`/org switch ${p.org.slug || p.org.id}`);
2173
+ return undefined;
2174
+ } });
2175
+ return undefined;
2176
+ }
2177
+ case '/connect': {
2178
+ if (BL.current) return say(BL.current, 'warn');
2179
+ if (!restArg) {
2180
+ if (!api.connections) return say('Connections are not available in this mode.', 'warn');
2181
+ const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
2182
+ for (const [t, tone] of connectionLines(vault, caps)) say(clip(t, cols - 4), tone);
2183
+ return undefined;
2184
+ }
2185
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2186
+ if (/^(yes|no)$/i.test(restArg.trim())) { // answers the private-network card
2187
+ const offer = R.current.lanOffer; R.current.lanOffer = null;
2188
+ if (!offer) return say('Nothing to confirm. Run /connect again.', 'warn');
2189
+ if (/^no$/i.test(restArg.trim())) return say('Left blocked. Nothing was changed.');
2190
+ const c = await confirmLanAllow(api, offer);
2191
+ for (const [t, tone] of c.lines) say(t, tone);
2192
+ // Z2H-R4: a LAN provider always arrives here; adopt it like the direct path does.
2193
+ if (c.ok && offer.providerId) await adoptConnected(offer.providerId, { host: c.host, model: c.model });
2194
+ return undefined;
2195
+ }
2196
+ const pv = providerFor(arg);
2197
+ if (pv?.url && pv.id !== 'ollama') { // B5: LM Studio / vLLM / custom: probed server side, then saved (custom may need a key)
2198
+ const rest = restArg.replace(/^\S+\s*/, '');
2199
+ say(`Checking the URL from the server…`);
2200
+ const r = await connectCompatFlow(api, pv.id, rest);
2201
+ for (const [t, tone] of r.lines) say(clip(t, cols - 4), tone);
2202
+ R.current.lanOffer = r.lanOffer ? { ...r.lanOffer, providerId: pv.id } : null;
2203
+ if (r.needsKey) CN.current.openCustom(r.extra);
2204
+ else if (r.ok) { await adoptConnected(pv.id, { host: r.host, model: r.model }); await CN.current.askConsent(pv.type); }
2205
+ return undefined;
2206
+ }
2207
+ if (pv?.url) { // B3: Ollama takes a URL, probed and saved server side
2208
+ { const u = parseOllamaArg(restArg.replace(/^\S+\s*/, '')).url; if (u) say(`Checking ${u} from the server…`); }
2209
+ const r = await connectOllamaFlow(api, restArg.replace(/^\S+\s*/, ''));
2210
+ for (const [t, tone] of r.lines) say(t, tone) /* Z2H-R3: notes wrap; clipping cut the egress message at "localho…" */;
2211
+ R.current.lanOffer = r.lanOffer ? { ...r.lanOffer, providerId: 'ollama' } : null;
2212
+ if (r.privateAsk) CN.current.askPrivate(r.privateAsk, async (c) => { if (c.ok) await adoptConnected('ollama', { host: c.host, model: c.model }); }); // #1288: y/N for this one address
2213
+ if (r.ok) await adoptConnected('ollama', { host: r.host, model: r.model });
2214
+ return undefined;
2215
+ }
2216
+ CN.current.open(arg);
2217
+ return undefined;
2218
+ }
2219
+ case '/model': {
2220
+ const sub = parseModelSub(restArg);
2221
+ const handled = MM.current.handle(sub);
2222
+ if (handled) return handled;
2223
+ if (sub.kind === 'pass') MP.current.touched = true;
2224
+ if (!api.connections) return say('Model choice is not available in this mode.', 'warn');
2225
+ // One provider status for /model, /connect, /doctor, /capabilities: the server's capabilities, vault for detail.
2226
+ const [res, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
2227
+ if (sub.kind === 'list' || !restArg) {
2228
+ for (const [t, tone] of modelLines(res, MC.current, caps)) say(clip(t, cols - 4), tone);
2229
+ return undefined;
2230
+ }
2231
+ let r = selectModel(res, restArg, caps);
2232
+ let installed = null;
2233
+ if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
2234
+ r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
2235
+ if (r.models) installed = ollamaModelRows(r.models, r.current || r.choice?.model);
2236
+ }
2237
+ if (r.ok && r.choice?.via === 'subscription') { // QUOXON-MODEL: Claude CLI family aliases, no list to fetch
2238
+ if (r.choice.model) r = selectSubscriptionModel(r.choice);
2239
+ } else if (r.ok && (r.choice?.provider === 'openai' || r.choice?.provider === 'anthropic') && api.providerModels) {
2240
+ // QUOXON-MODEL: live model list (server cached 1 h, refreshed now); no hardcoded lists to go stale
2241
+ const row = CHOICES.find(x => x.provider === r.choice.provider && !x.via);
2242
+ const d = selectDiscoveredModel(await api.providerModels(r.choice.provider, { refresh: true }), r.choice, row);
2243
+ r = d.ok && r.tone === 'warn' && !d.models ? { ...d, text: `${d.text} ${r.text}`, tone: 'warn' } : d;
2244
+ if (r.models) installed = compatModelRows(r.models, r.current || r.choice?.model);
2245
+ }
2246
+ if (r.ok && byId(r.choice?.provider)?.compat && api.compatModels) { // B5: check against the provider's own /models
2247
+ r = selectCompatModel(await api.compatModels(r.choice.provider), r.choice.provider, r.choice.model || null);
2248
+ if (r.models) installed = compatModelRows(r.models, r.current || r.choice?.model);
2249
+ }
2250
+ if (r.ok) {
2251
+ MC.current = r.choice; api.setModelChoice?.(r.choice);
2252
+ dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null } }); // the status line shows the new ask until the server answers
2253
+ }
2254
+ say(r.text, r.tone);
2255
+ if (installed) for (const [t, tone] of installed) say(clip(t, cols - 4), tone);
2256
+ return undefined;
2257
+ }
2258
+ case '/row': return RW.current.handle(restArg);
2259
+ case '/watch': return KC.current.watch(restArg);
2260
+ case '/automate': return KC.current.automate(restArg);
2261
+ case '/crystallize': return KC.current.crystallize(restArg);
2262
+ case '/undo': return UW.current.undo(restArg);
2263
+ case '/wish': return UW.current.wish(restArg);
2264
+ case '/capabilities': {
2265
+ if (!api.capabilities) return say('Capabilities are not available in this mode.', 'warn');
2266
+ const res = await api.capabilities();
2267
+ if (restArg && res.ok) {
2268
+ const c = findCapability(restArg, res);
2269
+ if (!c) return say(`Nothing called "${restArg}". /capabilities lists them.`, 'warn');
2270
+ say(clip(`${c.name}: ${c.intent}`, cols - 4));
2271
+ say(clip(`Proof: ${c.evidence || 'not stated'}`, cols - 4));
2272
+ return say(clip(offerFor(c.id, res) || `✔ ${c.name} is ready here.`, cols - 4), c.availability === 'installed' ? 'ok' : 'warn');
2273
+ }
2274
+ for (const [t, tone] of capabilityLines(res)) say(clip(t, cols - 4), tone);
2275
+ return undefined;
2276
+ }
2277
+ case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': case '/fleet': case '/prove': case '/replay': return undefined; // answered above (runObjectCommand)
2278
+ case '/agent': case '/agents': return undefined; // answered above (picker, tools, card)
2279
+ case '/which': return showWhich(arg);
2280
+ case '/detach':
2281
+ SN.current.agent = null; SN.current.slotTarget = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
2282
+ return say('Back to CommanderQ.');
2283
+ case '/sessions': {
2284
+ // SESSIONS (owner 2026-10-15): name and where first; ids only with `refs`.
2285
+ const flags = new Set(String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean));
2286
+ if (api.sessionsIndex) {
2287
+ try {
2288
+ const lines = humanSessionLines(await api.sessionsIndex(), { localHost: osHostname(), names: loadNames(namesPath), all: flags.has('all'), refs: flags.has('refs') || flags.has('ids') });
2289
+ for (const l of lines) say(clip(l.text, cols - 4), l.tone);
2290
+ } catch (e) { dispatch({ type: 'error', text: `sessions: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}` }); }
2291
+ return undefined;
2292
+ }
2293
+ if (!api.conversations) return say('Sessions are not available in this mode.', 'warn');
2294
+ try {
2295
+ const rows = await api.conversations(); const names = loadNames(namesPath);
2296
+ if (!rows.length) return say('No recent conversations.');
2297
+ for (const r of rows) say(clip(`${names[r.id]?.name || r.title || '(untitled)'} ${r.updated_at ? `· ${String(r.updated_at).slice(0, 16).replace('T', ' ')}` : ''}${flags.has('refs') ? ` · ${r.id}` : ''}`, cols - 4));
2298
+ } catch (e) { dispatch({ type: 'error', text: `sessions: ${e.message}` }); }
2299
+ return undefined;
2300
+ }
2301
+ case '/active': {
2302
+ const words = String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean);
2303
+ const filter = words.find(w => ACTIVE_KINDS.includes(w)) || null;
2304
+ if (words.some(w => w !== 'all' && !ACTIVE_KINDS.includes(w))) return say('Usage: /active [sessions|slots|agents] [all]', 'warn');
2305
+ if (!api.sessionsIndex) return say('Active is not available in this mode.', 'warn');
2306
+ try {
2307
+ let unread = null; if (api.slots) { try { unread = new Map((await api.slots()).map((x) => [x.name, x.unread_count || 0])); } catch { /* the count is a nicety */ } }
2308
+ for (const l of activeRows(await api.sessionsIndex(), { filter, cols, unread })) say(l.text, l.tone); // TUI-UX: one wrapped row per session, never clipped
2309
+ } catch (e) { say(`active: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}`, 'warn'); }
2310
+ return undefined;
2311
+ }
2312
+ case '/quit': case '/exit': {
2313
+ const midTurn = Boolean(S.current.turn);
2314
+ if (!leaveArmed('quit')) return undefined;
2315
+ R.current.leaveAsk = null;
2316
+ return exit(midTurn ? 30 : 0); // mid-turn: let the "keeps working" line paint first
2317
+ }
2318
+ case '/restart': {
2319
+ if (!onRestart) return say('Restart is not available in this mode.', 'warn');
2320
+ if (arg === 'auto') { // /restart auto [on|off]: bare toggles
2321
+ const v = String(restArg.split(/\s+/)[1] || '').toLowerCase();
2322
+ if (v && v !== 'on' && v !== 'off') return say('Usage: /restart auto [on|off]', 'warn');
2323
+ const a = AR.current; a.on = v ? v === 'on' : !a.on;
2324
+ if (!a.on) { clearTimeout(a.timer); a.timer = null; }
2325
+ api.saveSetting?.('restartAuto', a.on); bump(n => n + 1);
2326
+ return say(a.on ? 'Auto-restart is on. When a newer quox is installed and you are idle, this client restarts into the same conversation.' : 'Auto-restart is off.', a.on ? 'accent' : 'dim');
2327
+ }
2328
+ if (!leaveArmed('restart')) return undefined;
2329
+ R.current.leaveAsk = null;
2330
+ return doRestart();
2331
+ }
2332
+ case '/update': return updateCommand();
2333
+ case '/demo': {
2334
+ if (!leaveArmed('demo')) return undefined;
2335
+ R.current.leaveAsk = null;
2336
+ if (!onDemo) return say('/demo needs the terminal client.', 'warn');
2337
+ say('Starting the demo (autoplay, nothing real runs). Quit it to come back.', 'dim');
2338
+ onDemo();
2339
+ return exit(30);
2340
+ }
2341
+ case '/gripe': case '/gripe!': {
2342
+ if (!api.gripe) return say('/gripe is not available in this mode.', 'warn');
2343
+ const pending = R.current.pendingGripe && Date.now() - R.current.pendingGripe.at < 120000 ? R.current.pendingGripe.text : null;
2344
+ const text = restArg || (cmd === '/gripe!' ? pending : null);
2345
+ if (!text) return say(cmd === '/gripe!' ? 'Nothing to file. /gripe <text> first, or /gripe! <text>.' : 'Usage: /gripe <what is wrong> (/gripe! <text> files without asking)', 'warn');
2346
+ if (cmd === '/gripe') { // ask first: filing makes a public-ish issue
2347
+ R.current.pendingGripe = { text, at: Date.now() };
2348
+ say(`File this as a GitHub issue via GRIPE? "${clip(text, Math.max(20, cols - 50))}"`);
2349
+ return say('/gripe! files it · /gripe <new text> replaces it · anything else leaves it unfiled', 'dim');
2350
+ }
2351
+ R.current.pendingGripe = null;
2352
+ say('Filing via GRIPE…', 'dim');
2353
+ const g = await api.gripe(text.slice(0, 4000), { yes: true });
2354
+ if (!g.ok) return say(`Gripe not filed: ${g.message}`, 'warn');
2355
+ if (!g.superseded && g.response && !g.url) for (const l of String(g.response).split('\n').filter(Boolean).slice(0, 6)) say(clip(l, cols - 4), 'dim');
2356
+ for (const n of g.notes || []) {
2357
+ if (n.kind === 'created') say(`✔ GRIPE │ issue filed │ ${n.url}`, 'ok');
2358
+ else say(clip(n.text, cols * 3), n.kind === 'warn' ? 'warn' : 'dim');
2359
+ }
2360
+ return undefined;
2361
+ }
2362
+ case '/context':
2363
+ if (arg === 'off' || arg === 'on') {
2364
+ SCX.current.on = arg === 'on';
2365
+ bump(n => n + 1);
2366
+ return say(arg === 'on' ? 'Terminal context on: each message carries what this screen shows (2 KB max, secrets masked).' : 'Terminal context off for this session: Quox no longer sees your roster, approvals, commands or notices.');
2367
+ }
2368
+ return showPanel('/context');
2369
+ case '/status': case '/cost': case '/usage':
2370
+ return showPanel(cmd === '/usage' ? '/cost' : cmd);
2371
+ case '/doctor': {
2372
+ if (!api.doctorChecks) return say('/doctor is not available in this mode.', 'warn');
2373
+ say('Running the quox doctor checks…', 'dim');
2374
+ const [plat, chat] = await Promise.all([api.doctorChecks().then(c => ({ c }), e => ({ e })), chatChecks(api)]);
2375
+ const empty = !plat.e && (!Array.isArray(plat.c) || !plat.c.length); // a run that produced no rows is a failure to say, not a blank panel
2376
+ return help(doctorModel(Array.isArray(plat.c) ? plat.c : [], chat, plat.e ? (plat.e.message || 'failed') : empty ? 'the doctor checks returned no results' : null));
2377
+ }
2378
+ case '/server': {
2379
+ if (!api.serverStatus) return say('/server is not available in this mode.', 'warn');
2380
+ say('Reading the server status…', 'dim');
2381
+ const st = await api.serverStatus().then(r => r, e => ({ ok: false, error: e?.message || 'failed' }));
2382
+ if (!st.ok) return say(`No server status: ${st.error}`, 'warn');
2383
+ help(st.rows);
2384
+ return say('Change it from a terminal: quox server update --dry-run shows the plan first (also rollback, uninstall, doctor, diag).', 'dim');
2385
+ }
2386
+ case '/dashboard': {
2387
+ if (!api.dashboardLink) return say('/dashboard is not available in this mode.', 'warn');
2388
+ const r = await api.dashboardLink(arg || '').catch(e => ({ kind: 'error', error: e?.message || 'failed' }));
2389
+ // The link is a credential: the lines go to the screen only; the dev transcript records that one was minted.
2390
+ for (const l of dashboardLines(r, { hyperlinks: supportsHyperlinks(env, Boolean(stdout?.isTTY)) })) {
2391
+ const sensitive = r.kind === 'link' && l.text.includes(r.url);
2392
+ dispatch({ type: 'note', text: l.text, tone: l.tone, ...(sensitive ? { redact: 'dashboard link minted' } : {}) });
2393
+ }
2394
+ return undefined;
2395
+ }
2396
+ case '/config': case '/settings':
2397
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2398
+ return openConfig();
2399
+ case '/consent': {
2400
+ if (!api.consentStatus) return say('/consent is not available in this mode.', 'warn');
2401
+ const pending = R.current.consent || null;
2402
+ if (arg === 'grant') {
2403
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2404
+ const prov = String(restArg.replace(/^\S+\s*/, '') || pending?.provider || '').toLowerCase();
2405
+ if (!prov) return say('Which provider? /consent grant openai', 'warn');
2406
+ try { await api.grantConsent(prov); } catch (e) { return say(`Could not record your consent (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}). Settings > Privacy in the dashboard does the same.`, 'warn'); }
2407
+ say(`✔ Consent recorded: ${prov} may receive your messages and tool results. Change it any time in Settings > Privacy.`, 'ok');
2408
+ R.current.consent = null;
2409
+ if (pending?.text) { say('Sending your message again.', 'dim'); return runTurn(pending.text); }
2410
+ return undefined;
2411
+ }
2412
+ let rec = null;
2413
+ try { rec = await api.consentStatus(); } catch (e) { return say(`Could not read your consent (${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}).`, 'warn'); }
2414
+ for (const [t, tone] of consentStatusLines(rec, { provider: pending?.provider })) say(t, tone);
2415
+ return undefined;
2416
+ }
2417
+ case '/compact': {
2418
+ if (arg === 'show') {
2419
+ const last = R.current.lastCompact;
2420
+ if (!last) return say('Nothing compacted in this session yet. /compact runs it.', 'warn');
2421
+ return help(compactShowLines(last));
2422
+ }
2423
+ if (!api.compactConversation) return say('/compact is not available in this mode.', 'warn');
2424
+ const id = S.current.ctx.conversationId;
2425
+ if (!id) return say('Nothing to compact yet: this conversation starts with your next message.', 'warn');
2426
+ if (S.current.turn) return say('A turn is still running in this conversation. Compact after it finishes.', 'warn');
2427
+ say('Compacting on the server (one model call)…', 'dim');
2428
+ let r;
2429
+ try { r = await api.compactConversation(id); } catch (e) { return say(compactError(e), 'warn'); }
2430
+ const v = compactResult(r);
2431
+ R.current.lastCompact = v;
2432
+ return help([
2433
+ { kind: 'plain', text: v.head },
2434
+ ...v.summaryLines.map(t => ({ kind: 'dim', text: `│ ${t}` })),
2435
+ ...(v.more ? [{ kind: 'dim', text: `│ … ${v.more} more line${v.more === 1 ? '' : 's'}. /compact show for all` }] : []),
2436
+ ...v.notes.map(t => ({ kind: 'dim', text: t }))
2437
+ ]);
2438
+ }
2439
+ case '/resume':
2440
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2441
+ return resumeCmd(restArg);
2442
+ case '/dragon': { // the living dragon (qmotion DragonLive): /dragon, classic and hero = the one dragon show, fire = its last act; explain / ask / sleep are dragonModes.js
2443
+ const m = parseDragonArg(restArg);
2444
+ if (!m) return say(DRAGON_USAGE);
2445
+ if (m.mode === 'explain') return dragonExplain();
2446
+ if (m.mode === 'ask') return dragonAsk(m.question);
2447
+ if (m.mode === 'sleep') return dragonSleep();
2448
+ if (m.mode === 'fly') return dragonFly();
2449
+ playDragon(m.mode, undefined); bump(n => n + 1); return true;
2450
+ }
2451
+ case '/dragon-fly': return arg ? say('Usage: /dragon-fly', 'warn') : dragonFly();
2452
+ case '/sound': return SND.current.command(arg, say);
2453
+ case '/tip': return showTip();
2454
+ case '/whatsnew': {
2455
+ const tg = toggleArg(arg);
2456
+ if (tg) { api.saveSetting?.('whatsNewStartup', tg); return say(tg === 'on' ? "What's new will show at startup when there is something new." : "What's new is off at startup. /whatsnew still works any time; /whatsnew on turns it back on.", 'ok'); }
2457
+ const all = String(arg).trim().toLowerCase() === 'all';
2458
+ const st = api.settings?.() || {};
2459
+ const cs = unseenCommands({ registry: visibleRegistry(), seen: st.whatsNewSeen, all });
2460
+ if (cs.length) { say(`${all ? 'All commands' : 'New commands'} (${cs.length}):`); cs.forEach(e => say(`${e.usage ? e.usage.split(/\s+[\[<|]/)[0] : '/' + e.id}: ${e.desc}`)); }
2461
+ else say('No new commands since you last looked.');
2462
+ api.saveSetting?.('whatsNewSeen', seenAfter(REGISTRY));
2463
+ // QUOXON-WHATSNEW3: only entries newer than the last one shown (all: every entry), then mark them seen.
2464
+ const unseen = all ? listWhatsNew('all', whatsNewEntries).entries : unseenEntries(st.whatsNew, whatsNewEntries);
2465
+ const shown = all ? unseen : unseen.slice(0, LIST_DEFAULT);
2466
+ const entry = (e) => { say(`${e.title}: ${e.text}`); if (tryText(e)) say(`Try: ${tryText(e)}`); };
2467
+ say("What's new in Quox:");
2468
+ if (shown.length) { say(all ? `Every entry (${unseen.length}):` : `New since you last looked (${unseen.length}):`); shown.forEach(entry); if (unseen.length > shown.length) say(`${unseen.length - shown.length} more: /whatsnew all`); }
2469
+ else { const top = listWhatsNew('', whatsNewEntries).entries[0]; say('Nothing new since you last looked.'); if (top) say(`Latest: ${top.title}${tryText(top) ? ` (try ${tryText(top)})` : ''}. /whatsnew all lists every entry.`); }
2470
+ if (shown.length || !st.whatsNew?.id) api.saveSetting?.('whatsNew', seenRecord(version, whatsNewEntries)); return true; }
2471
+ case '/tips': { let plugins = []; try { plugins = (await api.installedPlugins?.()) || []; } catch { /* unreadable: plugin tips hidden */ }
2472
+ const r = tipsReply(arg, { mode: TP.current.mode, plugins });
2473
+ if (r.tips) { say(r.head); r.tips.forEach(t => dispatch({ type: 'tip', text: t.text, cmd: t.cmd })); r.tail.forEach(l => say(l)); return true; }
2474
+ r.lines.forEach((l, i) => say(l, i ? undefined : r.tone === 'plain' ? undefined : r.tone)); return true; }
2475
+ case '/tip-settings': {
2476
+ const r = tipSettingsReply(arg, TP.current.mode);
2477
+ if (r.mode) { TP.current.mode = r.mode; api.saveSetting?.('tips', r.mode); }
2478
+ return say(r.text, r.tone === 'plain' ? undefined : r.tone);
2479
+ }
2480
+ case '/welcome': { // replay the first screen: the Q art with the greeting text beside it, and (a real terminal, 40+ rows) the dragon greeting. No setup side effects: nothing is saved, no state is reset.
2481
+ dispatch({ type: 'introShow', text: introItem.text, cols: introItem.cols });
2482
+ if (motionOn && !mono() && (termRows(stdout) ?? 0) >= 40) { playDragon('greeting'); bump(n => n + 1); }
2483
+ return true; }
2484
+ case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
2485
+ case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
2486
+ case '/clear': case '/new': case '/reset': {
2487
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2488
+ const prev = S.current.ctx.conversationId;
2489
+ dispatch({ type: 'ctx', ctx: { conversationId: null } });
2490
+ SN.current.name = null; SN.current.saved = null;
2491
+ dispatch({ type: 'history.reset' });
2492
+ if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
2493
+ setEpoch(e => e + 1);
2494
+ return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
2495
+ }
2496
+ case '/aocl': return dispatch({ type: 'aocl.table' });
2497
+ case '/verbose':
2498
+ if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
2499
+ dispatch({ type: 'verbose', mode: arg });
2500
+ if (api.saveSetting) api.saveSetting('verbose', arg); // persisted per user (profile settings)
2501
+ return say(`verbose ${arg}${api.saveSetting ? ' (saved)' : ''}`);
2502
+ case '/rules': {
2503
+ if (arg === 'off' || arg === 'on') {
2504
+ R.current.rulesOn = arg === 'on';
2505
+ bump(n => n + 1);
2506
+ return say(arg === 'on' ? (rules?.files?.length ? 'Rules on.' : 'No QUOX.md loaded, nothing to turn on.') : 'Rules off for this session (nothing from QUOX.md is sent).');
2507
+ }
2508
+ const lines = rulesLines(rules, { enabled: R.current.rulesOn !== false, cols });
2509
+ const per = 20, pages = Math.max(1, Math.ceil(lines.length / per));
2510
+ const pg = Math.min(pages, Math.max(1, Number(arg) || 1));
2511
+ lines.slice((pg - 1) * per, pg * per).forEach(l => say(clip(l, cols - 4)));
2512
+ if (pages > 1) say(`page ${pg}/${pages}${pg < pages ? ` · /rules ${pg + 1} for more` : ''}`);
2513
+ return undefined;
2514
+ }
2515
+ case '/hat': { // quox#1183: change hats with no model turn. A normal hat switches now; a privileged one asks y/N.
2516
+ if (!api.hats || !api.bindHat) return say('Hat switching is not available in this mode.', 'warn');
2517
+ const R0 = R.current; const q = restArg.trim().replace(/^switch\b\s*/i, ''); // "/hat switch" is "/hat": the chooser
2518
+ const hatSub = q.split(/\s+/)[0].toLowerCase();
2519
+ if (hatSub === 'new' || hatSub === 'edit' || hatSub === 'delete') { // CUSTOM-HATS (owner 2026-10-17): own hats, walked one card at a time
2520
+ if (!api.createHat || !api.updateHat || !api.deleteHat) return say('Custom hats are not available in this mode.', 'warn');
2521
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2522
+ return HW.current.start(hatSub, q.slice(hatSub.length).trim());
2523
+ }
2524
+ if (q === 'yes') {
2525
+ const t = R0.hatPending; R0.hatPending = null;
2526
+ if (!t) return say('Nothing to confirm. /hat <name> first.', 'warn');
2527
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2528
+ let conv = S.current.ctx.conversationId;
2529
+ if (!conv) { conv = mintConversationId(randomUUID()); dispatch({ type: 'ctx', ctx: { conversationId: conv } }); }
2530
+ try { await api.bindHat(conv, t.id); } catch (e) { return say(`Hat not switched: ${e?.message || 'the server refused'}.`, 'warn'); }
2531
+ return say(`Hat switched: ${t.from || 'current'} → ${t.name}. Your next message runs on it.`, 'ok');
2532
+ }
2533
+ let hats, active = null;
2534
+ try { hats = await api.hats(); } catch (e) { return say(`Couldn't list hats: ${listFailure(e, profile)}`, 'warn'); }
2535
+ try { active = api.activeHat ? await api.activeHat(S.current.ctx.conversationId || null) : null; } catch { active = null; }
2536
+ if (!q || q === 'list') {
2537
+ if (!hats.length) return say('No hats on this server.', 'warn');
2538
+ const hw = Math.min(26, Math.max(4, ...hats.map(x => [...String(x.name || x.key)].length)));
2539
+ const hrows = hats.map(x => ({ key: `${x.key === active?.key ? '* ' : ' '}${String(x.name || x.key).padEnd(hw)}`, text: `${x.description || x.definition?.description || x.key}${hatIsPrivileged(x) ? ' (privileged: asks you first)' : ''}` }));
2540
+ PK.current.open({ title: 'Change hat for this conversation', rows: hrows, sel: Math.max(0, hats.findIndex(x => x.key === active?.key)), hint: '↑↓ select · Enter put it on · Esc cancel', onPick: (_, i) => command(`/hat ${hats[i].key}`) });
2541
+ return undefined;
2542
+ }
2543
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2544
+ const m = matchHat(q, hats);
2545
+ if (m.ambiguous) return say(`"${q}" matches more than one hat: ${hatChoices(m.ambiguous)}. Be more specific.`, 'warn');
2546
+ if (!m.hat) return say(`No hat matches "${q}". Hats you can pick: ${hatChoices(hats) || 'none'}.`, 'warn');
2547
+ if (active?.key === m.hat.key) return say(`Already wearing the ${m.hat.name || m.hat.key} hat.`);
2548
+ R0.hatPending = { id: m.hat.id, key: m.hat.key, name: m.hat.name || m.hat.key, from: active?.name || null, at: Date.now() };
2549
+ if (!hatIsPrivileged(m.hat)) return command('/hat yes'); // the person typed it: that is the authorization
2550
+ return ask(`Put on the ${R0.hatPending.name} hat?`, { action: `/hat ${m.hat.key}`, reason: 'It is a privileged hat: its tools can change real systems.' });
2551
+ }
2552
+ case '/org': {
2553
+ if (!api.orgs) return say('Org switching is not available in this mode.', 'warn');
2554
+ const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
2555
+ const sub = (restArg.match(/^(\S+)\s*(.*)$/) || [])[1] || '';
2556
+ const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
2557
+ let list;
2558
+ try { list = await api.orgs(); } catch (e) { return say(`Couldn't list your orgs: ${listFailure(e, profile)}`, 'warn'); }
2559
+ if (!sub || sub === 'list' || (sub === 'switch' && !query)) { // a bare /org switch offers the chooser, never a Usage line
2560
+ if (!list.length) return say('No orgs returned for this login.', 'warn');
2561
+ const orows = orgRows(list);
2562
+ const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
2563
+ PK.current.open({ title: 'Move this session to an org', rows: orows.map(x => ({ key: `${x.id === curId ? '* ' : ' '}${x.name.padEnd(ow)}`, text: `${x.slug || x.id}${x.role ? ` (${x.role})` : ''}` })), sel: Math.max(0, orows.findIndex(x => x.id === curId)), hint: '↑↓ select · Enter switch (you confirm first) · Esc cancel', onPick: (_, i) => (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) });
2564
+ return undefined;
2565
+ }
2566
+ if (sub === 'yes') {
2567
+ const t = R0.orgPending; R0.orgPending = null;
2568
+ if (!t) return say('Nothing to confirm. /org switch <name> first.', 'warn');
2569
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2570
+ if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
2571
+ let done;
2572
+ try { done = await api.switchOrg(t.id); } catch (e) { return say(`Not switched. ${e?.message || 'The server refused.'} You are still in ${R0.orgName ?? org ?? 'the same org'}.`, 'warn'); }
2573
+ R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
2574
+ R0.orgId = done.id; R0.orgName = done.name;
2575
+ dispatch({ type: 'ctx', ctx: { conversationId: null } });
2576
+ SN.current.name = null; SN.current.saved = null;
2577
+ dispatch({ type: 'history.reset' });
2578
+ if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
2579
+ setEpoch(e => e + 1); bump(n => n + 1);
2580
+ return say(`Switched to ${done.name}. New conversation in that org.`, 'ok');
2581
+ }
2582
+ if (sub === 'back' || sub === '-') { // owner 2026-10-15 "switch back": no "type /org switch <the org you were in>"
2583
+ const p = R0.prevOrg;
2584
+ if (!p) return say('No previous org in this session yet. /org lists yours.', 'warn');
2585
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2586
+ R0.orgPending = { id: p.id, name: p.name, at: Date.now() };
2587
+ if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
2588
+ return ask(`Switch back to ${p.name}?`, { action: `/org switch ${p.id} (${p.name})`, reason: 'This starts a new conversation in that org.' });
2589
+ }
2590
+ if (sub !== 'switch') return say('Usage: /org, /org switch <name or id>, or /org back', 'warn');
2591
+ if (!query) return say('Usage: /org switch <name or id>. /org lists them.', 'warn');
2592
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2593
+ const m = matchOrg(query, list);
2594
+ if (m.ambiguous) return say(`"${query}" matches more than one: ${m.ambiguous.map(o => o.name).join(', ')}. Be more specific.`, 'warn');
2595
+ if (!m.org) return say(`No org of yours matches "${query}". /org lists them.`, 'warn');
2596
+ if (m.org.id === curId) return say(`Already in ${m.org.name}.`);
2597
+ R0.orgPending = { id: m.org.id, name: m.org.name, at: Date.now() };
2598
+ // Already said yes to the suggested "/org switch X? y/N": one confirmation, not two (owner 2026-10-15).
2599
+ if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
2600
+ return ask(`Switch to ${m.org.name}?`, { action: `/org switch ${m.org.id} (${m.org.name})`, reason: 'This starts a new conversation in that org.' });
2601
+ }
2602
+ case '/skills': {
2603
+ if (!api.skills) return say('This server has no skills list.', 'warn');
2604
+ try {
2605
+ const all = await api.skills();
2606
+ if (arg) {
2607
+ const sk = findSkill(all, arg);
2608
+ if (!sk) return say(`No skill named "${arg}". /skills lists them.`, 'warn');
2609
+ const full = (api.skill && sk.id ? await api.skill(sk.id) : null) || sk;
2610
+ skillDetail(full).forEach(l => say(clip(l, cols - 4)));
2611
+ return undefined;
2612
+ }
2613
+ if (!all.length) return say('No skills in this org yet.');
2614
+ say(`${all.length} skill${all.length === 1 ? '' : 's'}. /skills <name> for detail, /skill <name> [args] to run:`);
2615
+ skillRows(all.slice(0, 25), cols - 4).forEach(l => say(l));
2616
+ if (all.length > 25) say(`... and ${all.length - 25} more. /skills <name> shows any one.`);
2617
+ } catch (e) { dispatch({ type: 'error', text: `skills: ${e.message}` }); }
2618
+ return undefined;
2619
+ }
2620
+ case '/skill': {
2621
+ const argRest = line.trim().split(/\s+/).slice(2).join(' ');
2622
+ if (!arg) return say('Usage: /skill <name> [args]. /skills lists them.', 'warn');
2623
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2624
+ if (blocked) return say(blocked, 'warn');
2625
+ if (api.skills) {
2626
+ try {
2627
+ const sk = findSkill(await api.skills(), arg);
2628
+ if (!sk) return say(`No skill named "${arg}". /skills lists them.`, 'warn');
2629
+ return submit(skillPrompt(sk.name || arg, argRest));
2630
+ } catch (e) { return dispatch({ type: 'error', text: `skills: ${e.message}` }); }
2631
+ }
2632
+ return submit(skillPrompt(arg, argRest));
2633
+ }
2634
+ case '/roster': {
2635
+ const ro = R.current.roster;
2636
+ if (arg === 'all') {
2637
+ ro.all = !ro.all; ro.on = true;
2638
+ ro.rows = selectRows(ro.allRows, { all: ro.all }); ro.sel = 0;
2639
+ return say(ro.all ? 'Roster shows everything, background work included. /roster all again to hide it.' : 'Roster shows only what needs you. Background work is hidden.');
2640
+ }
2641
+ ro.on = !ro.on; if (!ro.on) { ro.focus = false; ro.peek = false; ro.confirm = null; }
2642
+ say(`Roster ${ro.on ? 'on' : 'off'}.${ro.sources ? '' : ' Not loaded yet.'}`);
2643
+ for (const src of ro.sources || []) {
2644
+ say(`${src.state === 'connected' ? '✔' : src.state === 'error' ? '✖' : '·'} ${src.label}: ${src.state === 'connected' ? `${src.count} shown` : `${src.state}${src.note ? ` (${src.note})` : ''}`}`, src.state === 'error' ? 'warn' : undefined);
2645
+ }
2646
+ if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
2647
+ return undefined;
2648
+ }
2649
+ case '/vm': {
2650
+ if (!api.vm) return say('/vm is not available in this mode.', 'warn');
2651
+ if (!restArg) return say('Usage: /vm <list|info|doctor|start|stop|reboot|shutdown|snapshot|pool|node> <target> [args]', 'warn');
2652
+ try {
2653
+ const r = await api.vm(restArg.split(/\s+/));
2654
+ for (const l of r.lines) say(l, r.tone || undefined);
2655
+ } catch (e) { dispatch({ type: 'error', text: `vm: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}` }); }
2656
+ return undefined;
2657
+ }
2658
+ case '/hyperion': {
2659
+ if (!api.hyperion) return say('/hyperion is not available in this mode.', 'warn');
2660
+ try {
2661
+ const r = await api.hyperion();
2662
+ for (const l of r.lines) say(l, r.tone || undefined);
2663
+ } catch (e) { dispatch({ type: 'error', text: `hyperion: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}` }); }
2664
+ return undefined;
2665
+ }
2666
+ case '/sitrep': {
2667
+ if (!api.sitrep) return say('/sitrep is not available in this mode.', 'warn');
2668
+ const r = await api.sitrep();
2669
+ return dispatch({ type: 'object', obj: { type: 'sitrep', data: sitrepData(r.situation, r.approvals) } });
2670
+ }
2671
+ case '/afk': { // owner 2026-10-15: "/afk need a break, keep my requests organised for my return"
2672
+ const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: restArg || '' };
2673
+ try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2674
+ say(`Away from ${new Date(at).toISOString().slice(11, 16)} UTC${restArg ? ` (${clip(restArg, 80)})` : ''}. Approvals and messages keep collecting; when you're back, /back shows everything since, grouped.`, 'ok');
2675
+ const pr = await setPresence('away', restArg);
2676
+ if (!pr.ok) presenceFail(pr);
2677
+ else if (!pr.local) say(`Sessions now see: ${pr.notice || 'you are away'}. Nothing is approved while you are away; /back or any message marks you present.`, 'dim');
2678
+ return undefined;
2679
+ }
2680
+ case '/back': { // PRESENCE: present on the server too, then the digest
2681
+ AWAY.current.afk = null;
2682
+ const pr = await setPresence('present');
2683
+ if (!pr.ok) presenceFail(pr);
2684
+ return command('/away');
2685
+ }
2686
+ case '/observe': { // owner 2026-10-16: a note to the observer (the QUOXON session watching this window)
2687
+ if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
2688
+ if (!restArg.trim()) return say('Usage: /observe <text> (posts into the quoxon slot, for the session that watches your window)', 'warn');
2689
+ if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
2690
+ const r = await api.slotSay('quoxon', restArg.trim(), { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
2691
+ if (!r.ok) return say(`observe: not delivered (${r.message})`, 'warn');
2692
+ return say('✔ noted for the observer (quoxon)', 'ok');
2693
+ }
2694
+ case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
2695
+ const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
2696
+ if (!m) return say(`Usage: ${cmd} <slot or session> [text] (an urgent page; they are asked to acknowledge, and an idle session is woken to read it, even with an unsent draft, which is set aside and put back)`, 'warn');
2697
+ return command(`/page ${m.slot} ${m.text ? m.text.trim() : `are you there? (${cmd.slice(1)} from ${myLabel() || 'the owner'})`}`);
2698
+ }
2699
+ case '/away': {
2700
+ if (!api.away) return say('/away is not available in this mode.', 'warn');
2701
+ const p = parseSince(restArg, { lastSeen: AWAY.current.last });
2702
+ if (!p.ok) return say(p.error, 'warn');
2703
+ try { dispatch({ type: 'object', obj: { type: 'away', data: await api.away(p.since) } }); }
2704
+ catch (e) { say(`away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). Nothing is assumed.`, 'warn'); }
2705
+ return undefined;
2706
+ }
2707
+ case '/mind': {
2708
+ if (!api.mind) return say('/mind is not available in this mode.', 'warn');
2709
+ if (MIND_HELP_ARGS.has(restArg.toLowerCase())) { for (const [text, tone] of mindHelpLines()) say(text, tone); return undefined; }
2710
+ try { for (const [text, tone] of mindLines(await api.mind(restArg || undefined))) say(text, tone); }
2711
+ catch (e) {
2712
+ const isCommand = (n) => REGISTRY.some(x => x.kind === 'command' && x.status === 'active' && x.id === n);
2713
+ for (const [text, tone] of mindErrorLines(e, restArg, { isCommand })) tone === 'error' ? dispatch({ type: 'error', text }) : say(text, tone);
2714
+ }
2715
+ return undefined;
2716
+ }
2717
+ case '/observer': case '/observers': case '/lucid': {
2718
+ if (!api.observers) return say('Observers are not available in this mode.', 'warn');
2719
+ try { for (const [text, tone] of observerLines(await api.observers(), cols)) say(text, tone); }
2720
+ catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
2721
+ return undefined;
2722
+ }
2723
+ case '/approvals': {
2724
+ if (/^help$/i.test(restArg.trim())) { // the syntax lives here, not above every list
2725
+ say(`${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals walkthrough')} one at a time ${mark('/approvals mine|agents|old|<agent>')} filter (privileged: ${mark('/approve N confirm')})`);
2726
+ return undefined;
2727
+ }
2728
+ let items;
2729
+ try { items = await api.approvals(); }
2730
+ catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}). That does not mean nothing is waiting; try again or run: quox inbox list` }); }
2731
+ let stats = null; let statsErr = null;
2732
+ if (api.approvalStats) { try { stats = await api.approvalStats(); } catch (e) { statsErr = e.message; } }
2733
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
2734
+ const groups = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds });
2735
+ const flat = groups.flatMap(g => g.items.map(x => x.item));
2736
+ dispatch({ type: 'inbox', items: flat });
2737
+ R.current.shownIds = flat.map(x => x.id);
2738
+ dispatch({ type: 'notify', patch: { approvals: flat.length, error: null } });
2739
+ const hidden = stats ? Math.max(0, stats.awaiting - flat.length) : 0;
2740
+ if (!flat.length && !hidden && !statsErr) return say('Nothing waiting for you.', 'ok');
2741
+ const fArg = restArg.trim();
2742
+ if (/^walkthrough$/i.test(fArg)) return walkStart(flat, { fresh: true });
2743
+ if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
2744
+ const it = flat[Number(fArg) - 1];
2745
+ if (!it) return say(`No such item: [${fArg}] (${flat.length} listed).`, 'warn');
2746
+ for (const l of approvalDetail(it, Number(fArg))) say(l);
2747
+ say(' ');
2748
+ if (isDecision(it)) { R.current.focusedDecision = { n: Number(fArg), id: it.id, at: Date.now() }; return say(`${mark(`/answer ${fArg} ${it.payload.options.map(o => o.key).join('|')}`)} with an optional "note", or ${mark(`/deny ${fArg} <reason>`)}`, 'warn'); }
2749
+ return say(`${mark(`/approve ${fArg}`)} or ${mark(`/deny ${fArg} <reason>`)}`, 'warn');
2750
+ }
2751
+ const keep = filterPredicate(fArg || null, { userId: api.userId?.() || null, conversationId: S.current.ctx.conversationId });
2752
+ const shown = new Set(flat.filter(keep));
2753
+ const nDec = flat.filter(isDecision).length;
2754
+ const cardFirst = nDec > 0 && !fArg; // plain /approvals opens straight on the one-at-a-time card
2755
+ if (cardFirst) say(`${nDec} question${nDec === 1 ? '' : 's'} waiting for you.`, 'warn');
2756
+ else if (nDec) { const k1 = flat.findIndex(isDecision) + 1; const ks = flat[k1 - 1].payload.options.map(o => o.key).join('|'); say(`${nDec} question${nDec === 1 ? '' : 's'} waiting for you; I take them one at a time below${nDec > 1 ? ' (/yestoall answers every yes/no at once)' : ''}. Typed form: /answer ${k1} ${ks}, with an optional "note".`, 'dim'); }
2757
+ if (cardFirst && flat.length > nDec) say(`${flat.length - nDec} other approval${flat.length - nDec === 1 ? '' : 's'} wait${flat.length - nDec === 1 ? 's' : ''} too: ${mark('/approve N')} or ${mark('/deny N <reason>')}; ${mark('/approvals help')} has the syntax.`, 'dim'); // plain /approvals: the walk takes the questions; the syntax wall lives in /approvals help
2758
+ else if (flat.length > nDec || (fArg && !cardFirst)) say(`${flat.length} waiting${fArg ? `, ${shown.size} match "${fArg}"` : ''}. ${mark('/approve N')} ${mark('/deny N <reason>')} ${mark('/approvals N')} detail ${mark('/approvals mine|agents|old|<agent>')} (privileged: ${mark('/approve N confirm')}):`, 'warn');
2759
+ let n = 0;
2760
+ for (const g of groups) {
2761
+ const rows = g.items.map(x => ({ x, k: ++n })).filter(r => shown.has(r.x.item) && !cardFirst);
2762
+ if (!rows.length) continue;
2763
+ say(' ');
2764
+ say(`${g.group}:`, 'warn');
2765
+ for (const r of rows) { say(approvalRow(r.x.item, r.k, { cols: cols - 2 })); if (isDecision(r.x.item)) say(' '); }
2766
+ }
2767
+ if (flat.length && !shown.size) say(`Nothing matches "${fArg}".`, 'warn');
2768
+ if (flat.length) say(' ');
2769
+ if (nDec && !fArg) walkStart(flat, { fresh: true, quiet: true });
2770
+ if (hidden) {
2771
+ const others = humanProfiles.filter(h => h !== profile);
2772
+ const whoTxt = agentLogin ? `${profile} (an agent login), so it can't approve these` : `${profile}, which cannot act on these`;
2773
+ say(`${hidden} more approval${hidden === 1 ? ' is' : 's are'} waiting org-wide. This window is signed in as ${whoTxt}.${others.length ? ` Quit and run: quox -p ${others[0]}${others.length > 1 ? ` (human profiles found: ${others.join(', ')})` : ''}. Or approve in the dashboard Approvals page, or: quox inbox -p ${others[0]} respond <id> --action approve|reject` : ' Approve in the dashboard Approvals page.'}`, 'warn');
2774
+ if (agentLogin) offerSwitch(); // one keypress instead of quit and relaunch
2775
+ }
2776
+ if (statsErr) say(`Could not check the org-wide approval count (${statsErr}); the list above is what the inbox returned.`, 'warn');
2777
+ return undefined;
2778
+ }
2779
+ case '/feedback': { // owner 2026-10-15: /feedback goes to the quox.ai feedback API (/gripe stays GitHub)
2780
+ if (!api.feedback) return say('/feedback is not available in this mode.', 'warn');
2781
+ const fb = feedbackPayload(restArg, version);
2782
+ if (!fb) return say('Usage: /feedback <text> (5 to about 4900 characters; goes to the Quox team at quox.ai)', 'warn');
2783
+ for (const l of feedbackPreview(fb)) say(clip(l, cols - 4), 'dim');
2784
+ R.current.feedbackAsk = { payload: fb, at: Date.now() };
2785
+ return ask('Send it?', { action: 'send the feedback above to https://api.quox.ai/api/feedback (the Quox team)' });
2786
+ }
2787
+ case '/mouse': {
2788
+ const v = arg.trim().toLowerCase();
2789
+ if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it.`);
2790
+ if (v !== 'on' && v !== 'off') return say('Usage: /mouse on|off', 'warn');
2791
+ MO.current.on = v === 'on'; MO.current.drag = null;
2792
+ api.saveSetting?.('mouse', v);
2793
+ mouseTerm(MO.current.on);
2794
+ return say(MO.current.on ? mouseOnNote() : 'Mouse off. Your terminal selects, copies and scrolls as usual again.', MO.current.on ? undefined : 'ok');
2795
+ }
2796
+ case '/r': case '/reply': {
2797
+ if (!R.current.lastSender) return say('Nobody has messaged you in this window yet. /say <slot> <text> starts one.', 'warn');
2798
+ if (!restArg) return say(`Usage: /r <text> (replies to ${R.current.lastSender})`, 'warn');
2799
+ return sendToSlot(R.current.lastSender.toLowerCase(), restArg.trim());
2800
+ }
2801
+ // Dragon verbs (owner 2026-10-15), only where an existing command does exactly that; the rest are an idea, not code.
2802
+ case '/mode': { // PLAN MODE (owner 2026-10-16): /mode [ask|plan]; auto is planned, not built
2803
+ if (!restArg.trim()) { // same arrow-key chooser as /model; /mode -h still explains
2804
+ const cur = normMode(MD.current);
2805
+ PK.current.open({ title: 'Mode for this window', rows: MODE_ROWS.map(x => ({ key: `${x.id === cur ? '* ' : ' '}${x.id}`, text: x.text })), sel: MODES.indexOf(cur), hint: '↑↓ select · Enter use · Esc cancel', onPick: (_, i) => command(`/mode ${MODES[i]}`) });
2806
+ return undefined;
2807
+ }
2808
+ const m = modeReply(restArg, MD.current);
2809
+ if (m.mode) { MD.current = m.mode; R.current.planAsk = null; bump(n => n + 1); }
2810
+ return say(m.text, m.tone || undefined);
2811
+ }
2812
+ case '/sleep': { // owner typed /sleep first (2026-10-16); it now MEANS sleeping: sessions stop waiting on approvals
2813
+ const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: 'sleeping' };
2814
+ try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2815
+ const pr = await setPresence('sleeping');
2816
+ if (!pr.ok) presenceFail(pr);
2817
+ return command('/dragon sleep');
2818
+ }
2819
+ case '/roar': return command(`/broadcast${restArg ? ` ${restArg}` : ''}`);
2820
+ case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
2821
+ case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
2822
+ case '/huh': return command(`/dragon explain${restArg ? ` ${restArg}` : ''}`); // owner 2026-10-15: shortcut for /dragon explain
2823
+ case '/slots': return listSlots(/^all$/i.test(restArg.trim()));
2824
+ case '/mirror': {
2825
+ // The Mirror itself: this org's server-built Mirror, what CommanderQ is told every turn (not /mind).
2826
+ if (!api.mirror) return say('/mirror is not available in this mode.', 'warn');
2827
+ const view = /^agents?$/i.test(restArg.trim()) ? 'agents' : null;
2828
+ try { dispatch({ type: 'object', obj: { type: 'mirror', data: { ...(await api.mirror()), ...(view ? { view } : {}) } } }); }
2829
+ catch (e) { say(mirrorErrorNote(e), 'warn'); }
2830
+ return undefined;
2831
+ }
2832
+ case '/core': case '/health': case '/collector-health': return command('/status'); // owner reached for these first; /health and /core are the dashboard card above when the objects api is there
2833
+ case '/walkthrough': return command('/approvals walkthrough');
2834
+ case '/yestoall': return command(`/approve all${arg ? ` ${arg}` : ''}`); // owner 2026-10-15: alias of /approve all
2835
+ case '/authenticate': {
2836
+ if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
2837
+ await SUC.current.authenticate(); bump(n => n + 1); return undefined;
2838
+ }
2839
+ case '/override': {
2840
+ const res = await SUC.current.override(restArg);
2841
+ if (res?.override_id && res.enforced) R.current.override = { id: res.override_id, until: Date.parse(res.expires_at) || Date.now() + 300000 };
2842
+ return undefined;
2843
+ }
2844
+ case '/authorise': case '/authorize': {
2845
+ if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
2846
+ let authArg = restArg;
2847
+ if (!authArg.trim()) { // QUOXON-1191: bare /authorise picks the one item that needs a PIN; with several it lists them
2848
+ let li = S.current.inbox;
2849
+ if (!li.length) { try { li = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: li }); } catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); } }
2850
+ const needs = li.map((x, k) => ((isDecision(x) ? x.payload.high_risk === true && x.payload.options.some(o => o.key === 'y') : (x.payload?.approval_context?.risk === 'high' || x.payload?.high_risk === true)) ? k + 1 : 0)).filter(Boolean);
2851
+ if (needs.length === 1) authArg = String(needs[0]);
2852
+ else return say(needs.length ? `${needs.length} items need your PIN, so say which: ${needs.map(k => mark(`/authorise ${k}`)).join(' ')}` : 'Usage: /authorise N (approve item N with your PIN; list them with /approvals). Nothing waiting needs a PIN right now.', 'warn');
2853
+ }
2854
+ R.current.authorising = true; // /approve's answer() asks for the PIN first when there is no live step-up
2855
+ try { await command(`/approve ${authArg}`); } finally { R.current.authorising = false; }
2856
+ return undefined;
2857
+ }
2858
+ case '/answer': { // quox ask: pick an option of a decision a session asked
2859
+ const words = line.trim().split(/\s+/).slice(1);
2860
+ const fo = R.current.focusedDecision;
2861
+ const pa = parseAnswer(words, { focusN: fo && Date.now() - fo.at < 600000 ? fo.n : null, raw: line.trim().replace(/^\S+\s*/, '') });
2862
+ if (pa.bad) return say(`Usage: ${mark('/answer 3 b')} (item number, then the option letter), optionally with a note: ${mark('/answer 3 b "after Friday"')}. Several at once: ${mark('/answer 1 y 2 n')}. List them with ${mark('/approvals')}.`, 'warn');
2863
+ if (await listChanged()) return undefined;
2864
+ let list = S.current.inbox;
2865
+ if (!list.length || (pa.n ? !list[pa.n - 1] : true)) {
2866
+ try {
2867
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
2868
+ list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item));
2869
+ dispatch({ type: 'inbox', items: list });
2870
+ } catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2871
+ }
2872
+ if (pa.ns) { // QUOXON-1191: /answer 1-5 y, /answer 1,3,4 y: validate all, ask the PIN once if any is high risk
2873
+ const keyOf = (k) => pa.keys?.[k] ?? pa.key; // QUOXON-1191b: pairs carry their own y|n per item
2874
+ if (pa.ns.some(k => !list[k - 1])) {
2875
+ try { list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: list }); }
2876
+ catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2877
+ }
2878
+ if (pa.note && noteNeedsConfirm(pa.note) && !R.current.noteBypass) { // NOTE-CONFIRM: one card for the whole batch
2879
+ const base = pa.keys ? pa.ns.map(k => `${k} ${pa.keys[k]}`).join(' ') : `${pa.ns.join(',')} ${pa.key}`;
2880
+ noteCard({ walk: false, batch: true, count: pa.ns.length, cmd: base, key: pa.key, label: pa.keys ? [...new Set(Object.values(pa.keys))].join('/') : pa.key, note: pa.note });
2881
+ return undefined;
2882
+ }
2883
+ const bad = pa.ns.filter(k => !list[k - 1] || !isDecision(list[k - 1]) || !list[k - 1].payload.options.some(o => o.key === keyOf(k)));
2884
+ if (bad.length) return say(`Nothing sent. ${bad.map(k => `[${k}]`).join(' ')} ${bad.length === 1 ? 'is' : 'are'} not ${list.length ? `a decision with option "${bad.map(keyOf).filter((v, i, a2) => a2.indexOf(v) === i).join('/')}"` : 'listed'}. Use ${mark('/approvals')} to see the options.`, 'warn');
2885
+ const bulkAns = pa.ns.length > 1 && !pa.keys; // explicit per-item pairs (1 y 2 n) name each item and key themselves
2886
+ let walkAfter = null;
2887
+ const heldNs = bulkAns ? pa.ns.filter(k => keyOf(k) !== 'n' && isHeldFromBulk(list[k - 1])) : []; // a bulk yes never covers a risky ask
2888
+ if (heldNs.length) {
2889
+ pa.ns = pa.ns.filter(k => !heldNs.includes(k));
2890
+ say(`${heldNs.length} risky decision${heldNs.length === 1 ? '' : 's'} held back from the bulk yes (${[...new Set(heldNs.map(k => heldReason(list[k - 1])))].join(', ')}). I will take ${heldNs.length === 1 ? 'it' : 'them'} one at a time, with the full text, after the rest.`, 'warn');
2891
+ walkAfter = heldNs.map(k => list[k - 1]);
2892
+ }
2893
+ const items = pa.ns.map(k => list[k - 1]);
2894
+ const gate = pa.ns.some(k => keyOf(k) !== 'n' && list[k - 1].payload.high_risk === true);
2895
+ const askPin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject: { line1: `answer ${items.length} decisions ${[...new Set(pa.ns.map(keyOf))].join('/')}`, raisedBy: null, risk: 'high' } }); if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; } };
2896
+ try { if (gate && !SU.current.token('approve')) await askPin(); }
2897
+ catch (e) { return say(`Not answered: ${e.message}. Nothing was sent.`, 'warn'); }
2898
+ const okIds = []; const failedK = [];
2899
+ for (const k of pa.ns) {
2900
+ const x = list[k - 1];
2901
+ const go = () => api.answerDecision(x.id, keyOf(k), { note: pa.note || null, stepUp: SU.current.headers('approve'), bulk: bulkAns });
2902
+ try {
2903
+ try { await go(); }
2904
+ catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
2905
+ okIds.push(x.id); walkAnsweredIds().add(x.id);
2906
+ say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(x.title || x.id, Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
2907
+ if (x.payload.slot && api.slotSay) await api.slotSay(String(x.payload.slot).toLowerCase(), `Your question "${clip(x.title, 80)}" was answered: ${keyOf(k)}. Read it with: quox ask status ${x.id}`, { from: myLabel() });
2908
+ } catch (e) { failedK.push(`[${k}] ${e?.status === 404 || e?.status === 400 ? 'already handled or expired' : e.message}`); if (e?.stepUpDeclined) break; }
2909
+ }
2910
+ if (failedK.length) say(`Not answered: ${failedK.join('; ')}`, 'warn');
2911
+ R.current.focusedDecision = null;
2912
+ dispatch({ type: 'inbox', items: list.filter(x => !okIds.includes(x.id)) });
2913
+ if (R.current.shownIds) R.current.shownIds = list.filter(x => !okIds.includes(x.id)).map(x => x.id);
2914
+ if (walkAfter) walkStart(walkAfter);
2915
+ return undefined;
2916
+ }
2917
+ if (!pa.n) { // no number: fine when exactly one decision is open
2918
+ const idx = list.map((x, k) => (isDecision(x) ? k + 1 : 0)).filter(Boolean);
2919
+ if (idx.length !== 1) return say(idx.length ? `${idx.length} decisions are open, so say which: ${idx.slice(0, 3).map(k => mark(`/answer ${k} ${list[k - 1].payload.options.map(o => o.key).join('|')}`)).join(' ')}` : 'No decision is waiting for an answer.', 'warn');
2920
+ pa.n = idx[0];
2921
+ }
2922
+ const it = list[pa.n - 1];
2923
+ if (!it) return say(`No such item: [${pa.n}] (${list.length} listed).`, 'warn');
2924
+ if (!isDecision(it)) return say(`[${pa.n}] is an approval, not a question. Use ${mark(`/approve ${pa.n}`)} or ${mark(`/deny ${pa.n} <reason>`)}.`, 'warn');
2925
+ if (!it.payload.options.some(o => o.key === pa.key)) return say(`[${pa.n}] has no option "${pa.key}". Options: ${it.payload.options.map(o => `(${o.key}) ${o.label}`).join(' ')}`, 'warn');
2926
+ if (pa.note && noteNeedsConfirm(pa.note) && !R.current.noteBypass) { noteCard({ walk: false, cmd: `${pa.n} ${pa.key}`, key: pa.key, label: it.payload.options.find(o => o.key === pa.key).label, note: pa.note, id: it.id }); return undefined; }
2927
+ say(`Answering: ${clip(it.title || it.id, Math.max(20, cols - 30))} → ${it.payload.options.find(o => o.key === pa.key).label} (${pa.key})${pa.note ? `, with your note (${pa.note.length} characters)` : ''}`, 'dim'); // DECISION-LABELS: say which question and which option BEFORE it is sent
2928
+ try { // QUOXON-1191: a yes to a high-risk ask needs the PIN: ask up front, or on the server's step_up_required, then retry once
2929
+ const gate = it.payload.high_risk === true && pa.key !== 'n';
2930
+ const subject = { line1: `answer ${String(it.title || "")}`, raisedBy: it.payload.session || null, risk: 'high' };
2931
+ const askPin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject }); if (!r.ok) { const err = new Error(declinedText(r.reason)); err.stepUpDeclined = true; throw err; } };
2932
+ if (gate && !SU.current.token('approve')) await askPin();
2933
+ const go = () => api.answerDecision(it.id, pa.key, { note: pa.note || null, stepUp: SU.current.headers('approve') });
2934
+ try { await go(); }
2935
+ catch (e) {
2936
+ if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
2937
+ SU.current.drop('approve'); await askPin(); await go();
2938
+ }
2939
+ }
2940
+ catch (e) {
2941
+ if (e?.stepUpDeclined) return say(R.current.walkSend ? `Not answered: ${e.message}.` : `Not answered: ${e.message}. Run ${mark(`/answer ${pa.n} ${pa.key}`)} again when you can confirm.`, 'warn'); // in a walkthrough the card says what to do next
2942
+ if (e?.status === 404 || e?.status === 400) { R.current.goneAsk = it.id; return say(`[${pa.n}] ${expiredText(it)}`, 'accent'); }
2943
+ return dispatch({ type: 'error', text: `answer: ${e.message}` });
2944
+ }
2945
+ say(' ');
2946
+ say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(it.title || it.id, Math.max(20, cols - 40))} -> (${pa.key}) ${it.payload.options.find(o => o.key === pa.key).label}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
2947
+ say(' ');
2948
+ R.current.lastAnswered = it.id; walkAnsweredIds().add(it.id);
2949
+ if (it.payload.slot && api.slotSay) { // WALK-FIXES: the answer is already recorded; the slot post never holds up the next card
2950
+ const slotNote = (m) => say(`Answer recorded; could not post it to slot ${it.payload.slot} (${m}). The asker gets it from: quox ask wait ${it.id}`, 'warn');
2951
+ const posting = Promise.resolve().then(() => api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was answered: ${pa.key}${pa.note ? ` (${pa.note})` : ''}. Read it with: quox ask status ${it.id}`, { from: myLabel() })).then(r => { if (!r?.ok) slotNote(r?.message || 'no reply'); }, e => slotNote(e?.message || 'failed'));
2952
+ if (!R.current.walkSend) await posting;
2953
+ }
2954
+ R.current.focusedDecision = null;
2955
+ dispatch({ type: 'inbox', items: list.filter(x => x.id !== it.id) });
2956
+ if (R.current.shownIds) R.current.shownIds = list.filter(x => x.id !== it.id).map(x => x.id);
2957
+ return undefined;
2958
+ }
2959
+ case '/approve': case '/deny': {
2960
+ const verb = cmd === '/approve' ? 'approve' : 'reject';
2961
+ const words = line.trim().split(/\s+/).slice(1);
2962
+ const prs = cmd === '/approve' ? parsePairs(words) : null; // QUOXON-1191b: /approve 1 y 2 n, 1y 2n
2963
+ if (prs) {
2964
+ if (prs.bad) return say(`Could not read "${words.join(' ')}". Use each number once: ${mark('/approve 1 y 2 n')}.`, 'warn');
2965
+ let pl = S.current.inbox;
2966
+ if (!pl.length || prs.some(x => !pl[x.k - 1])) {
2967
+ try { pl = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean) }).flatMap(g => g.items.map(x => x.item)); dispatch({ type: 'inbox', items: pl }); }
2968
+ catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2969
+ }
2970
+ const missingP = prs.filter(x => !pl[x.k - 1]);
2971
+ if (missingP.length) return say(`Nothing sent. ${missingP.map(x => `[${x.k}]`).join(' ')} ${missingP.length === 1 ? 'is' : 'are'} not listed (${pl.length} listed). Use ${mark('/approvals')}.`, 'warn');
2972
+ const decP = prs.filter(x => isDecision(pl[x.k - 1])); const plainP = prs.filter(x => !isDecision(pl[x.k - 1]));
2973
+ if (decP.length) await command(`/answer ${decP.map(x => `${x.k} ${x.key}`).join(' ')}`);
2974
+ for (const x of plainP) await command(x.key === 'y' ? `/approve ${x.k}` : `/deny ${x.k}`);
2975
+ return undefined;
2976
+ }
2977
+ const pd = parseDecision(words, verb, { raw: line.trim().replace(/^\S+\s*/, '') });
2978
+ const { confirm, all, agent, reason } = pd; const nums = pd.nums;
2979
+ const spec = words.filter(w => w !== 'confirm');
2980
+ const usage = `Usage: ${mark(`${cmd} N [N...]`)} | ${mark(`${cmd} 1,2`)}${verb === 'approve' ? ` | ${mark('/approve 1 y 2 n')}` : ''} | ${mark(`${cmd} 1-3`)} | ${mark(`${cmd} all`)} | ${mark(`${cmd} agent <name>`)}${verb === 'reject' ? ` (add a reason after the target: ${mark('/deny 2 wrong host')})` : ''} (privileged ones need: ${mark(`${cmd} N confirm`)}). List them with ${mark('/approvals')}.`;
2981
+ if (pd.bad) return say(`${spec.length ? `Could not read "${spec.join(' ')}". ` : ''}${usage}`, 'warn');
2982
+ if (!all && !agent && nums.length && await listChanged()) return undefined;
2983
+ let list = S.current.inbox;
2984
+ if (!list.length || (!all && nums.some(k => !list[k - 1]))) { // never send the user back to /approvals: fetch the same list, in the same order, ourselves
2985
+ try {
2986
+ const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
2987
+ list = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds }).flatMap(g => g.items.map(x => x.item));
2988
+ dispatch({ type: 'inbox', items: list });
2989
+ } catch (e) { return dispatch({ type: 'error', text: `Could not check inbox approvals (${e.message}); try again or run: quox inbox list` }); }
2990
+ }
2991
+ if (!list.length) return say('Nothing is waiting for your approval.', 'warn');
2992
+ let targets = all ? list.map((_, k) => k + 1) : [...new Set(nums)];
2993
+ if (agent) {
2994
+ targets = list.map((it, k) => (matchesAgent(it, agent) ? k + 1 : 0)).filter(Boolean);
2995
+ if (!targets.length) return say(`Nothing waiting from "${agent}". ${mark('/approvals agents')} shows who is asking.`, 'warn');
2996
+ }
2997
+ const privOf = (it) => isPrivileged({ tool: it.payload?.approval_context?.method?.tool, risk: it.payload?.approval_context?.risk, flag: it.payload?.privileged === true || it.payload?.approval_context?.privileged === true, title: it.title });
2998
+ const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = [];
2999
+ const heldDecisions = []; const done = []; const heldPriv = []; const failed = []; const missing = []; const gone = [];
3000
+ const label = (k) => { const it = list[k - 1]; return `[${k}] ${approvalSourceLabel(it)}: ${approvalWhat(it)}`; }; // owner 2026-10-15: no repeated who, no cut at all (lines wrap); the server caps summary length
3001
+ for (const k of targets) {
3002
+ const it = list[k - 1];
3003
+ if (!it) { missing.push(k); continue; }
3004
+ if (isDecision(it)) {
3005
+ if (all || agent) { heldDecisions.push(k); continue; } // a bulk approve never answers a human question for you
3006
+ if (verb === 'approve' && bulkish && isHeldFromBulk(it)) { heldRisky.push(k); continue; } // risky asks are never part of a bulk yes
3007
+ if (verb === 'approve' && !bulkish && it.payload.options.some(o => o.key === 'y')) say(`Answering: ${it.title || it.id} → ${it.payload.options.find(o => o.key === 'y').label} (y)`, 'dim');
3008
+ if (verb === 'approve' && !it.payload.options.some(o => o.key === 'y')) { failed.push(`[${k}] is a question, not yes/no: /answer ${k} ${it.payload.options.map(o => o.key).join('|')} ${it.payload.options.map(o => `(${o.key}) ${o.label}`).join(' ')}`); continue; }
3009
+ }
3010
+ if (verb === 'approve' && privOf(it) && !(confirm && !all && !agent)) { heldPriv.push(k); continue; }
3011
+ try {
3012
+ const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
3013
+ const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: dsc.tool || clip(it.title || it.id, cols - 40), target: dsc.target, raisedBy: approvalSourceLabel(it), risk: it.payload?.approval_context?.risk || null }, verb, { batch: true, reason, proactive: Boolean(R.current.authorising) && verb === 'approve', bulk: bulkish && isDecision(it) });
3014
+ if (r?.gone) gone.push(k); else if (r?.ok) done.push(k);
3015
+ } catch (e) { failed.push(`[${k}] ${e.message}`); }
3016
+ }
3017
+ if (done.length || gone.length || failed.length || missing.length) say(' ');
3018
+ for (const k of done) say(`${verb === 'approve' ? '✔ approved' : '✔ denied'} ${label(k)}${reason ? ` (reason: ${clip(reason, 40)})` : ''}`, 'ok');
3019
+ if (gone.length) say(`already handled: ${gone.map(k => `[${k}]`).join(' ')}`, 'accent');
3020
+ if (done.length > 1) say(`${verb === 'approve' ? 'Approved' : 'Denied'}: ${done.map(k => `[${k}]`).join(' ')}`, 'ok');
3021
+ if (missing.length) say(`No such item: ${missing.map(k => `[${k}]`).join(' ')} (${list.length} listed).`, 'warn');
3022
+ for (const f of [...new Set(failed)]) dispatch({ type: 'error', text: `approval: ${f}` });
3023
+ for (const k of done) { // quox ask: tell the asking session on its slot, as /answer does
3024
+ const it = list[k - 1];
3025
+ if (isDecision(it) && it.payload.slot && api.slotSay) await api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was ${verb === 'approve' ? 'answered: y' : `declined${reason ? ` (${reason})` : ''}`}. Read it with: quox ask status ${it.id}`, { from: myLabel() });
3026
+ }
3027
+ if (done.length || gone.length || failed.length || missing.length) say(' ');
3028
+ const letteredHeld = heldDecisions.filter(k => !list[k - 1].payload.options.some(o => o.key === 'y'));
3029
+ const riskyYn = verb === 'approve' ? heldDecisions.filter(k => !letteredHeld.includes(k) && isHeldFromBulk(list[k - 1])) : [];
3030
+ const walkKs = [...new Set([...letteredHeld, ...riskyYn, ...heldRisky])].sort((a, b) => a - b);
3031
+ const ynShown = heldDecisions.filter(k => !letteredHeld.includes(k) && !riskyYn.includes(k));
3032
+ const ynHeld = verb === 'approve' && all && !agent ? ynShown : [];
3033
+ if (ynShown.length) say(`${ynShown.length} decision${ynShown.length === 1 ? '' : 's'} left for you to pick: ${ynShown.map(k => `/answer ${k} ${list[k - 1].payload.options.map(o => o.key).join('|')}`).join(', ')} (or /deny ${ynShown.join(',')} <reason>)`, 'warn');
3034
+ if (ynHeld.length) { // ONE card for the LOW-RISK y/N decisions only, each listed in full; risky ones were held back for the walkthrough
3035
+ for (const k of ynHeld) say(` [${k}] ${list[k - 1].title || list[k - 1].id}`, 'dim');
3036
+ R.current.pendingGrant = { cmd, verb, items: ynHeld.map(k => list[k - 1]), ks: ynHeld, decisions: true, hi: 0, at: Date.now(), thenWalk: walkKs.length ? walkKs.map(k => list[k - 1]) : null };
3037
+ ask(`Answer yes to ${ynHeld.length === 1 ? 'it' : `all ${ynHeld.length}`}?`, { who: '', action: `/answer ${ynHeld.join(',')} y`, reason: `${walkKs.length ? `${walkKs.length} risky or lettered held back for the walkthrough. ` : ''}Each one listed above is answered yes.`, keys: 'y answers yes to these, N cancels' });
3038
+ }
3039
+ if (walkKs.length) {
3040
+ say(`${walkKs.length} question${walkKs.length === 1 ? '' : 's'} held back from any bulk yes (${[...new Set(walkKs.map(k => heldReason(list[k - 1]) || 'needs a real choice'))].join(', ')}). I will take ${walkKs.length === 1 ? 'it' : 'them'} one at a time, with the full text, each with your own yes.`, 'warn');
3041
+ if (!ynHeld.length) walkStart(walkKs.map(k => list[k - 1])); // with a bulk card up, the walkthrough starts once the card is answered
3042
+ }
3043
+ if (heldPriv.length && (all || agent)) say(`${heldPriv.length} privileged left: ${heldPriv.map(k => `${cmd} ${k} confirm`).join(', ')}`, 'warn');
3044
+ else if (heldPriv.length) { // ask inline instead of sending the user to a second command
3045
+ R.current.pendingGrant = { cmd, verb, items: heldPriv.map(k => list[k - 1]), at: Date.now() };
3046
+ for (const k of heldPriv) {
3047
+ const it = list[k - 1]; const c = it.payload?.approval_context || {};
3048
+ const tools = [].concat(c.elevated_tools || c.tools || it.payload?.tools || c.method?.target || []).filter(Boolean).join(', ');
3049
+ say(`Grant ${clip(it.title || it.id, cols - 30)} (privileged)${tools ? `: elevated tools ${tools}` : ''}${c.on_behalf_of?.conversation_id ? ' for that conversation' : ''}`, 'warn');
3050
+ }
3051
+ ask(`Grant ${heldPriv.length === 1 ? 'it' : `all ${heldPriv.length}`}?`, { who: heldPriv.map(k => approvalSourceLabel(list[k - 1])).filter(Boolean)[0] || '', action: `${cmd} ${heldPriv.join(' ')} confirm`, reason: 'privileged: it grants elevated tools.', keys: 'y grants, anything else cancels' });
3052
+ }
3053
+ return undefined;
3054
+ }
3055
+ default: {
3056
+ const e = findEntry(cmd);
3057
+ if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
3058
+ if (!e && await slotNameCommand(cmd, restArg)) return undefined;
3059
+ const cliHit = resolveCliSlash(cmd, restArg); // QUOXON-FW: slash words that name a real quox CLI leaf
3060
+ if (cliHit) return cliSlashRun(cliHit);
3061
+ const loose = looseCommand(cmd, restArg);
3062
+ if (loose?.run) { say(`ran ${loose.name}`, 'dim'); return command(loose.run); }
3063
+ if (loose?.suggest) return say(`Unknown command ${cmd}. Did you mean ${loose.suggest}? (/help lists them)`, 'warn');
3064
+ if (loose?.ask) { R.current.askQuox = { text: loose.ask, at: Date.now() }; return ask('Ask Quox instead?', { action: `send as a chat turn: ${loose.ask}`, reason: 'Not a command.' }); }
3065
+ const near = nearestCommands(cmd);
3066
+ if (!near.length) return say(`Unknown command ${cmd}. Try /help`, 'warn');
3067
+ // spelling-only hints keep the plain "/a or /b" line; keyword hints say what each command does
3068
+ const byWords = near.some(x => x.kw);
3069
+ say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(x => x.name).join(' or ')}? (/help lists them)`, 'warn');
3070
+ if (near.length === 1) { // one strong match: offer it as a y/N card (vetted, never a human-typed command)
3071
+ const v = vetSuggestion(near[0].name);
3072
+ if (v.ok) { R.current.suggestAsk = { line: v.line, at: Date.now() }; ask(`Run ${v.line}?`, { action: v.line, reason: 'Nearest command to what you typed.' }); }
3073
+ }
3074
+ return undefined;
3075
+ }
3076
+ }
3077
+ }
3078
+
3079
+ // Closest slots (with host) and agents for a mistyped name, each labelled which is which.
3080
+ async function nearbyTargets(name, slots, agents = null) {
3081
+ let ag = agents;
3082
+ if (!ag && api.agents) { try { ag = await api.agents(); } catch { ag = null; } }
3083
+ const out = [];
3084
+ const sl = Array.isArray(slots) ? slots : [];
3085
+ for (const n of nearest(name, sl.map(x => x.name))) {
3086
+ const row = sl.find(x => x.name === n);
3087
+ const host = row?.holder_identity ? String(row.holder_identity).replace(/^resident:/, '') : (row?.host_name || row?.hostname || null); // never host_id: an internal id
3088
+ out.push(`slot ${n}${host ? ` (${host})` : ''}`);
3089
+ }
3090
+ const ids = (Array.isArray(ag) ? ag : []).map(a => String(a?.agent_id ?? a?.id ?? a?.name ?? ''));
3091
+ for (const n of nearest(name, ids)) out.push(`agent ${n}`);
3092
+ return out;
3093
+ }
3094
+
3095
+ // QUOXON-ASK y/N/c (owner 2026-10-15 "i need y/n/chat more"): the open composer asks, each with its own answer
3096
+ // handler below. An ask counts as open while its 2-minute window runs.
3097
+ const ASK_TTL = 120000;
3098
+ function openAskStates() {
3099
+ const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
3100
+ const W = R.current.pendingWalk; const walkCardSt = W && W.card && Date.now() - W.at < WALK_TTL ? W.card : null; // WALK-FIXES: one card per decision, open as long as the walk (30 min), not the 2-minute ask clock
3101
+ return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.profileAsk, R.current.cliAsk, R.current.updateAsk, R.current.orgPending, R.current.hatPending, HW.current.card(), RW.current.walk.card(), R.current.suggestAsk && !R.current.suggestLine ? R.current.suggestAsk : null, R.current.handoffAsk, R.current.askQuox, R.current.strayAsk, R.current.planAsk, SC.current.pendingFile, SC.current.reattach, CH.current.pending, SPN.current.pending].map(fresh)].filter(Boolean);
3102
+ }
3103
+ const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || UW.current.pending?.() || runAskOpen();
3104
+ function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); UW.current.touch?.(); }
3105
+ // ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): each live ask card is
3106
+ // bound to the open state it asks about; once that state is no longer open the card goes and ONE line stays:
3107
+ // "✔ Wake it now? yes (00:44)", "⌛ ... expired, not answered", "↷ ... no longer open". Run requests likewise.
3108
+ const askBind = React.useRef(new Map()); // card id -> its state object | 'kc' | 'uw' | 'any'
3109
+ function askStateOpen(st) {
3110
+ if (st === 'kc') return Boolean(KC.current.pending?.());
3111
+ if (st === 'uw') return Boolean(UW.current.pending?.());
3112
+ if (st === 'any') return askOpen();
3113
+ return openAskStates().includes(st);
3114
+ }
3115
+ function settleRun(rq, outcome, now = Date.now()) {
3116
+ SC.current.runCards?.delete(rq.askId);
3117
+ dispatch({ type: 'slotmsg.settle', askId: rq.askId, text: settledAskLine(`Run ${clip(String(rq.cmd || ''), Math.max(20, cols - 40))}?`, outcome, now), tone: settledAskTone(outcome.kind) });
3118
+ }
3119
+ function reconcileAsks() {
3120
+ const now = Date.now();
3121
+ const takeAnswer = () => { const a = R.current.askAnswer; R.current.askAnswer = null; return a && now - a.at < 3000 ? a : null; };
3122
+ const bind = askBind.current;
3123
+ const cards = [...(S.current.asks || [])].reverse(); // newest first: an answer belongs to the newest card it closes
3124
+ for (const cd of cards) {
3125
+ if (!bind.has(cd.id)) {
3126
+ const taken = new Set(bind.values());
3127
+ const st = openAskStates().filter(x => !taken.has(x)).sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0];
3128
+ bind.set(cd.id, st || (KC.current.pending?.() ? 'kc' : UW.current.pending?.() ? 'uw' : 'any'));
3129
+ }
3130
+ const st = bind.get(cd.id);
3131
+ if (askStateOpen(st)) continue;
3132
+ bind.delete(cd.id);
3133
+ const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
3134
+ if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
3135
+ takeAnswer();
3136
+ if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
3137
+ else dispatch({ type: 'ask.settle', id: cd.id, text: '', tone: 'plain', silent: true });
3138
+ continue;
3139
+ }
3140
+ const outcome = expired ? { kind: 'expired' } : takeAnswer() || { kind: st === 'any' || st === 'kc' || st === 'uw' ? 'expired' : 'closed' };
3141
+ dispatch({ type: 'ask.settle', id: cd.id, text: settledAskLine(askQuestion(cd.text), outcome, now), tone: settledAskTone(outcome.kind) });
3142
+ }
3143
+ for (const [id, rq] of [...(SC.current.runCards || new Map())]) {
3144
+ const live = SC.current.runAsk?.askId === id;
3145
+ if (live && runAskOpen()) continue;
3146
+ const expired = rq.expiresAt != null && (runNow() >= rq.expiresAt || now >= rq.expiresAt);
3147
+ const outcome = expired ? { kind: 'expired' } : (!live && takeAnswer()) || { kind: 'closed' };
3148
+ settleRun(rq, outcome, now);
3149
+ }
3150
+ }
3151
+ React.useEffect(() => {
3152
+ const t = setInterval(() => { if ((S.current.asks || []).length || SC.current.runCards?.size) reconcileAsks(); }, 1000);
3153
+ t.unref?.();
3154
+ return () => clearInterval(t);
3155
+ }, []);
3156
+ /** c or free text while an ask is open: keep it open, send a chat turn about it, and show the same ask again after. */
3157
+ function chatAboutAsk(text) {
3158
+ const la = R.current.lastAsk;
3159
+ const q = la?.question || 'the question above';
3160
+ touchAsks();
3161
+ R.current.reask = la || null;
3162
+ const msg = askChatMessage(text, q);
3163
+ touchSeen(); dispatch({ type: 'user', text: /^c(hat)?$/i.test(text.trim()) ? msg : text.trim() }); remember(text); R.current.histIdx = -1;
3164
+ runTurn(msg);
3165
+ }
3166
+
3167
+ function onEnter(now) {
3168
+ if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
3169
+ if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
3170
+ let buf = B.current;
3171
+ // y/N/c: Enter alone answers an open ask "no" (the default), as typing n does.
3172
+ if (!buf.text.trim() && R.current.pendingWalk && !R.current.denyReason) { say('Type a choice for the card above (s skips, Esc stops). Enter alone answers nothing.', 'dim'); return; } // WALK-FIXES: an empty Enter must never answer "no"
3173
+ if (!buf.text.trim() && !R.current.denyReason && !S.current.turn && askOpen()) { B.current = { ...initialBuffer(), text: 'n', cursor: 1 }; buf = B.current; }
3174
+ if (buf.text.endsWith('\\')) { edit({ type: 'set', text: buf.text.slice(0, -1) + '\n', keepPastes: true }); return; }
3175
+ if (!buf.text.trim()) {
3176
+ if (R.current.noteAsk && Date.now() - R.current.noteAsk.at < 300000) { const st = R.current.noteAsk; R.current.noteAsk = null; noteResolve('1', st); return; } // Enter on the note card = the recommended choice
3177
+ if (R.current.denyReason) { const d = R.current.denyReason; R.current.denyReason = null; if (Date.now() - d.at < 120000) { answer(d.card, 'reject'); return; } } // Enter on "Reason?" skips it
3178
+ const s = S.current;
3179
+ if (!s.turn && s.pending.length) { dispatch({ type: 'queue.shift' }); runQueued(s.pending[0].text); }
3180
+ return;
3181
+ }
3182
+ const chk = sanitizeForSend(buf);
3183
+ if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
3184
+ let text = chk.text.trim();
3185
+ const wrapped = joinWrappedSlash(text); // a wrapped paste of "/say slot long text": one command, not a refusal
3186
+ if (wrapped) { text = wrapped.text; say(`joined ${wrapped.lines} lines`, 'dim'); }
3187
+ const keyHit = looksLikeKey(text);
3188
+ if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
3189
+ if (text.startsWith('!') && text.includes('\n')) { // owner 2026-10-15: a wrapped `!git ... push origin\nHEAD:main` ran as two commands
3190
+ const joined = text.split('\n').map(l => l.trim()).filter(Boolean).join(' ');
3191
+ B.current = { ...initialBuffer(), text: joined, cursor: joined.length }; R.current.confirmMulti = null;
3192
+ say(`That ! command arrived on ${text.split('\n').filter(l => l.trim()).length} lines (a wrapped paste?). Joined into one line in the composer: check it, then Enter runs it.`, 'warn');
3193
+ bump(n => n + 1); return;
3194
+ }
3195
+ const bang = parseBang(text);
3196
+ if (bang) { // QUOXON-FW8: the user's own command on their own machine; never a turn, never the server
3197
+ if (bang.kind !== 'reserved' && bang.kind !== 'empty' && R.current.bang) { say('A command is still running. Ctrl+C stops it; your draft is kept.', 'warn'); return; }
3198
+ B.current = initialBuffer(); R.current.confirmMulti = null; remember(text); R.current.histIdx = -1;
3199
+ echoCmd(text); bangCommand(bang, { echoed: true }); bump(n => n + 1); return;
3200
+ }
3201
+ const s = S.current;
3202
+ const isCommand = text.startsWith('/') && !text.includes('\n');
3203
+ const ws = R.current.wokeSwallowed; R.current.wokeSwallowed = null;
3204
+ if (ws && !isCommand && !text.startsWith('!') && now - ws.at < 15000 && !askOpen() && !R.current.pendingWalk) { // the key that woke the screen was not typed: this line may be missing its first letter, so nothing goes to the model unless the user means it
3205
+ B.current = { ...initialBuffer(), text, cursor: text.length };
3206
+ say('Not sent: the key that woke the screen was not typed, so this line may be missing its first letter. Check it, then press Enter to send.', 'warn'); bump(n => n + 1); return;
3207
+ }
3208
+ if (blocked && !isCommand) { // Z2H-R4: clear the box (else the next /login is appended and refused); Up recalls the message
3209
+ remember(text); R.current.histIdx = -1; B.current = initialBuffer(); say(blocked, 'warn'); bump(n => n + 1); return;
3210
+ }
3211
+ if (text.startsWith('/') && text.includes('\n') && !R.current.confirmMulti) {
3212
+ // commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter.
3213
+ // The second Enter sends it even after edits (R3 D3: requiring a byte-identical draft swallowed every later Enter).
3214
+ R.current.confirmMulti = true;
3215
+ if (!R.current.multiWarned) {
3216
+ R.current.multiWarned = true;
3217
+ say('That draft starts with / but has several lines, so it is not a command. Press Enter again to send it to Quox as chat, or edit it (Esc Esc clears it).', 'warn');
3218
+ }
3219
+ return;
3220
+ }
3221
+ R.current.confirmMulti = null; R.current.multiWarned = false;
3222
+ B.current = initialBuffer();
3223
+ K.current.composerClicked = false;
3224
+ if (R.current.handoffAsk?.bigger && /^(y(es)?|no?|c|keep)$/i.test(text.trim())) { // "Switch this window back to CommanderQ?" y switch / n just this one / c keep asking
3225
+ const a = R.current.handoffAsk; R.current.handoffAsk = null;
3226
+ const t = text.trim().toLowerCase();
3227
+ const kind = t[0] === 'y' ? 'yes' : t[0] === 'n' ? 'no' : 'dismissed';
3228
+ R.current.askAnswer = { kind, at: Date.now() }; queueMicrotask(() => reconcileAsks());
3229
+ const hs = R.current.handoffStreak;
3230
+ if (Date.now() - a.at >= 120000) { say('That offer expired. Nothing changed.', 'dim'); return; }
3231
+ if (kind === 'yes') { hs?.clear(); echoCmd('/detach'); command('/detach'); return; }
3232
+ if (kind === 'no') { hs?.restart(); runHandoff(a); return; }
3233
+ hs?.mute(); say(`Staying with ${a.h.fromName}.`, 'dim'); return;
3234
+ }
3235
+ // ASK-SETTLE: an answer is remembered; the card it closes settles to one line once this line is handled.
3236
+ let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
3237
+ if (askOpen() && !R.current.noteAsk) {
3238
+ const o = askOutcomeOf(text, { runWord: runAskOpen() }) || (askAnswer(text) === 'chat' && !S.current.turn && !R.current.pendingWalk && !isAskClarification(text) ? { kind: 'dismissed' } : null);
3239
+ const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'profileAsk', 'cliAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
3240
+ const foreign = KC.current.pending?.() || UW.current.pending?.() || HW.current.card() || SC.current.pendingFile || SC.current.reattach || CH.current.pending || SPN.current.pending || runAskOpen();
3241
+ if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
3242
+ else if (o) {
3243
+ askDismissed = o.kind === 'dismissed';
3244
+ R.current.askAnswer = { ...o, at: Date.now() }; queueMicrotask(() => reconcileAsks());
3245
+ if (askDismissed) for (const k of OWN) R.current[k] = null; // the offer is gone: the line below is plain chat, not an answer to anything
3246
+ }
3247
+ }
3248
+ // y/N/c: c or any other free text keeps the open ask and asks Quox about it first (never a silent cancel).
3249
+ if (R.current.noteAsk) { // NOTE-CONFIRM: the line is the pick (1, 2, 3); anything else leaves the card and nothing was sent
3250
+ const st = R.current.noteAsk; R.current.noteAsk = null;
3251
+ const pick = ({ 1: '1', 2: '2', 3: '3', y: '1', yes: '1', n: '2', no: '2', e: '3', edit: '3' })[text.trim().toLowerCase()];
3252
+ if (Date.now() - st.at < 300000 && pick) { noteResolve(pick, st); return; }
3253
+ noteResolve('3', st); // anything else backs out: the whole answer is put back in the composer, never dropped
3254
+ say('Not sent. Your answer and note are back in the composer; press Enter to send it, or edit it first.', 'warn');
3255
+ return;
3256
+ }
3257
+ if (R.current.pendingWalk && !text.startsWith('/')) { walkHandle(text); return; } if (R.current.pendingWalk) walkEnd(`you ran ${text.split(/\s+/)[0]}`); // WALKTHROUGH: the line is the answer to the card on screen; a command ends the walk with its summary
3258
+ if (!askDismissed && !S.current.turn && !noAiNow() && askAnswer(text) === 'chat' && askOpen() && !(runAskOpen() && /^run$/i.test(text.trim()))) { chatAboutAsk(text); return; }
3259
+ if (KC.current.answer(text)) return; // y/n after a /watch, /automate or /crystallize preview card
3260
+ if (UW.current.answer(text)) return; // y/n after a /undo or /wish card
3261
+ if (HW.current.active() && HW.current.answer(text)) return; // CUSTOM-HATS: the next line of a /hat new|edit|delete walk
3262
+ if (RW.current.walk.active() && RW.current.walk.answer(text)) return; // ROWS: the next line of a /row new|edit walk
3263
+ if (R.current.denyReason) { // the n card asked "Reason? (Enter to skip)": this one line is the reason
3264
+ const d = R.current.denyReason; R.current.denyReason = null;
3265
+ if (Date.now() - d.at < 120000 && !text.startsWith('/')) { answer(d.card, 'reject', { reason: text.trim() || null }); return; }
3266
+ }
3267
+ if (R.current.pendingGrant) { // /approve N on a privileged item asked "y/N": this one line is the answer
3268
+ const g = R.current.pendingGrant; R.current.pendingGrant = null;
3269
+ if (Date.now() - g.at < 120000 && !text.startsWith('/')) {
3270
+ if (/^y(es)?$/i.test(text.trim())) {
3271
+ (async () => {
3272
+ if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it)); // defence: a risky ask never rides the bulk card
3273
+ if (g.decisions && g.hi) { // one PIN for the whole batch; the token then rides on every item
3274
+ const r = SU.current.token('approve') ? { ok: true } : await SU.current.request({ scope: 'approve', orgId: api.orgId?.() || null, subject: { line1: `answer yes to ${g.items.length} decisions (${g.hi} high risk)`, raisedBy: null, risk: 'high' } });
3275
+ if (!r.ok) return say(`Not answered: ${declinedText(r.reason)}. Nothing was sent.`, 'warn');
3276
+ }
3277
+ for (const it of g.items) {
3278
+ if (g.decisions) {
3279
+ try {
3280
+ const r = await answer({ callId: it.id, inboxItemId: it.id, approvalId: null, tool: clip(it.title || it.id, cols - 40), target: null, raisedBy: it.payload.session || null, risk: it.payload.high_risk ? 'high' : null }, 'approve', { batch: true, bulk: true });
3281
+ walkAnsweredIds().add(it.id); // answered (or already handled): a walk never shows it again
3282
+ if (r?.gone) say(`already handled: ${clip(it.title || it.id, cols - 30)}`, 'accent'); else say(`✔ answered yes: ${clip(it.title || it.id, cols - 24)}`, 'ok');
3283
+ if (r?.ok && it.payload.slot && api.slotSay) await api.slotSay(String(it.payload.slot).toLowerCase(), `Your question "${clip(it.title, 80)}" was answered: y. Read it with: quox ask status ${it.id}`, { from: myLabel() });
3284
+ } catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); if (e?.stepUpDeclined) return; }
3285
+ continue;
3286
+ }
3287
+ try { await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: clip(it.title || it.id, cols - 40), target: null }, g.verb); say(`✔ Granted ${clip(it.title || it.id, cols - 20)}`, 'ok'); }
3288
+ catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); }
3289
+ }
3290
+ if (g.thenWalk) walkStart(g.thenWalk); // WALK-FIXES: the ONE start, once the yeses are in (it used to start here AND straight away: two cards)
3291
+ })();
3292
+ } else { say('Cancelled; nothing granted.', 'dim'); if (g.thenWalk) { const w = g.thenWalk; setTimeout(() => walkStart(w), 0); } }
3293
+ return;
3294
+ }
3295
+ }
3296
+ if (R.current.feedbackAsk) { // /feedback showed the exact payload and asked "Send it? y/N"
3297
+ const a = R.current.feedbackAsk; R.current.feedbackAsk = null;
3298
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
3299
+ say('Sending to quox.ai…', 'dim');
3300
+ (async () => {
3301
+ const r = await api.feedback(a.payload, { version });
3302
+ if (!r?.ok) say(`Feedback not sent: ${r?.message || 'failed'}`, 'warn');
3303
+ else say(`✔ Sent to the Quox team at quox.ai${r.id != null ? ` (feedback #${r.id})` : ''}. Thank you.`, 'ok');
3304
+ })();
3305
+ return;
3306
+ }
3307
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3308
+ }
3309
+ if (R.current.wakeAsk) { // "<slot> hasn't replied ... Wake it now? y/N/c": y pokes it (/poke = an urgent page that wakes it)
3310
+ const a = R.current.wakeAsk; R.current.wakeAsk = null;
3311
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { echoCmd(`/poke ${a.slot}`); command(`/poke ${a.slot}`); return; }
3312
+ if (!text.startsWith('/')) { say('Not woken.', 'dim'); return; }
3313
+ }
3314
+ if (R.current.profileAsk) { // PROFILE-SWITCH "Switch? y/N/c": only y switches
3315
+ const a = R.current.profileAsk; R.current.profileAsk = null;
3316
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
3317
+ if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
3318
+ }
3319
+ if (R.current.slotCreateAsk) { // "No slot X. Create it? y/N/c" from a /slot near miss: y runs /slot X --create
3320
+ const a = R.current.slotCreateAsk; R.current.slotCreateAsk = null;
3321
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { const l = `/slot ${a.nm} --create`; echoCmd(l); command(l); return; }
3322
+ if (!text.startsWith('/')) { say('No slot created.', 'dim'); return; }
3323
+ }
3324
+ if (R.current.slotAsk) { // "Take it over? y/N" from /slot
3325
+ const a = R.current.slotAsk; R.current.slotAsk = null;
3326
+ if (/^y(es)?$/i.test(text.trim())) { takeSlot(a.nm, true); return; }
3327
+ if (!text.startsWith('/')) { say('Left it alone.', 'dim'); return; }
3328
+ }
3329
+ if (CH.current.answer(text) || SPN.current.answer(text)) return; // /challenge: "Resolve these? y/N"
3330
+ if (R.current.updateAsk) { // /update: "Update now and restart? y/N/c"; y hands the terminal to the update, then restarts
3331
+ const a = R.current.updateAsk; R.current.updateAsk = null;
3332
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
3333
+ if (S.current.turn) { say('A turn is running. Wait for it, then /update.', 'warn'); return; }
3334
+ say(`Updating: ${a.plan.command.display} runs in this terminal, then quox restarts into this conversation.`, 'dim');
3335
+ doRestart({ update: a.plan }); return;
3336
+ }
3337
+ if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
3338
+ }
3339
+ if (R.current.cliAsk) { // QUOXON-FW: a slash word named a WRITE quox command: "Run it? y/N" (typing y is the authorization)
3340
+ const a = R.current.cliAsk; R.current.cliAsk = null;
3341
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { if (R.current.bang) say('A command is still running. Ctrl+C stops it.', 'warn'); else bangCommand(a.bang); bump(n => n + 1); return; }
3342
+ if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
3343
+ }
3344
+ if (R.current.hatPending && !text.startsWith('/')) { // "Put on the NSOC hat? y/N" from /hat: y switches (/hat yes still works)
3345
+ const a = R.current.hatPending;
3346
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/hat yes'); return; }
3347
+ R.current.hatPending = null; say('Hat not changed.', 'dim'); return;
3348
+ }
3349
+ if (R.current.orgPending && !text.startsWith('/')) { // "Switch to X? y/N" from /org switch: y switches (/org yes still works)
3350
+ const a = R.current.orgPending;
3351
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - (a.at || 0) < 120000) { command('/org yes'); return; }
3352
+ R.current.orgPending = null; say(`Not switched. Still in ${R.current.orgName ?? org ?? 'the same org'}.`, 'dim'); return;
3353
+ }
3354
+ if (R.current.suggestAsk) { // "Run /slot hitl? y/N" from terminal_suggest_command: y runs it as if typed (normal command path)
3355
+ const a = R.current.suggestAsk; R.current.suggestAsk = null;
3356
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { const v = vetSuggestion(a.line); if (v.ok) { R.current.preConfirmed = v.id === 'org' ? 'org' : null; echoCmd(v.line); command(v.line); bump(n => n + 1); } return; }
3357
+ if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
3358
+ }
3359
+ if (R.current.handoffAsk) { // "Hand this question to CommanderQ?" (yes or no): yes asks it once, the attach stays
3360
+ const a = R.current.handoffAsk; R.current.handoffAsk = null;
3361
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runHandoff(a); return; }
3362
+ if (!text.startsWith('/')) { say('Not handed over.', 'dim'); return; }
3363
+ }
3364
+ if (R.current.planAsk) { // PLAN MODE: "Run this plan now? y/N/c": y switches back to ask and sends the go-ahead
3365
+ const a = R.current.planAsk; R.current.planAsk = null;
3366
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { MD.current = 'ask'; bump(n => n + 1); say('mode ask first: running the plan.', 'dim'); submit(PLAN_GO); return; }
3367
+ if (!text.startsWith('/')) { say('Not run. Still plan only (/mode ask to switch).', 'dim'); return; }
3368
+ }
3369
+ if (R.current.strayAsk) { // TUI-INPUT: "Did you mean /help? y/N/c" before a queued "n/help" is sent: y runs the command, n sends it as typed
3370
+ const a = R.current.strayAsk; R.current.strayAsk = null;
3371
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { echoCmd(a.command); command(a.command); return; }
3372
+ if (/^no?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runQueued(a.text, { checked: true }); return; }
3373
+ }
3374
+ if (R.current.askQuox) { // an unknown "/words ..." offered to ask Quox: "y/N"
3375
+ const a = R.current.askQuox; R.current.askQuox = null;
3376
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { submit(a.text); return; }
3377
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3378
+ }
3379
+ if (SC.current.pendingFile) { // /send found secret-shaped values: "y/N" (send redacted)
3380
+ const pf = SC.current.pendingFile; SC.current.pendingFile = null;
3381
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - pf.at < 120000) { sendToSlot(pf.slot, fileMessage({ name: pf.f.name, body: pf.f.redacted, size: Buffer.byteLength(pf.f.redacted) })); return; }
3382
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3383
+ }
3384
+ if (SC.current.reattach) { // startup "Reattach to slot X? y/N"
3385
+ const ra = SC.current.reattach; SC.current.reattach = null;
3386
+ if (/^y(es)?$/i.test(text.trim())) { takeSlot(ra.slot, ra.take); return; }
3387
+ if (/^no?$/i.test(text.trim())) { say('Left it.', 'dim'); return; }
3388
+ }
3389
+ if (SC.current.runAsk) { // SLOT-RUN: "Run it? y/N/c" (or "run" for a dangerous one) on a slot sender's command
3390
+ const ra = SC.current.runAsk;
3391
+ const v = runAnswer(text, ra);
3392
+ if (runNow() >= ra.expiresAt) { // runNow: the run-ask clock (a prop so a test can move it 10 minutes without freezing Date.now)
3393
+ SC.current.runAsk = null;
3394
+ if (v === 'run' || v === 'needs-run' || v === 'no') { say(`That run request from ${ra.who} expired (run requests last 10 minutes). Not run; ask them to send it again.`, 'warn'); return; }
3395
+ } else if (v === 'run') { SC.current.runAsk = null; runSlotCommand(ra); return; }
3396
+ else if (v === 'needs-run') { say(`Not run yet. ${ra.danger}, so type run to run it, or n to skip.`, 'warn'); return; }
3397
+ else if (v === 'no') { SC.current.runAsk = null; transcript?.bang?.(ra.cmd, 'declined'); say('Not run.', 'dim'); return; }
3398
+ }
3399
+ if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
3400
+ const roomSend = api.slotSay ? parseRoomSend(text) : null;
3401
+ if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
3402
+ remember(text); R.current.histIdx = -1;
3403
+ if (roomSend.text) postRoom(roomSend.room, roomSend.text); else setActiveRoom(roomSend.room);
3404
+ return;
3405
+ }
3406
+ if (SC.current.active && text.trim() === '#') { SC.current.active = null; say('Back to Quox: plain lines go to the model again.', 'ok'); return; }
3407
+ if (SC.current.active && api.slotSay && !text.startsWith('!') && !text.includes('\n')) { // default room target
3408
+ remember(text); R.current.histIdx = -1;
3409
+ postRoom(SC.current.active, text.trim()); return;
3410
+ }
3411
+ const atSend = parseAtSend(text);
3412
+ if (atSend && api.slotSay) { // QUOXON-MESH: @slot text goes straight to the slot, no model turn, even mid-turn
3413
+ remember(text); R.current.histIdx = -1;
3414
+ sendToSlot(atSend.slot, atSend.text); return;
3415
+ }
3416
+ if (R.current.askArmed && !s.turn) { // /dragon ask with no question: this one line goes to the dragon
3417
+ R.current.askArmed = false; R.current.dragonTalk = null; touchSeen(); dispatch({ type: 'user', text });
3418
+ remember(text); R.current.histIdx = -1; dragonTurn(DRAGON_ANSWERS, askPrompt(text)); return;
3419
+ }
3420
+ if (s.turn && alreadyQueued(text, { pending: s.pending, steering: s.steering, waiting: R.current.waitingServer })) { say('Already queued: that exact message is waiting to send.', 'dim'); return; } // TUI-INPUT: Enter again on the same queued text adds no copy
3421
+ if (s.turn && !R.current.caps.steer) { // steering already refused for this turn: queue, say so once
3422
+ dispatch({ type: 'queue.add', text });
3423
+ if (!R.current.steerNoted) { R.current.steerNoted = true; say(STEER_OFF_NOTE, 'warn'); }
3424
+ return;
3425
+ }
3426
+ if (s.turn) { steer(text); return; }
3427
+ submit(text);
3428
+ }
3429
+
3430
+ /**
3431
+ * QUOXON-FW15: y/n answer the shown card, but ONLY while the composer is empty (no draft, not mid-command). With a draft
3432
+ * they are ordinary text, which is what stops a stray keystroke approving. A privileged (or undescribable) card needs
3433
+ * two deliberate presses: y, then y again; any other key cancels. Returns true when the key was consumed.
3434
+ */
3435
+ // ---- QUOXON-MOUSE ------------------------------------------------------------------------------
3436
+ /** Turn tracking on or off at the terminal (TTY only). On also arms an exit hook so a crash never leaves it on. */
3437
+ function mouseTerm(on) {
3438
+ const M = MO.current;
3439
+ if (!stdout?.isTTY || Boolean(on) === M.tty) return;
3440
+ try { stdout.write(on ? MOUSE_ON : MOUSE_OFF); } catch { /* closed */ }
3441
+ M.tty = Boolean(on);
3442
+ if (on && !M.exitFn) { M.exitFn = () => { try { writeSync(process.stdout.fd ?? 1, MOUSE_OFF); } catch { /* closed */ } }; process.once('exit', M.exitFn); }
3443
+ if (!on && M.exitFn) { process.removeListener('exit', M.exitFn); M.exitFn = null; }
3444
+ }
3445
+ /** What a run hand-over writes when it takes the terminal back (tracking was turned off for the child). */
3446
+ function mouseResume() { return MO.current.tty ? MOUSE_ON : ''; }
3447
+ /** The terminal's cursor row (DSR), or null when it does not answer in time. */
3448
+ function cursorRow() {
3449
+ return new Promise((res) => {
3450
+ const t = setTimeout(() => { MO.current.onCursor = null; res(null); }, DSR_TIMEOUT_MS);
3451
+ MO.current.onCursor = (row) => { clearTimeout(t); MO.current.onCursor = null; res(row); };
3452
+ try { stdout.write(DSR); } catch { clearTimeout(t); MO.current.onCursor = null; res(null); }
3453
+ });
3454
+ }
3455
+ /** -> what Ink drew under screen cell (x, y): { text, col, inFrame, frameRow, frameLines, rowsBelow } | null. */
3456
+ async function locate(x, y, row = null) {
3457
+ if (mouseScreen) return mouseScreen.at(x, y);
3458
+ const scr = stdout?.screen;
3459
+ if (!scr) return null;
3460
+ const cr = row ?? await cursorRow();
3461
+ if (cr == null) return null;
3462
+ const hit = screenHit(scr.snapshot(), cr, x, y);
3463
+ return hit ? { ...hit, cursorRow: cr } : null;
3464
+ }
3465
+ function onMouse(e) {
3466
+ if (!MO.current.on || e.button !== 'left') return;
3467
+ if (e.action !== 'press' && e.action !== 'drag' && e.action !== 'release') return;
3468
+ MO.current.chain = MO.current.chain.then(() => mouseEvent(e)).catch(() => {}); // in order: a drag never overtakes its press
3469
+ }
3470
+ async function mouseEvent(e) {
3471
+ const M = MO.current;
3472
+ if (R.current.closed || R.current.sleep) return;
3473
+ if (e.action === 'release') { M.drag = null; copySelection(); return; }
3474
+ if (e.action === 'drag') {
3475
+ if (!M.drag) return;
3476
+ const hit = await locate(e.x, e.y, M.drag.cursorRow);
3477
+ const idx = hit?.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
3478
+ if (idx != null) edit({ type: 'select', anchor: M.drag.anchor, cursor: idx });
3479
+ return;
3480
+ }
3481
+ // a modal card (PIN, sign-in, secure connect) takes keys only: a click never answers it
3482
+ if (SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen()) return;
3483
+ const hit = await locate(e.x, e.y);
3484
+ if (!hit) return;
3485
+ const now = Date.now();
3486
+ const idx = hit.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
3487
+ if (idx != null) {
3488
+ const dbl = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
3489
+ M.last = dbl ? null : { at: now, x: e.x, y: e.y };
3490
+ if (dbl) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
3491
+ edit({ type: 'place', index: idx }); K.current.composerClicked = true;
3492
+ M.drag = { anchor: B.current.cursor, cursorRow: hit.cursorRow ?? null };
3493
+ return;
3494
+ }
3495
+ M.last = null;
3496
+ const what = classifyClick(hit);
3497
+ if (!what) return;
3498
+ if (what.kind === 'sender') {
3499
+ if (!what.slot) return say(`${what.who} has no reply address (it did not send from a slot), so there is nothing to /say to.`, 'warn');
3500
+ const draft = B.current.text;
3501
+ const body = draft.startsWith('/say ') ? draft.replace(/^\/say\s+\S*\s?/, '') : draft;
3502
+ edit({ type: 'set', text: `/say ${what.slot} ${body}`, keepPastes: true });
3503
+ return;
3504
+ }
3505
+ if (what.kind === 'key') {
3506
+ if (B.current.text) return say('Your draft is not empty, so a click does not answer. Clear it, or press the key.', 'warn');
3507
+ if (what.source === 'approval') { handleEvents([{ type: 'text', text: what.key }], now, false); return; }
3508
+ if (!askOpen()) return;
3509
+ if (what.key === 'run' || (what.key === 'y' && runAskOpen())) return say('A click never runs a command. Press y or type run.', 'warn');
3510
+ handleEvents([{ type: 'text', text: what.key }, { type: 'key', name: 'enter' }], now, false);
3511
+ }
3512
+ return undefined;
3513
+ }
3514
+
3515
+ /**
3516
+ * TUI-INPUT: a single y, n or c answers the open y/N/c ask (the "Reattach to slot X?" card and friends) exactly as
3517
+ * typing it and Enter does, and never reaches the composer. Only while the composer is empty and was not clicked;
3518
+ * a draft in progress keeps its keys (same rule as approval cards). Returns true when the key was consumed.
3519
+ */
3520
+ function askKey(ch, now) {
3521
+ if (!ch || ch.length !== 1 || !/^[ync]$/i.test(ch)) return false;
3522
+ const k = K.current;
3523
+ if (B.current.text || k.composerClicked || R.current.sleep) return false;
3524
+ if (k.walkEndAt && now - k.walkEndAt < ASK_ECHO_MS && !askOpen() && !R.current.pendingWalk) { // a walk card just closed: a late y/n/c is its answer arriving after the walk, never a chat message
3525
+ B.current = { ...initialBuffer(), text: ch, cursor: 1 };
3526
+ k.askEcho = { ch, at: now }; clearTimeout(k.askEchoTimer);
3527
+ k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50); k.askEchoTimer.unref?.();
3528
+ bump(n => n + 1); return true;
3529
+ }
3530
+ if (!askOpen() || R.current.pendingWalk || R.current.denyReason || HW.current.active?.()) return false;
3531
+ if (/c/i.test(ch) && (S.current.turn || noAiNow())) { say('Quox is busy or off; c chats about this question once it can. y or n answers it now.', 'dim'); return true; }
3532
+ B.current = { ...initialBuffer(), text: ch, cursor: 1 };
3533
+ onEnter(now);
3534
+ // The answer is already given. The key stays on screen for ASK_ECHO_MS as its echo; an Enter in that window is the
3535
+ // old "type y, press Enter" habit and is swallowed, and ANY other key wipes the echo first (so "n" + "/help" is never "n/help").
3536
+ B.current = { ...initialBuffer(), text: ch, cursor: 1 };
3537
+ k.askEcho = { ch, at: now };
3538
+ clearTimeout(k.askEchoTimer);
3539
+ k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50);
3540
+ k.askEchoTimer.unref?.();
3541
+ bump(n => n + 1);
3542
+ return true;
3543
+ }
3544
+ function endAskEcho() {
3545
+ const k = K.current; const ae = k.askEcho;
3546
+ k.askEcho = null; clearTimeout(k.askEchoTimer);
3547
+ if (ae && B.current.text === ae.ch) B.current = initialBuffer();
3548
+ }
3549
+
3550
+ function approvalKey(name, text, now) {
3551
+ const card = headCard(S.current);
3552
+ if (!card) return false;
3553
+ const ch = text || '';
3554
+ const k = K.current;
3555
+ const g = k.guard;
3556
+ if (R.current.denyReason && Date.now() - R.current.denyReason.at < 120000) return false; // typing the deny reason: y/n are text
3557
+ if (B.current.text && !k.cardFocus) { k.confirm = false; return false; } // a draft in progress: the key is text (Tab focuses the card, draft kept)
3558
+ const armedAt = Math.max(card.displayedAt ?? card.shownAt ?? 0, g?.since ?? 0);
3559
+ const ok = () => approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: k.lastKeyAt, heldKey: g?.heldKey, ch });
3560
+ if (k.confirm) { // second press of a two-press grant
3561
+ k.confirm = false;
3562
+ if (ch.toLowerCase() === 'y' && ok()) { answer(card, 'approve'); return true; }
3563
+ bump(n => n + 1);
3564
+ return true; // any other key cancels
3565
+ }
3566
+ const key = ch.toLowerCase();
3567
+ if (key === 'c' && !S.current.turn && !noAiNow()) { // y/N/c: chat about the approval first; the card stays
3568
+ if (!ok()) return true;
3569
+ const what = card.what || [card.tool, card.target].filter(Boolean).join(' ') || 'this approval';
3570
+ const msg = `Tell me more about: approve ${what}, before I decide`;
3571
+ touchSeen(); dispatch({ type: 'user', text: msg }); runTurn(msg); bump(n => n + 1); return true;
3572
+ }
3573
+ if (key !== 'y' && key !== 'n') return false; // ordinary typing: the composer keeps it
3574
+ if (!ok()) return true; // too early / key repeat: swallowed, never answered
3575
+ if (key === 'n' && k.cardFocus) { k.cardFocus = false; answer(card, 'reject'); return true; } // focused over a draft: the composer holds the draft, so no reason prompt
3576
+ if (key === 'n') { // ask why in one line; Enter skips. The card stays until the answer is sent.
3577
+ R.current.denyReason = { card, at: Date.now() }; say('Reason? (Enter to skip)', 'warn'); bump(n => n + 1); return true;
3578
+ }
3579
+ if (card.privileged || card.undescribable) { k.confirm = true; bump(n => n + 1); return true; }
3580
+ answer(card, 'approve');
3581
+ return true;
3582
+ }
3583
+
3584
+ const rosterOn = () => { const ro = R.current.roster; return ro.on && ro.rows.length > 0 && rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40 }).mode === 'full'; };
3585
+ function focusRoster() { const ro = R.current.roster; ro.focus = true; ro.sel = Math.min(ro.sel, Math.min(ro.rows.length, MAX_ROSTER) - 1); ro.peek = false; ro.confirm = null; bump(n => n + 1); }
3586
+ function leaveRoster() { const ro = R.current.roster; ro.focus = false; ro.peek = false; ro.confirm = null; bump(n => n + 1); }
3587
+ /** Keys while the roster has focus. Returns true when consumed. */
3588
+ function rosterKey(name, text) {
3589
+ const ro = R.current.roster;
3590
+ const visible = Math.min(ro.rows.length, MAX_ROSTER);
3591
+ if (!visible) { leaveRoster(); return false; }
3592
+ if (ro.confirm) { // any key answers; only y stops
3593
+ const row = ro.confirm; ro.confirm = null;
3594
+ if (text === 'y') stopRow(row); else { say('Stop cancelled.'); bump(n => n + 1); }
3595
+ return true;
3596
+ }
3597
+ if (name === 'up') { ro.sel = Math.max(0, ro.sel - 1); ro.peek = false; bump(n => n + 1); return true; }
3598
+ if (name === 'down') { ro.sel = Math.min(visible - 1, ro.sel + 1); ro.peek = false; bump(n => n + 1); return true; }
3599
+ if (name === 'enter') { ro.peek = !ro.peek; bump(n => n + 1); if (ro.peek && ro.rows[ro.sel]?.kind === 'workflow') command(`/workflow ${ro.rows[ro.sel].id}`); return true; }
3600
+ if (name === 'escape') { if (ro.peek) { ro.peek = false; bump(n => n + 1); } else leaveRoster(); return true; }
3601
+ if (text === 's') { ro.confirm = ro.rows[ro.sel]; ro.peek = false; bump(n => n + 1); return true; }
3602
+ if (text) { leaveRoster(); return false; } // ordinary typing goes to the draft
3603
+ return true; // other keys are swallowed while focused
3604
+ }
3605
+ async function stopRow(row) {
3606
+ const ro = R.current.roster;
3607
+ if (!api.stopRow) return say('Stop not available on this server.', 'warn');
3608
+ const r = await api.stopRow(row);
3609
+ if (r.unavailable) return say(`Stop not available for ${row.kind} "${clip(row.name, 40)}": no governed stop route exists.`, 'warn');
3610
+ say(r.message, r.ok ? undefined : 'warn');
3611
+ if (r.ok) { refreshRoster(); }
3612
+ return ro;
3613
+ }
3614
+
3615
+ /** COMPOSER-EDIT: cells the draft has per row (the composer box is `cols` wide, less the "› " prompt). */
3616
+ function composerWidth() { return Math.max(1, cols - 2); }
3617
+ /** COMPOSER-EDIT: copy the composer selection to the clipboard with OSC 52 (works over SSH; needs a terminal that allows it). */
3618
+ function copySelection() {
3619
+ const t = selectedText(B.current);
3620
+ if (!t || !stdout?.isTTY) return;
3621
+ try { stdout.write(osc52(t)); } catch { /* closed */ }
3622
+ }
3623
+ function onKey(e, now) {
3624
+ const s = S.current;
3625
+ const busy = Boolean(s.turn);
3626
+ const name = e.name;
3627
+ if (name === 'ctrl-c') {
3628
+ if (R.current.bang) { R.current.bang.run.kill(); return; } // QUOXON-FW8: stops the running ! command, never quits
3629
+ if (B.current.text) { edit({ type: 'clear' }); return; }
3630
+ if (busy) { interrupt(); return; }
3631
+ if (K.current.ctrlC) return exit();
3632
+ K.current.ctrlC = 1; say('Press Ctrl+C again to quit.');
3633
+ clearTimeout(R.current.ctrlCTimer);
3634
+ R.current.ctrlCTimer = setTimeout(() => { K.current.ctrlC = 0; }, 1500);
3635
+ return;
3636
+ }
3637
+ if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
3638
+ if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
3639
+ // quick reply (owner 2026-10-15): Tab in an EMPTY composer pre-fills "/say <last sender> " (before the palette, which owns Tab otherwise)
3640
+ if (name === 'tab' && !B.current.text && R.current.lastSender && !headCard(s)) { edit({ type: 'set', text: `/say ${R.current.lastSender.toLowerCase()} ` }); return; }
3641
+ if (!headCard(s) && PL.current.onKey(name)) { bump(n => n + 1); return; } // gate on the card actually SHOWN: an approval put away with Tab (or still queued) must not swallow Tab/Up/Down while the palette is on screen
3642
+ if (name === 'escape') {
3643
+ if (selection(B.current)) { edit({ type: 'place', index: B.current.cursor }); return; } // COMPOSER-EDIT: Esc drops a selection, nothing else
3644
+ if (flyActive()) { R.current.fly = null; bump(n => n + 1); return; } // Esc ends the dragon's pass
3645
+ if (busy) { interrupt(); return; }
3646
+ if (R.current.askArmed) { R.current.askArmed = false; R.current.dragonTalk = null; say('Dragon ask cancelled.', 'dim'); return; }
3647
+ if (HW.current.cancel()) return; // CUSTOM-HATS: Esc ends the hat walk, nothing sent
3648
+ if (RW.current.walk.cancel()) return; // ROWS: Esc ends the row walk, nothing saved
3649
+ if (R.current.pendingWalk) { walkEnd('Esc'); K.current.lastEscAt = now; return; } // WALK-FIXES: Esc leaves the walkthrough with a summary; the draft stays
3650
+ if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
3651
+ if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
3652
+ else { remember(B.current.text); edit({ type: 'clear' }); say('Draft cleared (Up recalls it).'); }
3653
+ }
3654
+ K.current.lastEscAt = now; return;
3655
+ }
3656
+ switch (name) {
3657
+ case 'enter': return onEnter(now);
3658
+ case 'shift-enter': case 'newline': return edit({ type: 'newline' });
3659
+ case 'tab':
3660
+ if (busy && B.current.text.trim()) { const t = sanitizeForSend(B.current); if (!t.ok || looksLikeKey(t.text)) return onEnter(now); dispatch({ type: 'queue.add', text: t.text.trim() }); B.current = initialBuffer(); bump(n => n + 1); }
3661
+ return;
3662
+ case 'backspace': return edit({ type: 'backspace' });
3663
+ case 'delete': return edit({ type: 'delete' });
3664
+ case 'delete-word': return edit({ type: 'deleteWordBack' });
3665
+ case 'kill-line': return edit({ type: 'killLineBack' });
3666
+ case 'left': case 'right': case 'home': case 'end': return edit({ type: name });
3667
+ case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'wordLeft' : 'wordRight' });
3668
+ case 'kill-line-forward': return edit({ type: 'killLineForward' }); // Ctrl+K
3669
+ case 'delete-word-forward': return edit({ type: 'deleteWordForward' }); // Alt+D, Ctrl+Delete
3670
+ case 'select-left': case 'select-right': case 'select-up': case 'select-down': case 'select-home': case 'select-end':
3671
+ case 'select-word-left': case 'select-word-right': { // COMPOSER-EDIT: Shift selection, copied to the clipboard (OSC 52)
3672
+ const move = { 'select-word-left': 'wordLeft', 'select-word-right': 'wordRight' }[name] || name.slice(7);
3673
+ edit({ type: 'extend', move, width: composerWidth() });
3674
+ copySelection();
3675
+ return;
3676
+ }
3677
+ case 'undo': return edit({ type: 'undo' });
3678
+ case 'redo': return edit({ type: 'redo' });
3679
+ case 'up': {
3680
+ if (B.current.text === '' && s.pending.length) { const last = s.pending[s.pending.length - 1]; dispatch({ type: 'queue.pop' }); edit({ type: 'set', text: last.text }); return; }
3681
+ if (atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
3682
+ const hist = R.current.history;
3683
+ if (B.current.text === '' || R.current.histIdx >= 0) {
3684
+ const i = R.current.histIdx < 0 ? hist.length - 1 : Math.max(0, R.current.histIdx - 1);
3685
+ if (hist[i] !== undefined) { R.current.histIdx = i; edit({ type: 'set', text: hist[i] }); }
3686
+ }
3687
+ return;
3688
+ }
3689
+ return edit({ type: 'up', width: composerWidth() });
3690
+ }
3691
+ case 'down': {
3692
+ if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
3693
+ if (atVisualEdge(B.current, 'down', composerWidth()) && R.current.histIdx >= 0) {
3694
+ const i = R.current.histIdx + 1;
3695
+ if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; edit({ type: 'set', text: R.current.history[i] }); }
3696
+ return;
3697
+ }
3698
+ return edit({ type: 'down', width: composerWidth() });
3699
+ }
3700
+ case 'ctrl-g': {
3701
+ const full = sanitizeForSend({ ...B.current }).text ?? B.current.text;
3702
+ mouseTerm(false); // the editor gets a plain terminal (QUOXON-MOUSE), and tracking comes back after
3703
+ if (isRawModeSupported) setRawMode(false);
3704
+ const out = editor(full);
3705
+ if (isRawModeSupported) setRawMode(true);
3706
+ mouseTerm(MO.current.on);
3707
+ if (out !== null && out !== undefined) edit({ type: 'set', text: out });
3708
+ return;
3709
+ }
3710
+ case 'ctrl-s': {
3711
+ if (B.current.text) { R.current.stash.push(B.current.text); edit({ type: 'clear' }); say('Draft stashed. Ctrl+S on an empty composer restores it.'); }
3712
+ else if (R.current.stash.length) edit({ type: 'set', text: R.current.stash.pop() });
3713
+ return;
3714
+ }
3715
+ default:
3716
+ }
3717
+ }
3718
+
3719
+ const onData = React.useRef(null);
3720
+ const handleEvents = (events, now, burst) => {
3721
+ for (const e of events) {
3722
+ if (e.type === 'focus') { SND.current.focus(e.focused); continue; } // CSI I / CSI O, never a key
3723
+ if (e.type === 'cursor') { MO.current.onCursor?.(e.row); continue; } // the answer to a click's DSR, never a key
3724
+ if (e.type === 'mouse') { onMouse(e); continue; } // QUOXON-MOUSE: never text, never a key
3725
+ if (K.current.askEcho) { // TUI-INPUT: see askKey
3726
+ const stale = now - K.current.askEcho.at > ASK_ECHO_MS;
3727
+ const enter = e.type === 'key' && e.name === 'enter';
3728
+ endAskEcho();
3729
+ if (enter && !stale) { bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // swallowed: no empty or stray message
3730
+ }
3731
+ if (AR.current.timer && e.type === 'key') { // the countdown owns two keys: Esc stays on this version, Enter restarts now; anything else is typing (a draft holds the restart)
3732
+ if (e.name === 'escape') { stopCountdown(); cdLog('cancelled'); AR.current.cancelledId = AR.current.newId; say("Restart cancelled. /restart when you're ready.", 'accent'); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3733
+ if (e.name === 'enter' && autoIdle()) { stopCountdown(); cdLog('restart-now'); doRestart(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3734
+ }
3735
+ if (R.current.sleep) { // the sleep screen eats the waking key (nothing is lost), except "/": slash commands always work asleep
3736
+ if (e.type === 'key' && e.name === 'ctrl-c') { exit(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3737
+ if (R.current.sleep.wakeAt != null) { /* already waking: later keys are typed as normal, so "/back" typed in one go is not cut short */ }
3738
+ else {
3739
+ wakeDragon();
3740
+ if (e.type === 'text' && e.text === '/') edit({ type: 'insert', text: '/' });
3741
+ else if (e.type === 'text') R.current.wokeSwallowed = { at: now };
3742
+ K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue;
3743
+ }
3744
+ }
3745
+ if (SU.current.isOpen()) { SU.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the PIN card takes every key: nothing reaches the composer, history or undo
3746
+ if (LI.current.isOpen()) { LI.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the sign-in card takes every key
3747
+ if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
3748
+ if (PK.current.isOpen() && !S.current.approvals.length && PK.current.onEvent(e) !== false) { K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // /resume, /config (typing closes it and falls through)
3749
+ const card = headCard(S.current);
3750
+ const g = K.current.guard;
3751
+ // any different key ends the "this key was already down" suppression
3752
+ if (g?.heldKey && !(e.type === 'text' && e.text === g.heldKey)) g.heldKey = null;
3753
+ // Tab (composer empty) puts the card away into the status line until a NEW approval arrives
3754
+ // RESTORE-TUI: Tab with a draft focuses the card (the draft is kept, never queued or sent); Esc returns to the draft
3755
+ if (card && e.type === 'key' && e.name === 'tab' && B.current.text) { K.current.cardFocus = true; K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3756
+ if (card && e.type === 'key' && e.name === 'escape' && K.current.cardFocus) { K.current.cardFocus = false; K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3757
+ if (card && e.type === 'key' && e.name === 'tab' && !B.current.text) { for (const x of queueOf(S.current)) K.current.dismissed.add(x.callId); K.current.confirm = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3758
+ if (card && K.current.confirm && e.type === 'key') { K.current.confirm = false; bump(n => n + 1); } // any other key cancels a pending privileged confirm
3759
+ if (e.type === 'paste') {
3760
+ K.current.lastPasteAt = now;
3761
+ edit({ type: 'paste', text: e.text }); // a card never discards a paste
3762
+ } else if (e.type === 'text') {
3763
+ if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
3764
+ else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
3765
+ else if (!card && askKey(e.text, now)) { /* consumed by the open ask */ }
3766
+ else edit({ type: 'insert', text: e.text });
3767
+ } else if (e.type === 'key') {
3768
+ if (e.name === 'enter' && burst) edit({ type: 'newline' }); // startup keys only: never submitted
3769
+ else onKey(e, now);
3770
+ }
3771
+ K.current.lastKeyAt = now;
3772
+ K.current.lastKeyCh = e.type === 'text' ? e.text : null;
3773
+ }
3774
+ };
3775
+ onData.current = (chunk) => {
3776
+ const now = Date.now();
3777
+ finishIntroRef.current();
3778
+ // Enter is a newline only inside a real paste (bracketed, or several lines in one chunk: keys.js).
3779
+ // Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
3780
+ const burst = false;
3781
+ K.current.lastDataAt = now;
3782
+ const parsed = parseChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), K.current.keys, B.current.text);
3783
+ K.current.keys = parsed.state;
3784
+ clearTimeout(K.current.danglingTimer);
3785
+ if (hasDangling(parsed.state)) {
3786
+ K.current.danglingTimer = setTimeout(() => {
3787
+ const f = flushDangling(K.current.keys);
3788
+ K.current.keys = f.state;
3789
+ handleEvents(f.events, Date.now(), false); // as paste text: inserted, never submitted
3790
+ bump(n => n + 1);
3791
+ }, PASTE_DANGLING_MS);
3792
+ }
3793
+ handleEvents(parsed.events, now, burst);
3794
+ };
3795
+
3796
+ // QUOXON J2: `quox --conversation <id>` opens that conversation interactively.
3797
+ React.useEffect(() => {
3798
+ if (!resumeConversation || !api.conversationTurns) return undefined;
3799
+ let live = true;
3800
+ reconnecting(() => api.conversationTurns(resumeConversation)).then((turns) => {
3801
+ if (!live) return;
3802
+ if (!turns.length) {
3803
+ // C5: the conversation's first turn may still be running (nothing is stored until it ends).
3804
+ return api.runningTurn?.(resumeConversation).then((r) => {
3805
+ if (!live) return;
3806
+ if (r?.running) { dispatch({ type: 'ctx', ctx: { conversationId: resumeConversation } }); followTurn(resumeConversation, r.turnId); return; }
3807
+ say(`No saved turns for conversation ${resumeConversation} (not yours, expired after 7 days, or a typo). Starting a new conversation.`, 'warn');
3808
+ }) ?? say(`No saved turns for conversation ${resumeConversation} (not yours, expired after 7 days, or a typo). Starting a new conversation.`, 'warn');
3809
+ }
3810
+ dispatch({ type: 'resume', conversationId: resumeConversation, turns });
3811
+ return reattachIfRunning(resumeConversation);
3812
+ }, (e) => { if (!live || R.current.closed) return; if (e?.reconnectGaveUp) { resumeGaveUp(resumeConversation); return; } dispatch({ type: 'error', text: `could not load conversation ${resumeConversation}: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); });
3813
+ return () => { live = false; };
3814
+ }, []);
3815
+ React.useEffect(() => {
3816
+ if (!startup) return undefined;
3817
+ let live = true;
3818
+ let timer = null;
3819
+ const slow = (note) => /is not answering/.test(note);
3820
+ // A slow collector keeps being probed (capped backoff, one timer, none once it answers) so the notice can settle itself.
3821
+ const recheck = (i) => {
3822
+ const delays = healthRecheckDelays; if (!healthRecheck || !delays?.length) return;
3823
+ timer = setTimeout(async () => {
3824
+ timer = null;
3825
+ let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
3826
+ if (!live) return;
3827
+ if (hl?.ok) { say('✔ QuoxCORE is answering again', 'accent'); return; }
3828
+ recheck(Math.min(i + 1, delays.length - 1));
3829
+ }, delays[Math.min(i, delays.length - 1)]);
3830
+ };
3831
+ Promise.resolve().then(startup).then((note) => {
3832
+ if (!live || !note) return;
3833
+ if (slow(note)) { if (startup.said) return; startup.said = true; } // once per session, even if the probe is mounted twice
3834
+ say(note, 'warn');
3835
+ if (slow(note)) recheck(0);
3836
+ }, () => {});
3837
+ return () => { live = false; clearTimeout(timer); };
3838
+ }, []);
3839
+ React.useEffect(() => { // E5: "While you were away" under the intro, after more than 2 h away
3840
+ if (!away) return undefined;
3841
+ let live = true;
3842
+ try { AWAY.current.last = away.read(profile); } catch { AWAY.current.last = null; }
3843
+ const last = AWAY.current.last;
3844
+ touchSeen(true);
3845
+ if (!BL.current && api.away && awayDue(last)) {
3846
+ api.away(new Date(last).toISOString()).then((d) => { if (live) dispatch({ type: 'object', obj: { type: 'away', data: { ...d, compact: true } } }); },
3847
+ (e) => { if (live && !BL.current) say(`While you were away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). /away to retry.`, 'warn'); });
3848
+ }
3849
+ return () => { live = false; touchSeen(true); };
3850
+ }, []);
3851
+ React.useEffect(() => { // --demo --autoplay: the whole journey with no input, on a fixed script (demo.autoplay.js)
3852
+ if (!demoAuto) return undefined;
3853
+ const P = { ...AUTOPLAY, ...demoAuto };
3854
+ const k = demoAuto.scale || 1; // tests shrink every wait by the same factor; the order never changes
3855
+ const wait = (ms) => new Promise((res) => setTimeout(res, ms / k));
3856
+ const until = async (pred, limit = 120000) => { for (let t = 0; t < limit && !R.current.closed; t += 25) { if (pred()) return true; await new Promise((res) => setTimeout(res, 25)); } return false; };
3857
+ const type = async (text, seed) => { for (const [i, d] of typingPlan(text, seed, { min: P.typeMinMs, max: P.typeMaxMs }).entries()) { if (R.current.closed) return; onData.current([...text][i]); await wait(d); } };
3858
+ (async () => {
3859
+ await wait(P.startMs);
3860
+ await type(AUTOPLAY_REQUEST, P.seed); await wait(P.afterTypeMs); onData.current('\r');
3861
+ if (!await until(() => S.current.approvals[0]?.displayedAt != null)) return;
3862
+ await wait(Math.max(P.approvalHoldMs, 450 * k)); // never shorter than the card's arming window + key-repeat guard
3863
+ say('autoplay presses y: approve', 'ok');
3864
+ onData.current('y'); await wait(P.afterKeyMs);
3865
+ if (!await until(() => !S.current.turn && !S.current.approvals.length && !S.current.running.length)) return;
3866
+ await wait(Math.max(P.afterTurnMs, 450 * k)); // never shorter than CARD_GONE_MS: an Enter right after the card closes is swallowed (matters only when tests shrink the waits)
3867
+ await type(AUTOPLAY_PROOF, P.seed + 1); await wait(P.afterTypeMs); onData.current('\r');
3868
+ await wait(P.finalHoldMs);
3869
+ if (P.exitAfterMs != null) { await wait(P.exitAfterMs); if (!R.current.closed) exit(); }
3870
+ })();
3871
+ return undefined;
3872
+ }, []);
3873
+ React.useEffect(() => { // --demo plays by itself
3874
+ if (!autoplay) return undefined;
3875
+ const t = setTimeout(() => { if (!S.current.turn && !R.current.closed) { dispatch({ type: 'user', text: autoplay }); runTurn(autoplay); } }, 600);
3876
+ return () => clearTimeout(t);
3877
+ }, []);
3878
+ React.useEffect(() => {
3879
+ if (!stdin) return undefined;
3880
+ if (isRawModeSupported) setRawMode(true);
3881
+ if (stdout?.isTTY) stdout.write(PASTE_ON);
3882
+ if (MO.current.on) mouseTerm(true); // /mouse on, saved for this profile
3883
+ const fn = (d) => onData.current(d);
3884
+ if (earlyInput) { // keys typed during startup: replayed exactly as if typed now (R3 D4: an Enter submits, it is not dropped)
3885
+ earlyInput.detach();
3886
+ for (const chunk of earlyInput.chunks.splice(0)) onData.current(chunk);
3887
+ }
3888
+ stdin.on('data', fn);
3889
+ return () => {
3890
+ R.current.closed = true;
3891
+ R.current.abort?.abort(); // closing quoxon cancels the active stream (and its HTTP body)
3892
+ for (const k of ['fallbackTimer', 'ctrlCTimer']) clearTimeout(R.current[k]);
3893
+ clearTimeout(K.current.danglingTimer);
3894
+ for (const t of R.current.pollTimers) clearTimeout(t);
3895
+ R.current.pollTimers.clear();
3896
+ R.current.pollWake?.();
3897
+ stdin.off('data', fn);
3898
+ if (stdout?.isTTY) stdout.write(PASTE_OFF);
3899
+ mouseTerm(false); // mouse tracking never outlives the client (or leaks into a /restart child)
3900
+ SND.current?.dispose?.(); // focus reporting off: it must not outlive the client (or leak into a /restart child)
3901
+ if (isRawModeSupported) setRawMode(false);
3902
+ };
3903
+ }, [stdin]);
3904
+
3905
+ // ---- view ----------------------------------------------------------------------------------
3906
+ // Height of everything below the live blocks, measured after each layout; sizes the live region next frame.
3907
+ React.useLayoutEffect(() => { if (chromeRef.current) chromeH.current = measureElement(chromeRef.current).height; });
3908
+ // Frame cost (tick -> React commit), for measurement only (motionStats prop; tests and the bench).
3909
+ React.useLayoutEffect(() => {
3910
+ if (!motionStats || R.current.tickPerf == null) return;
3911
+ (motionStats.costs ||= []).push(performance.now() - R.current.tickPerf);
3912
+ R.current.tickPerf = null;
3913
+ });
3914
+ const s = S.current;
3915
+ const busy = Boolean(s.turn);
3916
+ // Reveals: register what just landed, then give <Static> every leading item whose reveal has ended.
3917
+ const nowR = Date.now();
3918
+ let pendingHist = [];
3919
+ const sleeping = Boolean(R.current.sleep); // asleep: nothing is committed to scrollback, so waking loses nothing
3920
+ if (introDone.current && !sleeping) {
3921
+ const lim = commitLimit(s); // ASK-SETTLE: an armed run card stays live until it settles
3922
+ if (!motionOn) committedRef.current = Math.max(committedRef.current, lim);
3923
+ else {
3924
+ for (let i = committedRef.current; i < s.history.length; i++) registerFx(s.history[i], nowR);
3925
+ for (const b of s.blocks) registerFx(b, nowR);
3926
+ // The newest protocol stage stays live (its icon pulses) while it is the last thing that happened in the turn.
3927
+ const activeStage = (i) => isActiveStage(s, i);
3928
+ while (committedRef.current < lim && !fxActive(s.history[committedRef.current], nowR) && !activeStage(committedRef.current)) fxRef.current.delete(s.history[committedRef.current++].id);
3929
+ for (const r of R.current.roster.rows) if (!rosterSeen.current.has(r.key)) rosterSeen.current.set(r.key, nowR);
3930
+ }
3931
+ pendingHist = s.history.slice(committedRef.current);
3932
+ }
3933
+ lastAppRender.current = nowR;
3934
+ // Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
3935
+ const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
3936
+ const queue = queueOf(s);
3937
+ const card = queue[0] || null;
3938
+ const armed = card ? Date.now() - Math.max(card.displayedAt ?? card.shownAt ?? 0, K.current.guard?.since ?? 0) >= 300 : false;
3939
+ R.current.cardMidAnim = Boolean(card && motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000); // this render draws the card mid-animation (appDue keeps it due until a settled draw)
3940
+ if (motionStats && motionOn && card && card.displayedAt != null && !(motionStats.cards ||= []).some(x => x.card.callId === card.callId)) motionStats.cards.push({ card, org: orgShown() || null }); // the card took the draw-in path (a test renders it at fixed motionE)
3941
+ R.current.armedShown = armed;
3942
+ const ctxParts = [demoLabel && profile === 'demo' ? null : profile, orgShown(), (R.current.noAi && !s.ctx?.model ? NO_AI_STATUS : modelLabel(s.ctx, MC.current)), demoLabel ? (demoAuto ? 'demo data, nothing real runs' : null) : MD.current, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules), AR.current.on ? '↻ auto' : null]; // [5]: auto-restart, context row of the status
3943
+ const caps = R.current.caps;
3944
+ const hint = (busy ? `${caps.steer ? 'Enter steer' : 'Enter queue'} · Tab queue · ${caps.interrupt ? 'Esc stop' : 'Esc disconnects'}` : ''); // owner 22:12 "Lose this: Enter send · /help": only the hint a running turn needs is shown
3945
+ const placeholder = busy ? (caps.steer ? 'Type to steer (Enter) or queue (Tab)' : 'Type to queue (Enter or Tab)') : R.current.askArmed ? ASK_PROMPT : 'Ask Quox anything…';
3946
+
3947
+ // The live region (unsettled blocks + chrome) must stay SHORTER than the screen: log-update can only
3948
+ // erase rows that are still on screen, and Ink's own tall-frame path loses a line of history (term.js).
3949
+ // A tall live tail shows its latest lines; the whole block prints to <Static> in full once it settles.
3950
+ function liveRegion(kids) {
3951
+ const rows = termRows(stdout);
3952
+ if (!rows || !kids.length) return kids;
3953
+ const max = Math.max(2, rows - (chromeH.current ?? 8) - 2);
3954
+ if (liveRows([...pendingHist, ...s.blocks], cols) <= max) return kids;
3955
+ return [
3956
+ h(Box, { key: 'live-cap', paddingLeft: 2 }, h(Text, { color: c('dim'), wrap: 'truncate-end' }, clip('⋮ latest lines; the full reply prints when this part ends', cols - 2))),
3957
+ h(Box, { key: 'live-clip', height: max - 1, overflow: 'hidden', flexDirection: 'column', justifyContent: 'flex-end' },
3958
+ h(Box, { flexDirection: 'column', flexShrink: 0 }, ...kids))
3959
+ ];
3960
+ }
3961
+
3962
+ // The ⏸ approval row under the status line already says how many wait: the "▶ N approvals waiting" line is then a duplicate.
3963
+ function approvalRowShown() {
3964
+ const ro = R.current.roster;
3965
+ if (!ro.on || !ro.rows.some(r => r.kind === 'approval')) return false;
3966
+ const v = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now() });
3967
+ return v.mode === 'full' && v.lines.some(l => l.kind === 'approval');
3968
+ }
3969
+
3970
+ function rosterNode() {
3971
+ const ro = R.current.roster;
3972
+ const view = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now(), motion: motionOn && !mono() });
3973
+ const row = ro.rows[ro.sel];
3974
+ return h(Roster, {
3975
+ view, cols,
3976
+ peek: ro.focus && ro.peek ? peekLines(row, Date.now()) : [],
3977
+ fx: motionOn ? (key) => rosterFx(key, Date.now()) : null,
3978
+ confirm: ro.focus && ro.confirm ? `Stop ${ro.confirm.kind} "${clip(ro.confirm.name, 30)}"? y to stop, any other key cancels` : null,
3979
+ hint: ro.focus && !ro.confirm ? '↑↓ select · Enter peek · s stop · Esc back' : null
3980
+ });
3981
+ }
3982
+
3983
+ if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
3984
+ const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
3985
+ return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
3986
+ h(Static, { key: `static-${epoch}`, items: s.history.slice(0, committedRef.current) }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
3987
+ h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
3988
+ }
3989
+ const ruleAbove = endsInRule({ card, asks: s.asks || [], background: s.background, pending: s.pending, steering: s.steering, countdown: Boolean(AR.current.timer && AR.current.endsAt) }) && !(SU.current.isOpen() || LI.current.isOpen() || CN.current.isOpen());
3990
+ return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
3991
+ // Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
3992
+ // already-printed items would drop it (and reprint the rest).
3993
+ h(Static, { key: `static-${epoch}`, items: introDone.current ? s.history.slice(0, committedRef.current) : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols, gap: historyGap(s.history[i - 1], it, termRows(stdout)) }))),
3994
+ introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols, dragon: introDragon, progress: Math.min(1, (Date.now() - introStart.current) / introMs) })),
3995
+ liveRegion([
3996
+ ...pendingHist.map((it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(FxItem, { item: it, cols, fxFor, gap: historyGap(i ? pendingHist[i - 1] : s.history[committedRef.current - 1], it, termRows(stdout)), active: busy && it.kind === 'trace' && i === pendingHist.length - 1 && !s.blocks.length }))),
3997
+ ...s.blocks.map((b, i) => { const gap = historyGap(i ? s.blocks[i - 1] : (pendingHist[pendingHist.length - 1] ?? s.history[committedRef.current - 1]), b, termRows(stdout)); return h(Box, { key: b.id, flexDirection: 'column' }, motionOn && revealTimingFor(b) ? h(FxItem, { item: b, cols, live: true, fxFor, ignition: feats().ignition, gap }) : h(HistoryItem, { item: b, cols, live: true, gap })); })]),
3998
+ h(DragonLive, { key: 'dragon', cols }),
3999
+ flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still }) : null,
4000
+ dragonTalkVisible(nowR) ? h(DragonTalk, { key: 'dragon-talk', cols, label: R.current.dragonTalk.label, talking: !R.current.dragonTalk.armed && !R.current.dragonTalk.endedAt, since: R.current.dragonTalk.since, still: !motionOn || mono() }) : null,
4001
+ h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
4002
+ busy ? h(Working, { key: 'working', motion: motionOn && !R.current.exiting, handoff: feats().handoff, tokens: turnTokens(s.turn), act: s.turn.act, activity: describeActivity(s), since: s.turn.startedAt, interrupting: s.turn.status === 'interrupting', escLabel: caps.interrupt ? null : 'Esc disconnects' }) : null,
4003
+ card ? h(ApprovalCard, { card, org: orgShown() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, draftHint: K.current.cardFocus ? 'focus' : (B.current.text ? 'draft' : null), confirm: K.current.confirm, position: { i: 1, n: queue.length } }) : null,
4004
+ ...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
4005
+ // TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
4006
+ s.background > 0 ? h(Box, { paddingLeft: 2 }, h(Text, { color: c('dim') }, `Waiting for ${s.background} background agent${s.background === 1 ? '' : 's'} to finish`)) : null,
4007
+ h(Pending, { pending: s.pending, steering: s.steering, cols }),
4008
+ AR.current.timer && AR.current.endsAt ? h(Box, { key: 'restart-countdown', paddingLeft: 2 }, h(Text, { color: c('accent'), bold: true }, countdownLine(Math.max(1, Math.ceil((AR.current.endsAt - Date.now()) / 1000)), cols))) : null,
4009
+ ruleAbove ? null : h(Box, { marginTop: 1 }),
4010
+ SU.current.isOpen() ? h(StepUpCard, { view: SU.current.view(), cols }) : LI.current.isOpen() ? h(LoginCard, { ...LI.current.view(), cols, profile }) : CN.current.isOpen() ? h(ConnectCard, { ...CN.current.view(), cols })
4011
+ : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
4012
+ : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove }),
4013
+ !CN.current.isOpen() && PK.current.isOpen() && !card ? h(ListView, { ...PK.current.view(), cols }) : !CN.current.isOpen() && PL.current.isOpen() && !card ? h(ListView, { ...PL.current.view(), cols }) : null,
4014
+ h(RowsBar, { rows: RW.current.view(), cols }),
4015
+ h(PresentBar, { stepUp: SU.current, cols }),
4016
+ h(StatusLine, { cols, slot: where.current.slot, agent: where.current.agent, groups: [...SC.current.rooms].map(([name, e]) => ({ name, unread: e.unread })), parts: ctxParts, right: hint }),
4017
+ h(WaitingNotice, { items: s.waiting.filter(x => K.current.dismissed.has(x.callId)), cols }),
4018
+ h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
4019
+ rosterNode())));
4020
+ }
4021
+
4022
+ /** A live item that may be revealing: re-renders on every clock frame by itself (not the app). */
4023
+ function FxItem({ item, cols, live = false, fxFor, active = false, ignition = false, gap }) {
4024
+ const now = useFrame();
4025
+ const running = item.kind === 'tool' && item.status === 'running';
4026
+ const pulse = running || active ? now : null;
4027
+ const fx = fxFor(item, now);
4028
+ // expressive: a running tool row ignites as it lands (a short warm flare, motion/level.js ignitionGlow)
4029
+ const ignite = ignition && running && fx ? ignitionGlow(fx.e * 1000) : 0;
4030
+ return h(HistoryItem, { item, cols, live, fx, pulse, ignite, gap });
4031
+ }
4032
+ export { COLORS };