quoxcli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
package/lib/quoxon/app.js CHANGED
@@ -11,19 +11,22 @@ import { mkdtempSync, writeFileSync, writeSync, readFileSync, unlinkSync } from
11
11
  import { tmpdir, hostname as osHostname } from 'node:os';
12
12
  import { join } from 'node:path';
13
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';
14
+ import { MOUSE_ON, MOUSE_OFF, DSR, DOUBLE_MS, DSR_TIMEOUT_MS, mouseOnNote, lineAt, MOUSE_HINT, screenHit, classifyClick, composerIndexAt } from './mouse.js';
15
15
  import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
16
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';
17
+ import { isDestructive, destructiveObject } from './destructive.js';
18
+ import { batchFor, batchModel, batchLabel, collapseArrivals } from './approvalBatch.js';
19
+ import { walkCard, walkReply, walkNotYesNo, walkNoCatch, walkSummary, parseDecision, matchesAgent, filterPredicate, approvalRow, approvalDetail, isDecision, approvalAsWalkItem, parseAnswer, parsePairs, needsPin } from './approvalsView.js';
20
+ import { createAutoOpen, noteArrivals, openDecision, takeQueue, strayAnswer, parseApprovalsArg, autoOn, APPROVALS_OPTIONS } from './autoOpen.js';
18
21
  import { dividerLive, GREEN_MS, RED_MS } from './dividerAocl.js';
19
- import { reduce, initialState, approvalKeyAllowed, groupApprovals, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
22
+ import { reduce, initialState, approvalKeyAllowed, groupApprovals, orderApprovals, otherSessionsCount, isPrivileged, approvalSourceLabel, approvalWhat, describeInboxItem, textGate } from './events.js';
20
23
  import { turnTokens } from './indicator.js';
21
24
  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
+ Composer, RingComposer, Pending, ApprovalCard, BatchCard, LoopCardView, AskCard, StatusLine, RowsBar, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, WaitingNotice, historyGap, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
26
+ , endsInRule, approvalsPollStale } from './render.js';
27
+ import { c, mono, isLight } from './theme.js';
25
28
  import { strayCardAnswer, strayQuestion, alreadyQueued } from './stray.js';
26
- import { findEntry, helpModel, nearestCommands, joinWrappedSlash, commandNames, REGISTRY, visibleRegistry } from './commands.js';
29
+ import { findEntry, helpModel, nearestCommands, joinWrappedSlash, commandNames, REGISTRY, visibleRegistry, setExtEntries } from './commands.js';
27
30
  import { osc52, pickCopy } from './copy.js';
28
31
  import { detectSlotAgent } from './context.js';
29
32
  import { rulesContext, rulesLabel, rulesLines } from './rules.js';
@@ -33,19 +36,27 @@ import { startupWhatsNew, listWhatsNew, unseenEntries, seenRecord, tryText, LIST
33
36
  import { nextTip, normaliseMode, tipSettingsReply, tipsReply, DEFAULT_MODE, TIPS, tryCommand, tipText } from './tips.js';
34
37
  import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
35
38
  import { createScreenContext } from './screenContext.js';
39
+ import { parseSlotAttach } from './slotAttach.js';
36
40
  import { askText, askQuestion, askAnswer, askChatMessage, settledAskLine, settledAskTone, askOutcomeOf, isAskClarification, askClock } from './ask.js';
41
+ import { objectiveFromEvent, objectiveAskText, objectiveStartedNote, objectiveWatchLine, objectiveFailedNote, watchObjective } from './objectiveOffer.js';
37
42
  import { createHandoffStreak, handoffBiggerAskText, handoffFromEvent, handoffAskText, handoffAnsweredNote, handoffSupersededNote, handoffSkippedNote } from './handoffOffer.js';
38
43
  import * as selfUpdate from '../core/selfUpdate.js';
44
+ import { parseDone, todoDoneLine, parseTodo, todoAddedLine, todoBoardText, numberBoard, resolveRef, todoMovedLine } from './slash/todoCmd.js';
39
45
  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';
46
+ import { profileChooserRows, liveApi, switchAskLine, startupSwitchAskLine, slotCreateAskLine, loginRunRequest, okProfileName } from './profileSwitch.js';
47
+ import { attachLines, sessionLines } from './rosterSessions.js';
48
+ import { rosterView, peekLines, selectRows, fromApproval, backgroundAlerts, isExpired, applyProgress, SPIN_MS } from './roster.js';
42
49
  import { runObjectCommand } from './objects/index.js';
50
+ import { runEvidenceCommand } from './slash/evidenceCmd.js';
43
51
  import { peekLines as peekIdLines } from '../core/peek.js';
44
52
  import { turnsFromItems, asideNoteText } from './asideView.js';
45
53
  import { createMentionCache, loadMentions, receiptMentions } from './mentions.js';
46
54
  const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile', '/fleet', '/prove', '/replay', '/health', '/core']);
47
55
  import { intentHint } from './intent.js';
48
56
  import { observerLines } from './observers.js';
57
+ import { runWithSpinner, spinnerVerb } from './slashSpinner.js';
58
+ import { parseMail, mailTable, mailDetail, noMailboxLines, mailNewestFirst, MAIL_USAGE } from './mailView.js';
59
+ import { inboxBarText, arrivals, loadSeen, withSeen, arrivalLine, inboxTable, announceFresh, activeMutes, mutesLines, inboxDetail, parseInbox, INBOX_USAGE, newestFirst, unreadItems, readItems, isApprovalItem, isMuted } from './inboxView.js';
49
60
  import { devObserverOn, DEV_OBSERVER_REFUSAL } from './devObserver.js';
50
61
  import { mindLines, mindErrorLines, mindHelpLines, MIND_HELP_ARGS } from './mind.js';
51
62
  import { packageVersion } from './welcome.js';
@@ -53,9 +64,10 @@ import { feedbackPayload, feedbackPreview } from './feedback.js';
53
64
  import { vetSuggestion, namedCommand } from './suggestCommand.js';
54
65
  import { modeReply, normMode, PLAN_GO, MODES, MODE_ROWS } from './mode.js'; // PLAN MODE (owner 2026-10-16)
55
66
  import { echoLine } from './echo.js';
56
- import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
67
+ import { inviteText, cleanName, loadNames, saveName, pickNamed, matchAgent, uniqueSquash, resolveSayTarget, addressLabel, sayMissText, looksLikeAddress, sayWhoText, parseTarget, nearest, listFailure, slotsTable } from './session.js';
57
68
  import { pushHistory } from './promptHistory.js';
58
- import { termRows } from './term.js';
69
+ import { secretKinds, redactSecrets } from './secretShape.js';
70
+ import { termRows, liveCols } from './term.js';
59
71
  import { cardTallerThanScreen } from './objects/view.js';
60
72
  import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows, confirmLanAllow } from './connect.js';
61
73
  import { ConnectCard } from './connectCard.js';
@@ -64,15 +76,16 @@ import { createLoginController, LoginCard, SIGNED_OUT_LINE, signedOutCard, isSes
64
76
  import { createStepUp } from './slash/stepUp.js';
65
77
  import { StepUpCard, PresentBar } from './slash/stepUpCard.js';
66
78
  import { createStepUpCmds, declinedText } from './slash/stepUpCmds.js';
79
+ import { createConfirm } from './slash/confirm.js';
67
80
  import { isConsentError, consentHintLines, consentStatusLines } from './consent.js';
68
- import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
81
+ import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, adoptAfterConnect, selectDiscoveredModel, selectSubscriptionModel, limitedOffer, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
69
82
  import { humanSessionLines } from '../core/sessionsOverview.js';
70
83
  import { activeLines, activeRows, ACTIVE_KINDS } from '../core/activeSummary.js';
71
84
  import { capabilityLines, offerFor, findCapability } from './capabilities.js';
72
85
  import { anyProviderConnected, NO_AI_CARD, NO_AI_STATUS, asksAddHost, asksWatchHosts, addHostLines } from './noai.js';
73
- import { DragonLive, playDragon, dragonActive, resetDragon } from './qmotion.js';
86
+ import { DragonLive, playDragon, dragonActive, resetDragon, clipRoom, flyRoom } from './qmotion.js';
74
87
  import { AUTOPLAY, AUTOPLAY_REQUEST, AUTOPLAY_PROOF, typingPlan } from './demo.autoplay.js';
75
- import { motionLevel, levelFeatures, moodForEvent, ignitionGlow, normaliseLevel } from './motion/level.js';
88
+ import { motionLevel, levelFeatures, ignitionGlow, normaliseLevel } from './motion/level.js';
76
89
  import { CLIPS as DRAGON_CLIPS } from './qdragonScenes.js';
77
90
  import { DRAGON_USAGE, DRAGON_EXPLAINS, DRAGON_ANSWERS, NOTHING_TO_EXPLAIN, ASK_PROMPT, WAKE_MS, explainPrompt, askPrompt, lastOutput, parseDragonArg, sourceLines } from './dragonModes.js';
78
91
  import { SleepScreen, DragonTalk, FlyLive } from './dragonView.js';
@@ -85,13 +98,22 @@ import { runBroadcast, runAck, parsePokeArgs } from './slash/broadcastRun.js';
85
98
  import { parseBroadcastText } from './slash/broadcast.js';
86
99
  import { isOwnBroadcast } from './ownBroadcast.js';
87
100
  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';
101
+ import { enrichPlanItem } from './planSteps.js';
102
+ import { parseRoomSend, roomName, roomLine, senderName, parseAtSend, senderLabel, slotLine, slotOfHolder, withSenderSlot, decodeEntities, fileMessage, prepareFile, createSlotListener, createReplyWatches, isHolderLine, loadLastSlot, saveLastSlot, presenceLine, reattachSlowLine } from './slotChat.js';
89
103
  import { homedir as osHome } from 'node:os';
90
104
  import { createRowCmd } from './slash/rowCmd.js';
91
105
  import { createKillerCmds } from './slash/killer.js';
92
106
  import { createUndoWish } from './slash/undoWish.js';
93
107
  import { createHatWalk } from './slash/hatWalk.js';
94
108
  import { createRowEngine, runShell } from './rows.js';
109
+ import { qpeCommand, qpeRowsSetting, QPE_GRID } from './slash/qpeCmd.js';
110
+ import { deckOffer, runDeckOffer, declineText } from './deckLazy.js';
111
+ import { createExtensionHost, pickDraft as deckPickDraft, readExtList, extSetEnabled, extAdd, panelKeyName } from './extensions.js';
112
+ import { createLoopCards, rowsWithoutLoopCards } from './loopCards.js';
113
+ import { initLoop, applySnapshot as applyLoopSnapshot, cardLines } from './loopCard.js';
114
+ import { createCompanionController, companionCommand, DragonCompanionLive, takeGreeting, detachGreeting } from './dragonCompanion.js';
115
+ import { dragonForAgent } from '../dragon/select.js';
116
+ import { createDragonJobs } from '../dragon/jobs.js';
95
117
  import { createModelCmd } from './slash/modelCmd.js';
96
118
  import { createChallengeCmd } from './slash/challenge.js';
97
119
  import { createSpawnCmd } from './slash/spawn.js';
@@ -101,15 +123,17 @@ import { createLedger, costModel } from './slash/ledger.js';
101
123
  import { statusModel, contextModel, doctorModel, chatChecks, modelText } from './slash/panels.js';
102
124
  import { parseAgentsArgs, filterAgents, agentPickerRow, toolLines, whichReply, looseCommand } from './slash/agents.js';
103
125
  import { createPicker } from './slash/picker.js';
104
- import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
126
+ import { resumeRows, pickerRow, matchConversation, turnsToHistory, cleanTitle, titleFromTurns } from './slash/resume.js';
105
127
  import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
106
128
  import { countdownLine } from './countdownLine.js';
107
- import { startBuildWatch, DIFFERENT_NOTICE } from './slash/restart.js';
129
+ import { startBuildWatch, DIFFERENT_NOTICE, waitForHealthy, SERVER_WAIT_EVERY_MS, SERVER_WAIT_MAX_MS } from './slash/restart.js';
108
130
  import { dashboardLines } from './slash/dashboard.js';
109
131
  import { supportsHyperlinks } from './markdown.js';
110
132
  import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
111
133
  import { intentPanel } from './intent.js';
112
134
  import { awayDue, parseSince } from './away.js';
135
+ import { parseSlashArgs as parseCallMeSlashArgs, statusLines as callMeStatusLines } from '../core/callMe.js';
136
+ import { parseDndArgs, untilEpoch, sleepIntent, afkMeansSleep, newCollected, morningLines, waitingCount, DND_NOTE } from './dnd.js';
113
137
  import { sitrepData } from './objects/sitrep.js';
114
138
  import { mirrorErrorNote } from './objects/mirror.js';
115
139
  import { launchEgg } from './egg.js';
@@ -118,11 +142,17 @@ import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motio
118
142
  import { FrameContext, useFrame } from './motion/frame.js';
119
143
  import { ringMode, ringAnimating } from './motion/ring.js';
120
144
  import { TIMING, revealSpan } from './motion/reveal.js';
121
- import { matchOrg, orgLines, orgRows } from './orgSwitch.js';
145
+ import { matchOrg, orgLines, orgRows, orgCandidates } from './orgSwitch.js';
146
+ import { INSTALL_TOOLS, SUGGEST_LINE, planLine, offerQuestion, runInstall, missingTools } from './installCli.js';
122
147
  import { matchHat, hatLines, hatChoices, isPrivileged as hatIsPrivileged, mintConversationId } from './hatSwitch.js';
148
+ import { slotOutbox, isConnectivityFailure, queuedLine, sentLine, fullLine } from './slotOutbox.js';
123
149
  import { isServerDown, waitForServer, SERVER_RESTARTING, explainTermination, RETRY_HINT, isResumeRetryable, RESUME_RETRY_DELAYS, RESUME_RECONNECTING } from './resend.js';
150
+ import { gripeFiledLine, gripeFailedLine, gripeQueuedFiledLine } from '../core/quoxglyph.js';
151
+ import { crossOrgTuiLines } from '../core/crossOrgApprovals.js';
152
+ const gripeLead = '📣 GRIPE │';
124
153
 
125
154
  const h = React.createElement;
155
+ const BARE_COMMAND_WORDS = new Set(['clear', 'exit', 'quit', 'help', 'back', 'detach', 'restart', 'inbox', 'approvals']); // a message that is exactly one of these is offered as the command first
126
156
  const PASTE_GUARD_MS = 40;
127
157
  const ASK_ECHO_MS = 1500; // TUI-INPUT: how long an Enter after a single-key ask answer is treated as the confirm habit
128
158
  const MAX_ROSTER = 4;
@@ -156,8 +186,14 @@ export const friendlyError = (e, profile) =>
156
186
  const byId = (id) => CHOICES.find(x => x.id === id) || null;
157
187
  // the status line shows the new ask until the server answers
158
188
  const NO_RAN = { provider: null, model: null, endpoint: null };
189
+ // /gripe keeps the newlines of what was pasted: the whole body goes into the issue, not a space-joined line.
190
+ const GRIPE_LINE = /^\/gripe!?(\s|$)/i;
191
+ const isOneLineCommand = (t) => t.startsWith('/') && (!t.includes('\n') || GRIPE_LINE.test(t));
159
192
 
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 }) {
193
+ export const INBOX_BAR_MAX_MS = 300000; // the bar never waits longer than this between reads
194
+ export const INBOX_BAR_COST_X = 20; // wait 20x the last read's duration (a 3s read -> 60s; a fast read keeps the poll cadence)
195
+
196
+ 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, restartDeclined = null, slotRetryDelays = [2000, 5000, 10000, 20000, 30000], resumeRetryDelays = RESUME_RETRY_DELAYS, onRestart = null, onDemo = null, healthRecheck = null, healthRecheckDelays = [5000, 10000, 20000, 40000, 60000], startupGraceMs = 60000, startupRetryMs = 3000, neverAnswered = false, onConnect = null, restartCheck = null, updater = null, startSha = null, buildWatch = startBuildWatch, autoRestartMs = null, restartCooloffMs = null, presenceRetryDelays = [2000, 5000, 10000, 20000, 30000, 60000], presencePollMs = 30000, autoIdleMs = null, autoTickMs = 1000, autoResumeMs = 3000, serverProbe = null, serverPollMs = SERVER_WAIT_EVERY_MS, serverWaitMs = SERVER_WAIT_MAX_MS, env = process.env, motion, motionStats = null, away = null, tipStartup = false, whatsNewStartup = false, interactive, resendDelays = null, demoAuto = null, agentLogin: agentLoginProp = false, startupSwitchOffer = false, humanProfiles = [], switchProfile = null, loginCandidates = [], slotRecordPath = undefined, outboxPath = undefined, spawnStatePath = undefined, noReplyMs = 90000, replyWatchMs = 30 * 60 * 1000, historyStore = null, runSpawn = spawnSync, runHandOverDelayMs = 60, mouseScreen = null, runNow = Date.now, whatsNewEntries = undefined }) {
161
197
  // Z2H: signed-out is ONE state (card at the top + /login), not scattered errors. While it holds, `blocked` is the
162
198
  // one-line answer, so chat, /connect and the digest/approvals pollers stay quiet instead of printing raw 401s.
163
199
  // PROFILE-SWITCH (owner 2026-10-16): the window's api is swappable in place (profileSwitch.js liveApi); `who` is the
@@ -183,11 +219,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
183
219
  const exit = (afterMs = 0) => {
184
220
  const R0 = R.current;
185
221
  if (R0.exiting) return undefined;
222
+ let extClosing = null;
223
+ try { extClosing = DK.current?.unloadAll(); } catch { /* closing the deck never blocks exit */ } // async close() is awaited up to 500 ms
186
224
  R0.exiting = true; R0.sleep = null; R0.fly = null;
187
225
  R0.bang?.run.kill(); // QUOXON-FW8: a running ! command is killed with us, never left behind
188
226
  resetDragon();
189
227
  bump(n => n + 1);
190
- return setTimeout(() => inkExit(), 60 + afterMs);
228
+ return setTimeout(() => { Promise.resolve(extClosing).then(() => inkExit(), () => inkExit()); }, 60 + afterMs);
191
229
  };
192
230
  const [epoch, setEpoch] = React.useState(0);
193
231
 
@@ -232,6 +270,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
232
270
  // Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
233
271
  // they render in the live region. committedRef = how many history items <Static> has been given.
234
272
  const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
273
+ const staticRef = React.useRef({ list: [], seen: 0, deferred: [], epoch: 0 }); // what <Static> was given: committed items in order, minus loop cards still running (they join when they settle)
235
274
  const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
236
275
  const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
237
276
  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 });
@@ -240,7 +279,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
240
279
  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
280
  // QUOXON-HISTORY: Up recalls what was typed in earlier runs too (most recent first); main.js passes the file store.
242
281
  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 */ } } };
282
+ // remember: a /verify-email line with a 6-digit run (a PIN typed by mistake) is stored as the verb only
283
+ const remember = (t0) => { const t = /^\/verify-email(\s|$)/i.test(String(t0).trim()) && /(^|\D)\d{6}(\D|$)/.test(String(t0)) ? '/verify-email' : redactSecrets(t0); if (pushHistory(R.current.history, t)) { try { historyStore?.add?.(t); } catch { /* history is a convenience */ } } };
244
284
  // E5: when this profile was last seen (read once at launch, before it is refreshed), and a throttled touch.
245
285
  const AWAY = React.useRef({ last: null, touched: 0 });
246
286
  const touchSeen = (force = false) => {
@@ -252,10 +292,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
252
292
 
253
293
  const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
254
294
  /** 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)); };
295
+ const queueOf = (st) => { const all = orderApprovals([...st.approvals, ...st.waiting], LC.current.all().map((c) => c.id)); 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); if (AWAY.current.afk) return []; /* AFK-QUIET-CARDS: away = no card, no armed answer keys; the count rides the notify bar until /back */ return all.filter(x => !K.current.dismissed.has(x.callId)); };
256
296
  const headCard = (st) => queueOf(st)[0] || null;
257
297
  const SCX = React.useRef(null); SCX.current ??= createScreenContext(); // SCREEN-CONTEXT: the events ring + changed-only bookkeeping for the terminal block
258
298
  const dispatch = (a) => {
299
+ // A slash command whose request failed for want of a user session: one signed-out line plus the single
300
+ // sign-in offer (never the plain-CLI service-key paragraph). Covers every command's error path.
301
+ if (a.type === 'error' && NO_SESSION_RE.test(String(a.text || ''))) { noSessionAnswer(); return; }
259
302
  if (a.type === 'note') SCX.current.notice(a.text, a.tone);
260
303
  else if (a.type === 'error') SCX.current.error(a.text);
261
304
  else if (a.type === 'event' && a.ev) {
@@ -263,8 +306,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
263
306
  else if (a.ev.type === 'turn.finished' && a.ev.status && !['ok', 'success', 'completed'].includes(a.ev.status)) SCX.current.failedTurn(a.ev.status);
264
307
  }
265
308
  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
309
+ const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); if (!AWAY.current.afk) SND.current.observe(prevS, S.current, a); // DND: no sound while away
310
+ if (!AWAY.current.afk && motionOn && !mono() && feats().moods) { const m = DC.current.moodFor(prevS, a, S.current); if (m) playDragon(m); }
311
+ if (!AWAY.current.afk && (S.current.waiting?.length || 0) > (prevS.waiting?.length || 0)) DC.current.waitingCards(prevS, S.current).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // another session's approval from a dragon agent: its dragon (the Q dragon only reacts to this conversation's cards, so never two) // cinematic: the dragon reacts (one moment engine: an attached companion owns the moment instead, dragonMoments.js)
268
312
  // Per shown card: remember the key that was already down when it appeared, and restart the typed word.
269
313
  const c0 = headCard(S.current);
270
314
  if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
@@ -282,6 +326,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
282
326
  // (never printed to scrollback while open); reconcileAsks settles it to one line once it is no longer open.
283
327
  const askSeq = React.useRef(0);
284
328
  const say = (text, tone) => {
329
+ R.current.spinStop?.(); // INSTANT-FEEDBACK: the first printed line replaces the spinner
285
330
  if (tone !== 'ask') return dispatch({ type: 'note', text, tone });
286
331
  const id = `ask-${++askSeq.current}`;
287
332
  R.current.lastAsk = { text, question: askQuestion(text), id };
@@ -292,6 +337,33 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
292
337
  };
293
338
  // QUOXON-ASK: every question for the human is ONE shape (▶ reason. question? keys), highlighted. Never say a raw y/N.
294
339
  const ask = (question, opts) => say(askText(question, opts), 'ask');
340
+ // LAZY-AI-CLI (owner 2026-10-19): when something needs Claude Code or Codex and this server lacks it, offer to
341
+ // install it THEN, as one y/N card (never "type this command"). The server installs the vendor's own npm package.
342
+ const offerInstall = async (tool) => {
343
+ const t = INSTALL_TOOLS[tool];
344
+ const plan = api.providerCliPlan ? await api.providerCliPlan(tool) : null;
345
+ if (!plan?.ok) {
346
+ if (plan?.status === 403) say(`${t.name} isn't installed on this server. An instance admin can install it from the Quox terminal.`, 'warn');
347
+ else if (plan?.status === 404 || plan?.status === 405 || !api.providerCliPlan) say(`${t.name} isn't installed on this server, and this server is too old to install it for you. Update the server first.`, 'warn');
348
+ else say(`${t.name} isn't installed on this server, and Quox could not check npm right now (${plan?.error || 'no answer'}).`, 'warn');
349
+ return nextInstallOffer();
350
+ }
351
+ say(planLine(plan), 'dim');
352
+ R.current.installAsk = { tool, plan, at: Date.now() };
353
+ return ask(offerQuestion(tool, plan), { action: `install ${t.name}`, reason: `${t.vendor}'s own package, downloaded from npm onto this server.` });
354
+ };
355
+ function nextInstallOffer() {
356
+ const q = R.current.installQueue;
357
+ if (!q || !q.length) return undefined;
358
+ return offerInstall(q.shift());
359
+ }
360
+ async function installNow(a) {
361
+ say(`◐ installing ${INSTALL_TOOLS[a.tool].name} ${a.plan?.version || ''} on the server (a minute or two)`.trim(), 'dim');
362
+ const r = await runInstall(api, a.tool, { onProgress: (l) => say(l, 'dim') });
363
+ for (const l of r.lines) say(l, r.ok ? 'ok' : 'warn');
364
+ if (r.ok) bump(n => n + 1);
365
+ return nextInstallOffer();
366
+ }
295
367
  // PLAN MODE: this window's mode ('ask' | 'plan'). Window-scoped: rides /restart, never the shared profile.
296
368
  const MD = React.useRef(null); MD.current ??= normMode(mode);
297
369
  const askerName = () => String(SN.current?.agent || where.current?.agent || 'Quox').toUpperCase(); // a model-suggested command: who asks
@@ -307,10 +379,23 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
307
379
  const CN = React.useRef(null);
308
380
  CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
309
381
  // /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'); };
382
+ // Signed out: offer the fix as y/N (y = the same flow as /login), not only instruct. Same 2-minute ask window as the other offers.
383
+ const offerSignIn = () => { R.current.loginAsk = { at: Date.now() }; say(askText('Sign in now?', { title: `Signed out of ${profile}.`, action: 'open the sign-in card (same as /login)' }), 'ask'); };
384
+ // Once per sign-out: a ref (not state) so concurrent polls in one tick and the stale-credentials retry cannot re-announce.
385
+ const soNoticed = React.useRef(Boolean(signedOutProp));
386
+ const NO_SESSION_RE = /No authentication token|QUOX_SERVICE_KEY|service key|valid jwt|session expired|not signed in/i;
387
+ const noSessionAnswer = () => {
388
+ const first = !soNoticed.current;
389
+ setSignedOut(signedOutRef.current || { reason: null }); soNoticed.current = true;
390
+ say(SIGNED_OUT_LINE, 'error');
391
+ const live = R.current.loginAsk && Date.now() - R.current.loginAsk.at < 120000;
392
+ if (first && !live) offerSignIn();
393
+ };
394
+ const markSignedOut = (reason = null) => { if (BL.current) return; setSignedOut({ reason }); if (soNoticed.current) return; soNoticed.current = true; say(signedOutCard(profile, reason), 'error'); offerSignIn(); };
395
+ React.useEffect(() => { if (signedOutProp) offerSignIn(); }, []); // a window that started signed out gets the same offer
311
396
  const LI = React.useRef(null);
312
397
  LI.current ??= createLoginController({ api, note: (t, tone) => say(t, tone), refresh: () => bump(n => n + 1), onSignedIn: () => {
313
- setSignedOut(null); BL.current = blockedProp || null;
398
+ R.current.loginAsk = null; soNoticed.current = false; setSignedOut(null); BL.current = blockedProp || null;
314
399
  R.current.notify = { fails: 0, authLost: false };
315
400
  dispatch({ type: 'notify', patch: { error: null } });
316
401
  refreshNotify(); refreshRoster();
@@ -319,9 +404,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
319
404
  // QUOXON-STEPUP: the PIN card. The digits live in its closure box only: never dispatched, buffered, in history,
320
405
  // undo, the transcript or any log. Agent logins never get a PIN field (slash/stepUp.js).
321
406
  const SU = React.useRef(null);
407
+ const AO = React.useRef(null); AO.current ??= { st: createAutoOpen({ enabled: autoOn(api.settings?.()) }), timer: null, noticeAt: 0, othersDone: false, otherCounts: new Map(), waitSaid: false, dead: false }; // APPROVALS-AUTO: new approvals open by themselves
322
408
  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
409
  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 });
410
+ SUC.current ??= createStepUpCmds({ api, stepUp: SU.current, say: (t, tone) => say(t, tone), confirm: createConfirm({ say: (t, tone) => say(t, tone) }), lastBlock: () => R.current.lastBlock || null, orgOf: () => api.orgId?.() || null });
325
411
  // L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
326
412
  const PL = React.useRef(null);
327
413
  // QUOXON-FW6: model favourites + default (server-side user preferences; profile = cache).
@@ -355,6 +441,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
355
441
  const SPN = React.useRef(null);
356
442
  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
443
  React.useEffect(() => { if (!blocked && !demoLabel && api.slotSay) SPN.current.sweep().catch(() => {}); }, []); // release this session's overdue VMs
444
+ // GRIPE-QUEUE: gripes saved while Quox did not answer are filed on start and then at most once per 2 minutes (the queue itself enforces the gap).
445
+ const gripeSweep = React.useRef(null);
446
+ gripeSweep.current = async (force = false) => {
447
+ if (!api.gripeRetry) return;
448
+ let r; try { r = await api.gripeRetry({ force }); } catch { return; }
449
+ for (const it of r?.filed || []) say(gripeQueuedFiledLine(it.url));
450
+ for (const it of r?.expired || []) say(`Gripe not filed after 24 h, giving up: "${clip(it.text, Math.max(20, cols - 50))}" (re-file it with /gripe!)`, 'warn');
451
+ };
452
+ React.useEffect(() => {
453
+ if (blocked || demoLabel || !api.gripeRetry) return undefined;
454
+ gripeSweep.current();
455
+ const t = setInterval(() => gripeSweep.current(), 125000);
456
+ return () => clearInterval(t);
457
+ }, []);
358
458
  // QUOXON-FW17: custom rows above the status line (rows.js engine, slash/rowCmd.js).
359
459
  const RW = React.useRef(null);
360
460
  RW.current ??= createRowCmd({
@@ -365,11 +465,68 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
365
465
  const rowsLoaded = React.useRef(false);
366
466
  React.useEffect(() => { if (!blocked && !demoLabel && !rowsLoaded.current) { rowsLoaded.current = true; RW.current.load(); } }, [blocked]);
367
467
  React.useEffect(() => () => RW.current.stopEngine(), []);
468
+ // EXTENSIONS (extensions.js): optional npm packages listed in ~/.quox/extensions.json. They can open an approval or
469
+ // preselect an answer; the user confirms here. Core ships no extension-specific code.
470
+ const DK = React.useRef(null);
471
+ DK.current ??= createExtensionHost({
472
+ env,
473
+ getSettings: () => api.settings?.() || {},
474
+ handlers: {
475
+ openApproval: (n, id) => {
476
+ if (id != null) { const i = (R.current.deckItems || []).findIndex(x => String(x?.id) === id); if (i < 0) return false; n = i + 1; } // contract v1: the id decides, a gone id is refused
477
+ command(`/approvals ${n}`);
478
+ return true;
479
+ },
480
+ preselect: (n, option, id) => {
481
+ if (id != null) { const i = (R.current.deckItems || []).findIndex(x => String(x?.id) === id); if (i < 0) return false; n = i + 1; }
482
+ const it = (R.current.deckItems || [])[n - 1];
483
+ const draft = deckPickDraft(it, n, option);
484
+ if (!draft) return say(`Extension: no such option "${option}" for [${n}].`, 'warn');
485
+ edit({ type: 'set', text: draft }); // preselected only: the answer goes when the user presses Enter
486
+ say(`Preselected: ${draft.trim()}. Press Enter to confirm.`, 'accent');
487
+ },
488
+ note: (t, tone) => say(t, tone),
489
+ changed: () => { setExtEntries(DK.current.commands()); bump(n => n + 1); }
490
+ },
491
+ log: () => {}
492
+ });
493
+ const extDispatch = async (cmd, restArg) => { // a loaded extension's own slash command; true when one handled it
494
+ const xr = await DK.current.runCommand(cmd, restArg, { say: (t, tone) => say(t, tone), org: orgShown(), host: DK.current });
495
+ if (!xr.handled) return false;
496
+ if (xr.error) say(`${cmd}: ${xr.error}`, 'warn'); else if (typeof xr.result === 'string') say(xr.result); else if (xr.result?.text) say(xr.result.text, xr.result.tone);
497
+ return true;
498
+ };
499
+ const extLoad = async (name, quiet) => { const r = await DK.current.load(name); if (!quiet || r.ok) say(r.text, r.ok ? 'ok' : 'warn'); return r; };
500
+ React.useEffect(() => {
501
+ if (!blocked && !demoLabel) for (const x of readExtList()) if (x.enabled) extLoad(x.name, true).catch(() => {});
502
+ return () => { DK.current.unloadAll(); };
503
+ }, []);
504
+ React.useEffect(() => { // fleet for extensions: a light 30 s poll of the existing host list, only while an extension is loaded
505
+ if (!DK.current.armed() || !api.mentionHosts) return undefined;
506
+ let dead = false;
507
+ const poll = () => api.mentionHosts().then((h) => { if (!dead) { R.current.fleetHosts = h; bump(n => n + 1); } }, () => { if (!dead) { R.current.fleetHosts = null; } });
508
+ poll(); const id = setInterval(poll, 30000);
509
+ return () => { dead = true; clearInterval(id); };
510
+ }, [DK.current.armed()]);
511
+ React.useEffect(() => { // push what this window already holds; the host dedupes and throttles to 1/s
512
+ if (!DK.current.armed()) return;
513
+ const rows = R.current.roster?.allRows || [];
514
+ DK.current.push({
515
+ org: { id: R.current.orgId ?? api.orgId?.() ?? null, name: orgShown() },
516
+ items: R.current.deckItems || [],
517
+ health: { collector: R.current.notify?.fails > 0 || S.current.notify?.error ? 'down' : 'ok', rows: rows.some(r => r.state === 'failed') ? 'warn' : 'ok' },
518
+ loops: LC.current.all().map(c => ({ id: c.state.id, title: c.state.title, turn: c.state.iteration ?? c.state.turn, max: c.state.maxTurns, status: c.state.done ? (c.state.state === 'failed' ? 'failed' : 'done') : 'running' })),
519
+ fleet: R.current.fleetHosts || null,
520
+ evidence: api.evidenceChain?.() || null, // quoxon holds no chain status today: 'unknown' unless the api provides one
521
+ readOkAt: (R.current.readOkAt ??= Date.now()), // last good approvals read: older than 60 s makes state.stale
522
+ sessions: { active: rows.filter(r => r.state === 'running').length }
523
+ });
524
+ });
368
525
  PL.current ??= createPalette({
369
526
  getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }),
370
527
  run: (t) => { remember(t); command(t); }, say: (t, tone) => say(t, tone),
371
528
  ctx: () => ({ api, caps: R.current.contract, blocked: BL.current }),
372
- source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all() }),
529
+ source: createArgSources({ api, getPrefs: () => MP.current.prefs, getRows: () => RW.current?.all(), getInbox: () => ({ list: () => R.current.inboxList || [], mutes: () => (api.settings?.() || R.current.sessionSettings || {}).inboxMutes }), getExt: () => ({ listed: () => readExtList().map(x => x.name), loaded: () => DK.current.loadedNames(), panels: () => DK.current.panels(), keys: (n) => DK.current.settingsInfo(n).defs || [] }) }),
373
530
  refresh: () => bump(n => n + 1),
374
531
  // MENTIONS: agents, hosts and tasks fetched once per session on the first @; receipts are this session's own tool envelopes
375
532
  mentions: blocked ? null : { cache: createMentionCache({ load: () => loadMentions(api), refresh: () => bump(n => n + 1) }), local: () => receiptMentions([...S.current.history, ...S.current.blocks]) }
@@ -378,33 +535,80 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
378
535
  flagged: modelChoice != null, off: Boolean(blocked || demoLabel),
379
536
  dispatchCtx: () => dispatch({ type: 'ctx', ctx: NO_RAN })
380
537
  }), []);
538
+ // TUI-STATUS: the registered server this runs on, read once at start (a restart re-reads); null = show nothing
539
+ const SV = React.useRef(null); SV.current ??= { chip: null };
540
+ React.useEffect(() => { // retry every 3 min (max 20) until the lookup succeeds: a collector restart at startup must not hide the chip for the whole session; failing shows nothing
541
+ if (blocked || demoLabel || !api.serverChip) return undefined;
542
+ let stopped = false, timer = null, tries = 0;
543
+ const attempt = () => { tries++; Promise.resolve().then(() => api.serverChip()).then((c) => { if (stopped) return; if (c?.name) { SV.current.chip = c; dispatch({ type: 'notify', patch: {} }); } else if (tries < 20) timer = setTimeout(attempt, 180000); }).catch(() => { if (!stopped && tries < 20) timer = setTimeout(attempt, 180000); }); };
544
+ attempt();
545
+ return () => { stopped = true; if (timer) clearTimeout(timer); };
546
+ }, []);
381
547
  const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
382
548
  // /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 };
549
+ const AR = React.useRef(null); AR.current ??= { on: (api.settings?.() || {}).restartAuto === true, newId: null, cancelledId: null, timer: null, endsAt: 0, busyAt: 0, lastRestartAt: Number((api.settings?.() || {}).lastRestartAt) || 0, quietId: null, mountedAt: Date.now() };
550
+ // COALESCE (owner 2026-10-27): after a restart, no auto-restart for a cool-off (prop, else setting restartCooloffMin, else 30 min); a build landing
551
+ // inside it shows one quiet line (Enter restarts); afterwards the countdown also waits for autoIdleMs (default 2 min) without a keypress.
552
+ const cooloffMs = () => { if (restartCooloffMs != null) return restartCooloffMs; const m = (api.settings?.() || {}).restartCooloffMin; return (m != null && Number.isFinite(Number(m)) && Number(m) >= 0 ? Number(m) : 30) * 60000; };
553
+ const idleNeededMs = () => autoIdleMs ?? 120000;
554
+ const inCooloff = () => AR.current.lastRestartAt > 0 && Date.now() - AR.current.lastRestartAt < cooloffMs();
384
555
  // Countdown length: the autoRestartMs prop (tests), else QUOX_RESTART_COUNTDOWN seconds (default 10; 0 = restart at once).
385
556
  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
557
  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; };
558
+ const stopCountdown = () => { clearTimeout(AR.current.timer); AR.current.timer = null; AR.current.endsAt = 0; AR.current.waiting = null; };
559
+ // A land refreshes the CLI and redeploys the collector together: an AUTO restart waits for the server's /health (every
560
+ // serverPollMs, up to serverWaitMs), then restarts anyway. Manual /restart and Enter never wait.
561
+ const serverUp = serverProbe || (api.health ? () => api.health(3000) : null);
562
+ const autoFire = () => {
563
+ const a = AR.current;
564
+ if (!serverUp) { cdLog('timed-out'); doRestart(); return; }
565
+ const token = {}; a.waiting = token; // set first: cancels and the idle check see a restart in flight
566
+ Promise.resolve().then(() => serverUp()).catch(() => null).then((first) => {
567
+ if (AR.current.waiting !== token || R.current.closed) return;
568
+ if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); return; } // a turn started while the health probe ran: never kill it
569
+ if (first?.ok === true) { stopCountdown(); cdLog('timed-out'); doRestart(); return; } // healthy: no waiting line, as before
570
+ beginWait(token);
571
+ });
572
+ };
573
+ const beginWait = (token) => {
574
+ const a = AR.current;
575
+ a.endsAt = Date.now() || 1;
576
+ a.timer = setTimeout(() => {}, serverWaitMs + 60000); // holds the countdown slot (keys, held-by-draft check); cleared by stopCountdown
577
+ cdLog('waiting-for-server');
578
+ bump(n => n + 1);
579
+ waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => AR.current.waiting !== token || R.current.closed }).then((up) => {
580
+ if (AR.current.waiting !== token || R.current.closed) return; // cancelled (Esc, Enter, a draft) or closed
581
+ stopCountdown();
582
+ if (AR.current.on && autoIdle()) { cdLog(up ? 'server-back' : 'wait-timed-out'); doRestart(); } else { cdLog('held'); bump(n => n + 1); }
583
+ });
584
+ };
388
585
  React.useEffect(() => (startSha ? buildWatch({ startSha, repeat: true, onNotice: (t, id) => {
389
586
  AR.current.newId = id || t;
390
587
  if (!AR.current.on) say(t === DIFFERENT_NOTICE ? t : 'a newer quox is installed: /restart (or /restart auto on)', 'dim');
391
588
  } }) : undefined), []);
392
589
  const autoIdle = () => { // nothing for a restart to interrupt
393
590
  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
591
+ // AWAY-RESTART (2026-11-03): away/DND is the ideal time to restart (nothing to interrupt, DND restores from the server), so the dragon
592
+ // sleep screen (/sleep opens it) and approvals queued for the morning (cards are closed while away) do not count as busy.
593
+ const isAway = Boolean(AWAY.current.afk);
594
+ return !s.turn && (isAway || !s.approvals.length) && !s.pending.length && B.current.text === '' && !R.current.pendingWalk && !R.current.bang && (isAway || !R.current.sleep)
395
595
  && !R.current.restarting && !R.current.exiting && !LI.current.isOpen() && !SU.current.isOpen() && !CN.current.isOpen() && !PK.current.isOpen() && !blocked;
396
596
  };
397
597
  React.useEffect(() => {
398
598
  const id = setInterval(() => {
399
599
  const a = AR.current;
600
+ if (!autoIdle() && B.current.text === '') a.busyAt = Date.now(); // a turn, approval or modal is open: the restart resumes autoResumeMs after it clears (composer text just holds while present)
400
601
  if (!a.on || !a.newId || a.newId === a.cancelledId || R.current.restarting) return;
602
+ if (!a.timer && !a.waiting && inCooloff()) { if (a.quietId !== a.newId) { a.quietId = a.newId; say('↻ new version ready · Enter on an empty composer to restart', 'dim'); } return; } // coalesced: one quiet line, no countdown
603
+ if (!a.timer && !a.waiting && a.lastRestartAt > 0 && Date.now() - (K.current.lastKeyAt ?? a.mountedAt) < idleNeededMs()) return; // cool-off over: wait for a quiet window
604
+ if (a.waiting && !a.timer) { if (!autoIdle()) { stopCountdown(); cdLog('held'); bump(n => n + 1); } return; } // health probe in flight: a turn that started meanwhile cancels the restart
401
605
  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;
606
+ if (!autoIdle() || Date.now() - a.busyAt < autoResumeMs) return;
403
607
  const ms = countdownMs();
404
- if (ms <= 0) { cdLog('timed-out', 0); doRestart(); return; }
608
+ if (ms <= 0) { cdLog('timed-out', 0); autoFire(); return; }
405
609
  a.endsAt = Date.now() + ms;
406
610
  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);
611
+ a.timer = setTimeout(() => { if (AR.current.on && autoIdle()) { clearTimeout(a.timer); a.timer = null; a.endsAt = 0; autoFire(); } else { stopCountdown(); cdLog('held'); bump(n => n + 1); } }, ms);
408
612
  bump(n => n + 1);
409
613
  }, autoTickMs);
410
614
  return () => { clearInterval(id); stopCountdown(); };
@@ -412,18 +616,53 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
412
616
  // /restart carried a slot: re-check it out under this window's label before the old one is gone. /restart auto tends to
413
617
  // fire just as a new build lands, which is often while the collector redeploys, so a timeout or 5xx is retried; if it
414
618
  // 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); }, []);
619
+ React.useEffect(() => { if (restartSlot && !blocked && api.checkoutSlot) waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { if (!R.current.closed) carrySlot(restartSlot); }); }, []);
416
620
  // /restart carried an /attach (agent or slot): re-attach once the window can act (owner 2026-10-15 "why dont u reattach").
417
621
  const attachCarried = React.useRef(false);
418
622
  React.useEffect(() => {
419
623
  if (!restartAttach || blocked || attachCarried.current) return;
420
624
  attachCarried.current = true;
421
625
  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}`);
626
+ waitForHealthy({ probe: serverUp, everyMs: serverPollMs, maxMs: serverWaitMs, cancelled: () => R.current.closed }).then(() => { // the server may still be redeploying: attach only once it answers
627
+ if (R.current.closed) return;
628
+ if (S.current.turn) { R.current.pendingAttach = restartAttach; return; }
629
+ R.current.noGreeting = true; command(`/attach ${restartAttach}`);
630
+ });
423
631
  }, [blocked]);
424
632
  React.useEffect(() => { // any other way the resumed turn ends (reattach path, Esc): carry the attach then
425
633
  if (R.current.pendingAttach && !S.current.turn) { const a = R.current.pendingAttach; R.current.pendingAttach = null; command(`/attach ${a}`); }
426
634
  });
635
+ /**
636
+ * Move THIS window to org `t` ({ id, name }) now: new conversation, status bar, agent re-attach as on /restart.
637
+ * `quick` (picker / `/org <name>`): the pick is the authorization, so one short line, and `makeDefault` also
638
+ * changes the profile-wide default (the same change as `quox org switch` in a shell).
639
+ */
640
+ async function switchOrgNow(t, { quick = false, makeDefault = false } = {}) {
641
+ const R0 = R.current, curId = R0.orgId ?? api.orgId?.() ?? null;
642
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
643
+ if (!api.switchOrg) return say('Org switching is not available in this mode.', 'warn');
644
+ if (t.id === curId && !makeDefault) return say(`Already in ${t.name}.`);
645
+ let done;
646
+ 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'); }
647
+ const att = SN.current.agent ? `agent:${SN.current.agent}` : null;
648
+ if (t.id !== curId) R0.prevOrg = curId ? { id: curId, name: R0.orgName ?? org ?? curId } : null; // /org back returns here
649
+ R0.orgId = done.id; R0.orgName = done.name;
650
+ R0.inboxEpoch = (R0.inboxEpoch || 0) + 1; R0.inboxSeen = null; R0.inboxList = []; R0.inboxAt = 0; // re-baseline: the new org's existing unread are not arrivals
651
+ dispatch({ type: 'notify', patch: { inboxBar: '' } });
652
+ dispatch({ type: 'ctx', ctx: { conversationId: null } });
653
+ SN.current.name = null; SN.current.saved = null;
654
+ dispatch({ type: 'history.reset' });
655
+ if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
656
+ setEpoch(e => e + 1); bump(n => n + 1);
657
+ let scope = 'this window only; the profile default is unchanged';
658
+ let tone = 'ok';
659
+ if (makeDefault) {
660
+ try { await api.makeOrgDefault(done.id); scope = 'this window and the profile default'; }
661
+ catch (e) { scope = `this window only; could not change the profile default (${e?.message || 'refused'})`; tone = 'warn'; }
662
+ }
663
+ if (att) { R.current.noGreeting = true; command(`/attach ${att}`); }
664
+ return say(quick ? `now in ${done.name} (${scope})` : `Switched to ${done.name}. New conversation in that org.`, tone);
665
+ }
427
666
  async function carrySlot(nm) {
428
667
  for (let i = 0; ; i++) {
429
668
  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
@@ -436,7 +675,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
436
675
  ask(`Reattach to slot ${nm}?`, { action: `/slot ${nm} --take` });
437
676
  return;
438
677
  }
439
- if (i === 0) say(`slot ${nm}: reattaching after the restart is slow (${r.message}); retrying.`, 'dim');
678
+ if (i === 0) say(reattachSlowLine({ slot: nm, message: r.message, restartSeenAt: R.current.restartSeenAt, lastGoodAt: R.current.lastGoodAt }), 'dim');
440
679
  await new Promise((res) => { const t = setTimeout(res, slotRetryDelays[i]); t.unref?.(); });
441
680
  }
442
681
  }
@@ -447,6 +686,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
447
686
  return () => clearInterval(id);
448
687
  }, []);
449
688
  // ---- QUOXON-MESH: slot chat (slotChat.js) ----------------------------------------------------------------
689
+ // Dragon companion (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): attach state + real stream events; art and reducer live in dragonlab.
690
+ const DC = React.useRef(null); DC.current ??= createCompanionController({ settings: () => api.settings?.() || {}, isTTY: Boolean(stdout?.isTTY), typing: () => B.current.text !== '', jobs: createDragonJobs({ reads: api.dragonReads || {}, context: () => ({ envelopeId: receiptMentions([...S.current.history, ...S.current.blocks])[0]?.value || null }) }) }); // each dragon's one job: a real read (dragon/jobs.js)
691
+ const dispatchEv = (a) => { DC.current.tap(a); dispatch(a); };
692
+ // QLOOP-LIVE: live loop cards in the chat stream (loopCards.js). A card is held live (commitLimit) until its loop finishes.
693
+ const LC = React.useRef(null); LC.current ??= createLoopCards({ api, say: (t, tone) => say(t, tone), addItem: (it) => dispatch({ type: 'loopcard', card: it.card }), bump: () => { if (!R.current.closed) bump(n => n + 1); }, dragon: { summon: (d, cap) => DC.current.summon(d, cap) } });
450
694
  const SC = React.useRef(null); SC.current ??= { listener: null, slot: null, pendingFile: null, reattach: null, rooms: new Map(), active: null };
451
695
  const realCwd = () => (cwd ? String(cwd).replace(/^~(?=\/|$)/, osHome()) : process.cwd());
452
696
  const myDisplay = () => { try { return api.displayName?.() || null; } catch { return null; } };
@@ -461,9 +705,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
461
705
  open: (q, signal) => api.slotStream(nm, { since: q.since, excludeFrom: label }, signal),
462
706
  wait: api.slotWait ? ({ since }) => api.slotWait(nm, { since, excludeFrom: label }) : null,
463
707
  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));
708
+ // REPLY-ADDRESS: any message (say, broadcast, relayed) without a stamped from_slot gets its sender's held slot looked up
709
+ if (m0.from_slot || !api.slots) return handle(m0);
710
+ Promise.resolve().then(() => api.slots()).then(rows => handle(withSenderSlot(rows, m0)), () => handle(m0));
467
711
  },
468
712
  onState: (st) => {
469
713
  if (st.error && st.failures === 1 && !R.current.closed) { paused = true; streamPausedNote(`slot ${nm}`, st.error); }
@@ -476,9 +720,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
476
720
  noteReply(m); noteSender(m);
477
721
  if (R.current.closed || SC.current.slot !== nm || (label && m.from === label)) return;
478
722
  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 }); }
723
+ if (holdSlotMsg(m)) { rememberSender(m); return; } // DND: collected, no line, no bell, no run card
724
+ { 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 });
725
+ DC.current.slotMessage(m, { broadcast: Boolean(bc), run: Boolean(rq) }).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); } // a session with a dragon speaks to you: its dragon appears (dragonMoments.js sender moments; mode on, not while typing, cooled per sender)
480
726
  rememberSender(m);
481
- if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
727
+ if ((api.settings?.() || {}).slotBell !== 'off' && !AWAY.current.afk) { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
482
728
  }
483
729
  }
484
730
  SC.current.listener = l; l.start({});
@@ -502,6 +748,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
502
748
  const runAskOpen = () => Boolean(SC.current.runAsk && runNow() < SC.current.runAsk.expiresAt); // one clock: the run-ask clock decides open and expired alike
503
749
  /** ASK-SETTLE: history may go to <Static> (scrollback, immutable) only up to the first armed run card. */
504
750
  function commitLimit(s0) {
751
+ // QLOOP-LIVE: a running loop's card is drawn pinned above the composer, so it never holds the history after it live (that re-laid-out every later row on every card tick); syncStatic defers the card itself until it settles
505
752
  const held = SC.current.runCards;
506
753
  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
754
  return s0.history.length;
@@ -528,7 +775,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
528
775
  const wait = SC.current.waits ??= new Map();
529
776
  clearTimeout(wait.get(slot)); wait.delete(slot);
530
777
  if (p.listening) {
531
- const t = setTimeout(() => { wait.delete(slot); if (!R.current.closed) say(`no reply from ${slot} yet`, 'warn'); }, noReplyMs);
778
+ const t = setTimeout(() => { wait.delete(slot); if (R.current.closed) return; const heard = SC.current.heard?.get(String(slot).toLowerCase()); if (heard && Date.now() - heard < 10 * 60000) return; say(`no reply from ${slot} yet`, 'dim'); }, noReplyMs);
532
779
  t.unref?.(); wait.set(slot, t);
533
780
  } else if (r.presence?.state === 'not_listening') {
534
781
  // Transcript fp 6a482d60fe83 (2026-10-16): an attached window got nothing for 4 minutes after "not listening".
@@ -553,8 +800,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
553
800
  ttlMs: replyWatchMs,
554
801
  skipSlot: (nm) => nm === where.current.slot || SC.current.rooms?.has(nm),
555
802
  isMine: (m) => { const l = myLabel(); return Boolean(l && m?.from === l); },
556
- onReply: (nm, m) => {
803
+ onReply: async (nm, m0) => {
557
804
  if (R.current.closed) return;
805
+ let m = m0;
806
+ if (!m0.from_slot && api.slots) { try { m = withSenderSlot(await api.slots(), m0); } catch { m = m0; } if (R.current.closed) return; }
558
807
  noteReply(m); noteSender(m);
559
808
  const ln = slotLine(m); const rq = runRequest(m, { orgId: R.current.orgId ?? api.orgId?.() ?? null, now: runNow() });
560
809
  if (rq) armRun(rq, ln); else dispatch({ type: 'slotmsg', line: ln });
@@ -568,6 +817,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
568
817
  api.bindHolderSession?.(() => slotSession());
569
818
  /** Any inbound line from a slot we are waiting on cancels its "no reply" nudge. */
570
819
  function noteReply(m) {
820
+ const heard = SC.current.heard ??= new Map();
821
+ for (const k of [m?.from_slot, String(m?.from || '').split(' (')[0]]) if (k) heard.set(String(k).toLowerCase(), Date.now());
571
822
  const waits = SC.current.waits; if (!waits?.size) return;
572
823
  for (const [slot, t] of waits) if (isHolderLine(m, slot)) { clearTimeout(t); waits.delete(slot); } // case-insensitive: LUCID is the holder of lucid
573
824
  }
@@ -595,7 +846,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
595
846
  }
596
847
  /** A dropped slot stream: a server restart (connection cut) is routine and says so calmly; anything else keeps the error. */
597
848
  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');
849
+ if (/terminated|ECONNRESET|ECONNREFUSED|socket hang up|fetch failed|other side closed|502|503/i.test(String(err || ''))) { R.current.restartSeenAt = Date.now(); return say(`${where}: Quox server restarting, reconnecting (nothing is lost).`, 'dim'); }
599
850
  return say(`${where}: live messages paused (${err}); retrying.`, 'warn');
600
851
  }
601
852
  /** QUOXON-REPLY (owner 2026-10-15: "click the sender to reply"; no mouse tracking, so /r): who last messaged me. */
@@ -619,14 +870,69 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
619
870
  for (const n of new Set(list.map(r => r.human_presence?.away && r.human_presence.notice).filter(Boolean))) lines.push(`⏸ ${n}`); // PRESENCE
620
871
  return say(lines.join('\n'));
621
872
  }
622
- async function sendToSlot(slot, text) {
873
+ // ---- SLOT-OUTBOX: a send that failed because QuoxCORE did not answer is kept on disk and sent when it answers (slotOutbox.js) ----
874
+ const OB = React.useRef(null);
875
+ OB.current ??= slotOutbox(outboxPath);
876
+ const obProbe = React.useRef({ timer: null, i: 0 });
877
+ // Put text back in the prompt, in front of anything typed since (same shape as a failed steer).
878
+ const restoreDraft = (d) => { if (!d) return; const cur = B.current.text; edit({ type: 'set', text: cur ? `${d}\n${cur}` : d }); };
879
+ const noteAnswering = () => { // one "answering again" line, whichever probe saw it first
880
+ const t = Date.now(); if (t - (R.current.answeringAt || 0) < 5000) return;
881
+ R.current.answeringAt = t; say('✔ QuoxCORE is answering again', 'accent');
882
+ };
883
+ async function flushOutbox({ explicit = false } = {}) {
884
+ if (!api.slotSay) return { sent: [], failed: [], pending: 0 };
885
+ const r = await OB.current.flush(async (it) => {
886
+ const res = await api.slotSay(it.slot, it.text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
887
+ if (res.ok) return { ok: true };
888
+ return { ok: false, retryable: isConnectivityFailure(res), error: res.message };
889
+ }, { profile, includeUnsure: explicit });
890
+ if (R.current.closed) return r;
891
+ for (const it of r.sent) say(sentLine(it.slot), 'ok');
892
+ for (const it of r.failed) say(`Queued message to ${it.slot} was refused (${it.error}); it stays in /outbox (drop it with /outbox drop N).`, 'warn');
893
+ if (r.pending > 0 && !r.busy) armOutboxProbe();
894
+ return r;
895
+ }
896
+ function armOutboxProbe() {
897
+ const P = obProbe.current;
898
+ if (P.timer || R.current.closed) return;
899
+ const delays = healthRecheckDelays?.length ? healthRecheckDelays : [5000];
900
+ P.timer = setTimeout(async () => {
901
+ P.timer = null;
902
+ if (R.current.closed) return;
903
+ let hl = null; try { hl = await (healthRecheck ? healthRecheck() : api.health?.(5000)); } catch { hl = null; }
904
+ if (R.current.closed) return;
905
+ if (hl?.ok) { P.i = 0; noteAnswering(); flushOutbox(); return; }
906
+ P.i = Math.min(P.i + 1, delays.length - 1);
907
+ armOutboxProbe();
908
+ }, delays[Math.min(P.i, delays.length - 1)]);
909
+ }
910
+ React.useEffect(() => {
911
+ if (blocked || demoLabel || !api.slotSay) return undefined;
912
+ const open = OB.current.list({ profile });
913
+ const queued = open.filter(i => i.status === 'queued').length, unsure = open.filter(i => i.status === 'unsure').length;
914
+ if (queued) { say(`⏸ ${queued} message${queued === 1 ? '' : 's'} queued from earlier: sending when QuoxCORE answers · /outbox`, 'warn'); flushOutbox(); }
915
+ if (unsure) say(`⏸ ${unsure} earlier message${unsure === 1 ? '' : 's'} may or may not have been delivered (the window closed mid-send). /outbox lists them; /outbox send retries.`, 'warn');
916
+ return () => { clearTimeout(obProbe.current.timer); obProbe.current.timer = null; };
917
+ }, []);
918
+ async function sendToSlot(slot, text, { draft = `/say ${slot} ${text}` } = {}) {
623
919
  const target = await sayTarget(slot, { senders: true });
624
920
  slot = target.slot;
625
921
  if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
626
922
  if (SC.current.rooms.has(slot)) return postRoom(slot, text); // a joined room posts as a member
627
923
  const r = await api.slotSay(slot, text, { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
924
+ if (!r.ok && isConnectivityFailure(r) && !demoLabel) { // QuoxCORE did not answer: keep the message, never lose it
925
+ const q = OB.current.enqueue({ slot, text, profile });
926
+ if (q.ok) { obProbe.current.i = 0; say(queuedLine(slot, q.count), 'warn'); armOutboxProbe(); return undefined; }
927
+ say(q.full ? fullLine() : `slot ${slot}: ${r.message}. Could not save it to the outbox either; your text is back in the prompt.`, 'warn');
928
+ restoreDraft(draft); return undefined;
929
+ }
930
+ if (!r.ok) restoreDraft(draft); // a refusal (4xx, no such slot): show it and keep the text for editing
628
931
  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'); }
932
+ if (!r.ok && r.ambiguous) return say(r.message, 'warn');
629
933
  if (!r.ok) return say(`slot ${slot}: ${r.message}`, 'warn');
934
+ R.current.lastSay = { slot, at: Date.now() }; // SAY-LAST-TARGET
935
+ if (r.viaOrgId) say(`${slot} lives in ${r.viaOrgName || 'another of your orgs'}; sent there.`, 'dim');
630
936
  const ln = slotLine({ from: slot, text, created_at: r.message?.created_at || new Date().toISOString() });
631
937
  dispatch({ type: 'slotmsg', line: { ...ln, glyph: '→', who: target.label || slot, file: null, out: true, delivered: true } });
632
938
  announceDelivery(slot, r);
@@ -661,10 +967,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
661
967
  onMessage: (m) => {
662
968
  noteReply(m); noteSender(m);
663
969
  if (R.current.closed || !SC.current.rooms.has(nm) || isMine(m)) return;
970
+ if (holdSlotMsg(m, `#${nm}`)) { rememberSender(m); return; } // DND
664
971
  if (SC.current.active !== nm) entry.unread += 1; // before the dispatch, so the status line's count is current
665
972
  dispatch({ type: 'slotmsg', line: roomLine(nm, m) });
666
973
  rememberSender(m);
667
- if ((api.settings?.() || {}).slotBell !== 'off') { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
974
+ if ((api.settings?.() || {}).slotBell !== 'off' && !AWAY.current.afk) { try { stdout?.write?.('\x07'); } catch { /* no tty */ } }
668
975
  },
669
976
  onState: (st) => { if (st.error && st.failures === 1 && !R.current.closed) streamPausedNote(`#${nm}`, st.error); },
670
977
  });
@@ -748,13 +1055,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
748
1055
  SC.current.pendingFile = { slot, f, at: Date.now() };
749
1056
  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
1057
  }
751
- return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }));
1058
+ return sendToSlot(slot, fileMessage({ name: f.name, body: f.body, size: f.size }), { draft: null });
752
1059
  }
753
1060
  // Startup: the slot this profile last held in this directory, free or stale (or still under our old label), is offered once.
754
1061
  React.useEffect(() => {
755
1062
  if (blocked || demoLabel || restartSlot || !api.checkoutSlot || !api.slots) return;
756
1063
  const rec = loadLastSlot({ profile, cwd: realCwd(), path: slotRecordPath });
757
1064
  if (!rec) return;
1065
+ if (restartDeclined && restartDeclined === rec.slot) return; // this window already said no; a restart must not re-ask (a fresh launch has no carry)
758
1066
  api.slots().then((rows) => {
759
1067
  const row = (Array.isArray(rows) ? rows : []).find(x => String(x.name) === rec.slot);
760
1068
  if (!row || SC.current.slot || R.current.closed) return; // only this window's own checkout suppresses the ask, not an inherited one
@@ -765,12 +1073,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
765
1073
  }
766
1074
  }).catch(() => { /* no ask when the server cannot say */ });
767
1075
  }, []);
768
- React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
1076
+ React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume), { fromRestart: Boolean(restartAttach) }); }, []);
769
1077
  React.useEffect(() => { if (!blocked && !demoLabel) aiConnected().then((st) => { R.current.noAi = st === false ? true : st === true ? false : undefined; bump(n => n + 1); }); }, []);
770
1078
  // 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.
1079
+ // QUOXON-MOUSE: optional mouse (default off, saved per profile; selection-safe.test.jsx forbids tracking by default). `tty` = tracking is on at the terminal right now.
772
1080
  const MO = React.useRef(null);
773
1081
  MO.current ??= { on: (api.settings?.() || {}).mouse === 'on', tty: false, chain: Promise.resolve(), drag: null, last: null, onCursor: null, exitFn: null };
1082
+ const TODO_MAP = React.useRef(null); // /todos short ids (td1, do2, dn3) -> real item ids, as last shown
774
1083
  const TP = React.useRef(null);
775
1084
  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
1085
  const showTip = async ({ startup: atLaunch = false } = {}) => {
@@ -807,6 +1116,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
807
1116
  const shown = showWhatsNew();
808
1117
  if (tipStartup && !welcome && !shown) showTip({ startup: true });
809
1118
  };
1119
+ React.useEffect(() => { // STARTUP OFFER: agent login + human logins on this machine => ask once to restart as the human
1120
+ if (!startupSwitchOffer || blocked || demoLabel || R.current.startupSwitchOffered) return;
1121
+ R.current.startupSwitchOffered = true;
1122
+ offerStartupSwitch();
1123
+ }, []);
1124
+ React.useEffect(() => { // LAZY-AI-CLI: first launch after setup: one line, then a y/N card per missing CLI (admins only; never a wall)
1125
+ if (blocked || !welcome || !api.providerCliStatus || !api.providerCliPlan) return;
1126
+ api.providerCliStatus().then(async (st) => {
1127
+ const missing = missingTools(st);
1128
+ if (!missing.length) return;
1129
+ const first = await api.providerCliPlan(missing[0]);
1130
+ if (!first?.ok) return; // not an instance admin (or npm unreachable): no suggestion they cannot act on
1131
+ say(SUGGEST_LINE);
1132
+ R.current.installQueue = missing.slice(1);
1133
+ say(planLine(first), 'dim');
1134
+ R.current.installAsk = { tool: missing[0], plan: first, at: Date.now() };
1135
+ ask(offerQuestion(missing[0], first), { action: `install ${INSTALL_TOOLS[missing[0]].name}`, reason: `${INSTALL_TOOLS[missing[0]].vendor}'s own package, downloaded from npm onto this server.` });
1136
+ }, () => {});
1137
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
810
1138
  React.useEffect(() => {
811
1139
  if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); startupTip(); }, () => startupTip());
812
1140
  else startupTip();
@@ -817,7 +1145,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
817
1145
  // Only the live region repaints at the new width (R3 D2): the stdout proxy (term.js) erases the old frame
818
1146
  // by its reflowed height, so no ghost composers, and history is never reprinted into scrollback.
819
1147
  const onResize = () => setCols(clampCols((stdout.columns || 80) - 1));
1148
+ onResize.resizeFirst = true; // term.js runs Ink's own resize repaint a beat later, after this commits the new width
820
1149
  stdout?.on?.('resize', onResize);
1150
+ onResize(); // a resize between first render and this subscription (a restart replay) must not leave the old width
821
1151
  return () => stdout?.off?.('resize', onResize);
822
1152
  }, [stdout, columns]);
823
1153
 
@@ -846,6 +1176,51 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
846
1176
  if (nm && !isOrgId(nm)) { R.current.orgName = nm; dispatch({ type: 'notify', patch: {} }); }
847
1177
  } catch { /* still unknown: the next successful poll tries again */ } finally { R.current.orgLookup = false; }
848
1178
  };
1179
+ // HITL-INBOX-BAR: unread inbox items on the status area + one stream line per new arrival. A failed read keeps the last bar.
1180
+ // The inbox list read can be heavy on the auth service (seconds per call), and every window polls: the bar backs off in
1181
+ // proportion to the last read's cost (INBOX_BAR_COST_X, capped at INBOX_BAR_MAX_MS), reads 10 items, one read in flight
1182
+ // per window (2026-10-27 auth overload: every window read 30 items every 4s at ~3.4s each and pegged auth).
1183
+ const inboxMutes = () => activeMutes((api.settings ? api.settings() : (R.current.sessionSettings ||= {})).inboxMutes); // per profile, in the CLI settings
1184
+ const barOpts = () => ({ mutes: inboxMutes(), announced: (R.current.inboxAnnounced ||= new Map()) });
1185
+ const refreshInboxBar = async ({ force = false } = {}) => {
1186
+ if (!api.inboxItems) return;
1187
+ const t = Date.now();
1188
+ const gap = Math.min(INBOX_BAR_MAX_MS, (R.current.inboxCost || 0) * INBOX_BAR_COST_X);
1189
+ if (R.current.inboxBusy || (!force && t - (R.current.inboxAt || 0) < gap)) return;
1190
+ R.current.inboxBusy = true; R.current.inboxAt = t;
1191
+ const epoch = R.current.inboxEpoch || 0;
1192
+ try {
1193
+ const items = await api.inboxItems({ limit: 10 });
1194
+ R.current.inboxCost = Date.now() - t;
1195
+ if (epoch !== (R.current.inboxEpoch || 0)) return; // an org switch landed while this read was in flight: it belongs to the old org
1196
+ if (!R.current.inboxSeen && items.length) { try { R.current.inboxSeen = loadSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null); } catch { /* no saved watermark: baseline from this read */ } }
1197
+ const before = R.current.inboxSeen?.size || 0;
1198
+ const a = arrivals(items, R.current.inboxSeen || null);
1199
+ R.current.inboxSeen = a.ids;
1200
+ if (a.ids.size !== before) { try { api.saveSetting?.('inboxSeenByOrg', withSeen(api.settings?.(), R.current.orgId ?? api.orgId?.() ?? null, a.ids)); } catch { /* persisting is best effort */ } }
1201
+ const top = newestFirst(items).slice(0, 10);
1202
+ R.current.inboxList = top;
1203
+ const numOf = (it) => top.findIndex((x) => x.id === it.id) + 1; // each arrival names its own /inbox N (0: not in the latest 10)
1204
+ const ann = announceFresh(a.fresh, barOpts().announced, { mutes: inboxMutes() }); // repeats of an announced title and muted sources print nothing
1205
+ const lines = collapseArrivals(ann.print, numOf, arrivalLine); // APPROVAL-BURST: a burst of one agent's same-tool approvals is ONE line
1206
+ if (AWAY.current.afk) { const col = (AWAY.current.col ||= newCollected()); for (const it of a.fresh) if (!isApprovalItem(it) && !isMuted(it, inboxMutes())) col.inbox.push(it); } // DND: collected for /morningreport, never announced
1207
+ if (!AWAY.current.afk) for (const l of lines.slice(0, 3)) say(l, 'warn'); // AFK-QUIET-CARDS: away keeps notices quiet
1208
+ if (lines.length > 3 && !AWAY.current.afk) say(`📨 ${lines.length - 3} more · /inbox`, 'warn');
1209
+ dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
1210
+ } catch { R.current.inboxCost = Math.max(R.current.inboxCost || 0, Date.now() - t, 3000); /* a failed read backs off too; the last bar stays */ } finally { R.current.inboxBusy = false; }
1211
+ };
1212
+ // ONE pending set (transcript HIGH c80bcdf776d2): the bar count, the roster's approval row(s) and the all-clear all read the
1213
+ // approvals poll's items. The roster endpoint's own approval rows are replaced by rows derived from that set, atomically.
1214
+ const syncApprovalRows = () => {
1215
+ const ro = R.current.roster, open = R.current.openItems;
1216
+ if (!open || !ro.allRows || !api.roster) return; // no roster source in this window: the bar count alone speaks
1217
+ const now = Date.now();
1218
+ const keep = (x) => x.kind !== 'approval';
1219
+ const next = [...ro.allRows.filter(keep), ...open.map((it) => fromApproval(it, now)).filter(Boolean)];
1220
+ ro.allRows = next; ro.rows = selectRows(next, { all: ro.all });
1221
+ if (!ro.rows.length) { ro.focus = false; ro.peek = false; ro.confirm = null; }
1222
+ ro.sel = Math.min(ro.sel, Math.max(0, ro.rows.length - 1));
1223
+ };
849
1224
  const refreshNotify = async () => {
850
1225
  if (BL.current) return;
851
1226
  const N = R.current.notify || (R.current.notify = { fails: 0, authLost: false });
@@ -853,13 +1228,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
853
1228
  const items = await api.approvals();
854
1229
  if (N.authLost) say('Signed in again: approvals are back.', 'ok');
855
1230
  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 } });
1231
+ N.fails = 0; N.authLost = false; N.slow = false; R.current.readOkAt = Date.now();
1232
+ R.current.deckItems = groupApprovals(items, { 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)); // QUOXDECK: numbered as /approvals N
1233
+ R.current.openItems = items; syncApprovalRows();
1234
+ dispatch({ type: 'notify', patch: { approvals: items.length, error: null, slow: false } });
1235
+ refreshInboxBar(); // HITL-INBOX-BAR: rides the same poll, never blocks it
1236
+ if (api.otherOrgApprovals && !R.current.othersBusy) { // other orgs' counts: the helper's own 60s cache bounds the cost; never awaited, never blocks
1237
+ R.current.othersBusy = true;
1238
+ Promise.resolve().then(() => api.otherOrgApprovals()).then(async (v) => { if (v?.checked) { dispatch({ type: 'notify', patch: { others: v.waiting || [] } }); await autoOthers(v); } }, () => {}).finally(() => { R.current.othersBusy = false; });
1239
+ }
858
1240
  retryOrgName();
859
1241
  dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
860
1242
  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
1243
  // 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 });
1244
+ for (const raw of items) {
1245
+ const item = await enrichPlanItem(raw, { objectiveGet: api.objectiveGet, agents: api.agents, orgs: api.orgs, cache: (R.current.planCache ||= new Map()) });
1246
+ dispatchEv({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
1247
+ }
1248
+ autoArrive(items); // APPROVALS-AUTO: after the loop, so this conversation's own inline cards are already known (and skipped)
863
1249
  } catch (e) {
864
1250
  N.fails++; // counted apart from an empty inbox
865
1251
  const msg = e?.message || 'failed';
@@ -870,7 +1256,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
870
1256
  return;
871
1257
  }
872
1258
  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 } });
1259
+ // APPROVALS-POLL: one or two misses keep the last-known count on screen; only a sustained failure blanks it.
1260
+ if (approvalsPollStale({ fails: N.fails, lastOkAt: R.current.readOkAt ?? R.current.startedAt, now: Date.now() })) dispatch({ type: 'notify', patch: { error: msg, slow: true } });
874
1261
  }
875
1262
  };
876
1263
  React.useEffect(() => {
@@ -907,7 +1294,24 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
907
1294
  // model's provider, else the provider the last turn ran on. Unknown yet: say nothing; a failing turn says why.
908
1295
  const mine = String(MC.current?.provider || S.current?.ctx?.provider || '').toLowerCase();
909
1296
  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`) } });
1297
+ if (hits) {
1298
+ // Limited / out of credits on the model in use: offer a working one (y/N), never switch on its own.
1299
+ // Dismissed (n/Esc/chat) stays dismissed until the health state changes.
1300
+ let offer = null;
1301
+ try {
1302
+ const sig = JSON.stringify(list.map(e => [e.provider, e.state, e.resets_at || null]));
1303
+ if (R.current.limitedSig !== sig) { R.current.limitedSig = sig; R.current.limitedDismissed = false; }
1304
+ if (hits.length && !R.current.limitedDismissed && api.connections) {
1305
+ const [res, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
1306
+ offer = limitedOffer(list, MC.current, res, caps, mine);
1307
+ }
1308
+ } catch { /* no offer is the safe answer when the server cannot say what is ready */ }
1309
+ R.current.limitedAsk = offer ? { ...offer, at: Date.now() } : null;
1310
+ const lines = hits.map(e => `⚠ ${e.line || e.provider + ' ' + e.state}: chat on it fails until then`);
1311
+ R.current.limitedBase = lines.slice();
1312
+ if (offer) lines.push(askText(`Switch to ${offer.id} (${offer.why})?`));
1313
+ dispatch({ type: 'notify', patch: { providerLines: lines } });
1314
+ }
911
1315
  }
912
1316
  if (!stopped) timer = setTimeout(tick, 60000);
913
1317
  };
@@ -925,10 +1329,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
925
1329
  applyProgress(r.rows, (R0.progress ||= new Map()), Date.now());
926
1330
  R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
927
1331
  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; }
1332
+ R0.rows = r.rows; syncApprovalRows(); R0.at = Date.now(); // when these rows were last read: a sign-out shows them as "as of" this time
1333
+ if (!AWAY.current.afk) DC.current.watchRoster(rowsWithoutLoopCards(R0.allRows, (id) => LC.current.has(id))).then((shown) => { if (shown) bump(n => n + 1); }, () => {}); // an objective or loop that just finished or failed summons its dragon (companion mode on, not while typing)
1334
+ const at = R0.rows.findIndex(x => x.key === prevKey);
1335
+ R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, R0.rows.length - 1));
1336
+ if (!R0.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
932
1337
  if (!R.current.closed) bump(n => n + 1);
933
1338
  } catch { /* api.roster handles per-source failures; nothing to show */ }
934
1339
  };
@@ -1008,6 +1413,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1008
1413
  d.push({ every: FRAME_MS });
1009
1414
  if (s0.divider.endedAt != null) d.push({ at: s0.divider.endedAt + (s0.divider.phase === 'done' ? GREEN_MS : RED_MS) + 1 });
1010
1415
  }
1416
+ if (DC.current.animating()) d.push({ every: motionOn && !mono() ? FRAME_MS : 500 }); // companion dragon: paced like the classic one, frames come from the cache (dragon/frames.js)
1011
1417
  if (dragonActive(now, !motionOn || mono())) d.push({ every: motionOn && !mono() ? 40 : 500 }); // /dragon and the cinematic moods (qmotion DragonLive)
1012
1418
  if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
1013
1419
  const sl = R.current.sleep;
@@ -1017,6 +1423,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1017
1423
  return d;
1018
1424
  };
1019
1425
  React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
1426
+ // The companion dragon appears and leaves outside any App render (its own leaf and timers): re-arm the clock when it does, or it would get no frames until something else rendered.
1427
+ const rearm = React.useRef(null); rearm.current = () => { if (!R.current.closed) clockRef.current.schedule(demands()); };
1428
+ React.useEffect(() => DC.current.subscribe(() => rearm.current()), []);
1020
1429
 
1021
1430
  const fxEnd = (e) => Math.max(e.at + revealSpan(e.timing) * 1000, e.settleAt != null ? e.settleAt + revealSpan(TIMING.settle) * 1000 : 0);
1022
1431
  const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
@@ -1057,7 +1466,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1057
1466
  if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
1058
1467
  if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
1059
1468
  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 });
1469
+ // Command reached the host and exited >= 1 (not a connect/auth failure): say "ran, exit N", not "failed".
1470
+ const rr = r.result && typeof r.result === 'object' ? r.result : null;
1471
+ const exit = r.status === 'error' && rr && Number.isInteger(rr.exit_code) && rr.exit_code >= 1 && !/handshake failed|unable to authenticate|no supported methods|failed to connect|connection refused|econnrefused|no route to host|connect etimedout/i.test(String(rr.error || '')) ? rr.exit_code : undefined;
1472
+ return dispatch({ type: 'approval.result', callId: run.callId, outcome: r.status === 'error' ? 'failed' : 'done', text, ...(exit !== undefined ? { exit } : {}) });
1061
1473
  }
1062
1474
  } catch { /* keep polling until the deadline */ }
1063
1475
  }
@@ -1088,7 +1500,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1088
1500
  };
1089
1501
  // QUOXON-STEPUP: the server decides what needs a person. We try with any live step-up token; on 403 step_up_required
1090
1502
  // we ask for the PIN (masked card) and retry once. `proactive` (/authorise) asks first. Deny never needs a PIN.
1503
+ // PRESENCE (owner 2026-11-04): approving, denying or answering a card is the owner being here, PIN or not: close an open away
1504
+ // window server-side and stamp last-seen, so the next "While you were away" covers real absence only.
1505
+ const ownerActed = (action) => { touchSeen(true); markPresent(action, { quiet: true }); };
1091
1506
  const respondWithStepUp = async (card, action, { proactive = false, reason = null, bulk = false } = {}) => {
1507
+ ownerActed(action === 'approve' ? 'approval' : 'deny');
1092
1508
  const via = `tty ${sessionLabel()}`;
1093
1509
  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
1510
  const ask = async () => {
@@ -1153,6 +1569,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1153
1569
  queueMicrotask(() => reconcileAsks());
1154
1570
  return true;
1155
1571
  };
1572
+ // TUI-WALK (owner 2026-10-19: he always runs /approvals again to confirm it is empty): the walk ends with a FRESH server
1573
+ // read, never the local count. Empty is only ever claimed after a successful fetch.
1574
+ const walkRecheck = async (W) => {
1575
+ let items;
1576
+ try { items = await api.approvals(); }
1577
+ catch (e) { say(`⚠ couldn't re-check approvals (${clip(String(e?.message || e || 'unknown error').replace(/\s+/g, ' '), 80)}); /approvals to look again`, 'warn'); return; }
1578
+ const gone = walkAnsweredIds();
1579
+ const open = (Array.isArray(items) ? items : []).filter(x => x && !gone.has(x.id));
1580
+ // the closing line repeats the other-org count + switch hint the walk opened with (the helper's own cache: no new token mints)
1581
+ const sayOthers = async () => { try { const v = api.otherOrgApprovals ? await api.otherOrgApprovals() : null; for (const l of crossOrgTuiLines(v)) say(l, 'warn'); } catch { /* the hint is optional */ } };
1582
+ if (!open.length) { say('✔ Nothing else is currently waiting for you.', 'ok'); await sayOthers(); return; }
1583
+ const fresh = open.filter(x => !W.ids.includes(x.id)).length;
1584
+ AO.current.noticeAt = Date.now(); // APPROVALS-AUTO: a bare y/n right after this notice is the card's, never chat
1585
+ const added = AO.current.st.enabled ? autoArrive(open).added.length : 0; // only what is NEW since this window opened pops; older items stay a count
1586
+ if (added) { say(`⏸ ${added} more arrived while you were answering · /approvals`, 'warn'); say(`Opening ${added === 1 ? 'it' : 'them'} next by itself${fresh > added ? `; ${fresh - added} older still waiting` : ''}.`, 'dim'); }
1587
+ else if (fresh && W.auto) say(`⏸ ${fresh} older still waiting · /approvals`, 'warn'); // an auto-opened walk held only the new ones: the rest were there before
1588
+ else if (fresh) say(`⏸ ${fresh} more arrived while you were answering · /approvals`, 'warn');
1589
+ else say(`⏸ ${open.length} still waiting (skipped) · /approvals`, 'warn');
1590
+ await sayOthers();
1591
+ };
1156
1592
  const walkShow = () => {
1157
1593
  endAskEcho(); // TUI-INPUT: a new walk card is a new prompt; the last answer's echo must not sit in front of it
1158
1594
  const W = R.current.pendingWalk; if (!W) return;
@@ -1166,14 +1602,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1166
1602
  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
1603
  nextId = id; break;
1168
1604
  }
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; }
1605
+ 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); walkRecheck(W); queueMicrotask(() => reconcileAsks()); return; }
1170
1606
  const live = W.ids.filter(id => !(W.dropped && W.dropped.has(id))); // expired or gone questions are not counted
1171
1607
  const it = W.items[nextId]; const c = walkCard(it, live.indexOf(nextId) + 1, live.length, { cols: cols - 2 });
1172
1608
  W.current = nextId; W.at = Date.now(); W.card = { at: W.at, walk: nextId };
1173
1609
  say(' ');
1610
+ if (it.__orgName) say(`in ${it.__orgName} · answered there directly; this window stays in ${orgShown() || 'its own org'}`, 'warn'); // APPROVALS-AUTO: another org's card says whose it is
1174
1611
  c.lines.forEach((l, k) => say(l, k === 0 ? 'warn' : k === c.recoLine ? 'reco' : undefined));
1175
1612
  ask('Your choice, with an optional note', { who: it.payload.session || '', action: '', reason: '', keys: '· Esc stops', plain: true });
1176
1613
  };
1614
+ // DESTRUCTIVE-CONFIRM: a delete is approved only by typing the object's name. y opens this step; Esc or n goes back.
1615
+ const startDelConfirm = (obj, run) => {
1616
+ R.current.delConfirm = { name: obj.name, run, at: Date.now() };
1617
+ say(askText(`Type the name to delete it: ${obj.name}`, { keys: 'Esc or n cancels', action: `delete ${obj.label}`, reason: 'A delete is not approved by a single key' }), 'ask');
1618
+ bump(n => n + 1);
1619
+ };
1620
+ const cancelDelConfirm = () => { R.current.delConfirm = null; B.current = initialBuffer(); say('Cancelled; nothing deleted. The card is still open.', 'dim'); bump(n => n + 1); };
1621
+ const delConfirmLine = (text) => {
1622
+ const d = R.current.delConfirm; const t = String(text ?? '').trim();
1623
+ if (t === d.name) { R.current.delConfirm = null; d.run(); return; }
1624
+ if (!t || /^n(o)?$/i.test(t)) { cancelDelConfirm(); return; }
1625
+ say(`That is not the name, so nothing was deleted. Type ${d.name} exactly, or Esc to cancel.`, 'warn');
1626
+ };
1177
1627
  const walkStart = async (list, { fresh = false, quiet = false } = {}) => {
1178
1628
  let decs = (list || []).filter(isDecision);
1179
1629
  if (!fresh && decs.length) { // a list held from before: only what the server still has waiting
@@ -1194,6 +1644,89 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1194
1644
  walkShow();
1195
1645
  return undefined;
1196
1646
  };
1647
+ // APPROVALS-AUTO (owner 2026-11-06: "just make approvals pop up, i am tired of opening them"): a NEW approval or decision
1648
+ // opens by itself as the walkthrough card. It only OPENS; it never answers. A draft, typing in the last 2s, a streaming
1649
+ // turn or another card on screen holds it until the next idle point; an open walk simply gains the card.
1650
+ const autoSkip = () => new Set([...S.current.approvals.map(x => x.inboxItemId), ...walkAnsweredIds(), ...(R.current.pendingWalk ? R.current.pendingWalk.ids : [])].filter(Boolean));
1651
+ const autoBusy = () => Boolean(askOpen() || headCard(S.current) || SU.current.isOpen() || LI.current?.isOpen?.() || CN.current?.isOpen?.() || PK.current?.isOpen?.() || R.current.rs || R.current.noteAsk || R.current.delConfirm || R.current.denyReason || K.current.cardFocus || BL.current || AWAY.current.afk);
1652
+ const autoOpenItems = (items, how) => {
1653
+ const skip = autoSkip(); const list = items.filter(x => !skip.has(x.id));
1654
+ if (!list.length) return;
1655
+ if (how === 'open') { const n = list.length; say(' '); say(`⏸ ${n === 1 ? 'A new approval just arrived; it is open below' : `${n} new approvals just arrived; they are open below, one at a time`}. Esc stops · /approvals auto off stops them opening by themselves.`, 'warn'); }
1656
+ const had = R.current.pendingWalk;
1657
+ walkStart(list.map(approvalAsWalkItem), { fresh: true, quiet: true });
1658
+ if (!had && R.current.pendingWalk) R.current.pendingWalk.auto = true;
1659
+ };
1660
+ const autoTry = () => {
1661
+ const A = AO.current; clearTimeout(A.timer); A.timer = null;
1662
+ if (A.dead) return;
1663
+ const d = openDecision(A.st, { draft: B.current.text, lastKeyAt: K.current.lastKeyAt || 0, now: Date.now(), streaming: Boolean(S.current.turn), walkOpen: Boolean(R.current.pendingWalk), askOpen: autoBusy() });
1664
+ if (!d) return;
1665
+ if (d === 'open' || d === 'join') { A.waitSaid = false; autoOpenItems(takeQueue(A.st), d); return; }
1666
+ if (d === 'wait-draft' && !A.waitSaid) { A.waitSaid = true; say(`⏸ ${A.st.queue.length === 1 ? 'A new approval is' : `${A.st.queue.length} new approvals are`} waiting; it opens once your draft is empty.`, 'dim'); }
1667
+ A.timer = setTimeout(autoTry, 400); A.timer.unref?.();
1668
+ };
1669
+ const autoArrive = (items, opts = {}) => { const A = AO.current; const r = noteArrivals(A.st, items, { skipIds: autoSkip(), ...opts }); if (A.st.queue.length) autoTry(); return r; };
1670
+ // Other orgs: read an org's open items only when its count changed (the counts helper caches 60s), with the user's own token.
1671
+ const autoOthers = async (v) => {
1672
+ const A = AO.current; const first = !A.othersDone; A.othersDone = true;
1673
+ if (!api.approvalsInOrg) return;
1674
+ const waiting = v?.waiting || []; const ids = new Set(waiting.map(w => w.id));
1675
+ A.st.queue = A.st.queue.filter(x => !x.__orgId || ids.has(x.__orgId)); // nothing waiting there any more
1676
+ for (const [id] of A.otherCounts) if (!ids.has(id)) A.otherCounts.delete(id);
1677
+ for (const w of waiting) {
1678
+ if (A.otherCounts.get(w.id) === w.count) continue;
1679
+ try {
1680
+ const items = await api.approvalsInOrg(w.id);
1681
+ A.otherCounts.set(w.id, w.count);
1682
+ autoArrive(items, { orgId: w.id, orgName: w.name, seed: first ? true : (A.st.seeded.has(w.id) ? null : false) });
1683
+ } catch { /* unreadable: the "N more waiting in" line still says so */ }
1684
+ }
1685
+ };
1686
+ // Another org's card in the walk: answered via that org's own URL (the server checks membership there). A high-risk yes
1687
+ // still takes the PIN (a one-shot token minted for THAT org); a privileged yes still needs its explicit confirm there.
1688
+ const walkCrossOrg = async (W, it, rep) => {
1689
+ const org = it.__orgName || it.__orgId; const c = it.payload?.approval_context || {};
1690
+ const keep = () => { W.at = Date.now(); if (W.card) W.card.at = W.at; return true; };
1691
+ if (it.__approval && rep.key === 'y' && isPrivileged({ tool: c.method?.tool, risk: c.risk, flag: it.payload?.privileged === true || c.privileged === true, title: it.title })) {
1692
+ say(`This one is privileged, so a yes needs its explicit confirm in ${org}: ${mark(`/org switch ${org}`)} then ${mark('/approvals')}. n denies it from here; s skips.`, 'warn');
1693
+ return keep();
1694
+ }
1695
+ const label = it.payload.options.find(o => o.key === rep.key)?.label || rep.key;
1696
+ const subject = { line1: `${it.__approval ? (rep.key === 'y' ? 'approve' : 'deny') : 'answer'} ${String(it.title || '')} (in ${org})`, raisedBy: it.payload.session || null, risk: 'high' };
1697
+ let stepUp = {};
1698
+ const pin = async () => { const r = await SU.current.request({ scope: 'approve', orgId: it.__orgId, fresh: true, subject }); if (!r.ok) { const e = new Error(declinedText(r.reason)); e.stepUpDeclined = true; throw e; } stepUp = { 'x-step-up-token': r.token }; };
1699
+ const go = () => { ownerActed('answer'); return it.__approval
1700
+ ? api.respond(it.id, rep.key === 'y' ? 'approve' : 'reject', { via: 'walkthrough', reason: rep.note || null, org: it.__orgId, stepUp })
1701
+ : api.answerDecision(it.id, rep.key, { note: rep.note || null, org: it.__orgId, stepUp }); };
1702
+ R.current.walkSend = true;
1703
+ try {
1704
+ if (needsPin(it) && rep.key !== 'n') await pin();
1705
+ try { await go(); }
1706
+ catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; await pin(); await go(); }
1707
+ } catch (e) {
1708
+ if (e?.stepUpDeclined) { say(`Not answered: ${e.message}.`, 'warn'); return keep(); }
1709
+ if (e?.status === 404 || e?.status === 400 || e?.status === 409) { say(`"${clip(it.title || it.id, 60)}" is no longer waiting in ${org}. Moving on.`, 'dim'); W.done.add(it.id); walkAnsweredIds().add(it.id); if (R.current.pendingWalk === W) walkShow(); return true; }
1710
+ say(`Not answered in ${org} (${clip(String(e?.message || e), 80)}). The question stays: say a choice again, s to skip, or ${mark(`/org switch ${org}`)} to answer it there.`, 'warn');
1711
+ return keep();
1712
+ } finally { R.current.walkSend = false; }
1713
+ say(`✔ answered in ${org}: ${clip(decodeEntities(it.title || it.id), Math.max(20, cols - 40))} -> (${rep.key}) ${decodeEntities(label)}${rep.note ? ` (note: ${clip(rep.note, 40)})` : ''}`, 'ok');
1714
+ walkAnsweredIds().add(it.id); W.done.add(it.id); W.answered.push({ title: it.title, word: `${label} (in ${org})${rep.note ? ` (note: ${rep.note.length} characters)` : ''}` });
1715
+ if (R.current.pendingWalk === W) walkShow();
1716
+ return true;
1717
+ };
1718
+ // The stray y (2026-11-06: a y typed after "1 more arrived" went to triage-q as chat): a bare y/n while a card is queued,
1719
+ // or just after a "more arrived" notice, opens that card NOW with the key kept in the box. A key typed before the card
1720
+ // was on screen never approves what it has not shown: Enter then answers the card you can see.
1721
+ const autoStray = (text) => {
1722
+ const A = AO.current; const t = String(text).trim();
1723
+ const queued = takeQueue(A.st);
1724
+ const kept = () => { edit({ type: 'set', text: t }); say(`Your "${t}" was not sent as chat. It is kept in the box: press Enter to answer the card above.`, 'warn'); };
1725
+ if (queued.length) { autoOpenItems(queued, 'open'); if (R.current.pendingWalk) kept(); return; }
1726
+ A.noticeAt = 0;
1727
+ Promise.resolve(command('/approvals walkthrough')).then(() => { if (R.current.pendingWalk) kept(); else say(`Your "${t}" was not sent as chat: the approval it was for is no longer waiting.`, 'dim'); });
1728
+ };
1729
+ React.useEffect(() => () => { AO.current.dead = true; clearTimeout(AO.current.timer); }, []);
1197
1730
  // 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
1731
  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
1732
  const noteCard = (st) => {
@@ -1227,7 +1760,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1227
1760
  if (rep.notYesNo) { say(walkNotYesNo(it), 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1228
1761
  if (rep.defer) { W.done.add(it.id); W.skipped.push(it.id); walkShow(); return true; }
1229
1762
  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
1763
+ if (it.__approval && rep.key === 'y' && isDestructive(it) && R.current.delBypass !== it.id) { // a delete is confirmed by typing its name, then this same yes goes through
1764
+ W.at = Date.now(); if (W.card) W.card.at = W.at;
1765
+ startDelConfirm(destructiveObject(it), async () => { R.current.delBypass = it.id; try { await walkHandle('y'); } finally { R.current.delBypass = null; } });
1766
+ return true;
1767
+ }
1768
+ if (it.__orgId) return walkCrossOrg(W, it, rep); // APPROVALS-AUTO: another org's card is answered in THAT org, no switch
1769
+ let 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
1770
+ if (!n) { // APPROVALS-AUTO: a card that opened by itself was never listed by /approvals: read the same list, in the same order, now
1771
+ try { const 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 }); R.current.shownIds = pl.map(x => x.id); n = pl.findIndex(x => x.id === it.id) + 1; }
1772
+ catch (e) { say(`Could not re-read approvals (${clip(String(e?.message || e), 80)}); nothing was sent. Say the choice again.`, 'warn'); W.at = Date.now(); if (W.card) W.card.at = W.at; return true; }
1773
+ }
1231
1774
  if (!n) { say(`"${it.title}" is no longer waiting (answered or expired). Moving on.`, 'warn'); W.done.add(it.id); walkShow(); return true; }
1232
1775
  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
1776
  if (rep.note && noteNeedsConfirm(rep.note) && !R.current.noteBypass) {
@@ -1237,7 +1780,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1237
1780
  }
1238
1781
  R.current.lastAnswered = null;
1239
1782
  R.current.walkSend = true;
1240
- try { await command(`/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
1783
+ try { await command(it.__approval ? (rep.key === 'y' ? `/approve ${n}` : `/deny ${n}${rep.note ? ` ${rep.note}` : ''}`) : `/answer ${n} ${rep.key}${rep.note ? ` "${rep.note}"` : ''}`); } finally { R.current.walkSend = false; }
1241
1784
  if (R.current.lastAnswered === it.id) {
1242
1785
  walkAnsweredIds().add(it.id);
1243
1786
  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)` : ''}` });
@@ -1257,9 +1800,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1257
1800
  }
1258
1801
  await respondWithStepUp(card, action, { proactive, reason, bulk });
1259
1802
  // 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 });
1803
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, what: card.what, who: card.facts?.who || card.origin || card.askedBy || null, where: card.facts?.where || null, approvalId: card.approvalId, inboxItemId: card.inboxItemId || null, quiet: Boolean(demoLabel), foreign: Boolean(card.foreign), batch });
1261
1804
  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 });
1805
+ if (action === 'approve' && card.tool !== 'hat.switch' && !demoLabel && !batch) pollResult({ callId: card.callId, approvalId: card.approvalId });
1263
1806
  return { ok: true };
1264
1807
  } catch (e) {
1265
1808
  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)
@@ -1272,6 +1815,35 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1272
1815
  finally { R.current.answering.delete(card.callId); }
1273
1816
  };
1274
1817
 
1818
+ // APPROVAL-BURST: y / n on a batch card answers every item through the normal per-item API (one call each, recorded per item).
1819
+ const answerBatch = async (b, action) => {
1820
+ if (R.current.batchBusy) return;
1821
+ R.current.batchBusy = true;
1822
+ const word = action === 'approve' ? 'approved' : 'denied'; const total = b.cards.length; let done = 0, gone = 0; const failed = [];
1823
+ try {
1824
+ for (const c of b.cards) {
1825
+ try {
1826
+ const r = await answer(c, action, { batch: true, bulk: action === 'approve' });
1827
+ if (r?.ok) done++; else if (r?.gone) gone++; else failed.push(batchLabel(c));
1828
+ } catch (e) { failed.push(`${batchLabel(c)} (${clip(String(e?.message || e), 40)})`); if (e?.stepUpDeclined) break; }
1829
+ }
1830
+ } finally { R.current.batchBusy = false; }
1831
+ const rest = total - done - gone - failed.length; // left unattempted (step-up declined)
1832
+ if (rest > 0) for (const c of b.cards.slice(total - rest)) failed.push(batchLabel(c));
1833
+ say(`${word} ${done} of ${total}${gone ? ` (${gone} already handled elsewhere)` : ''}${failed.length ? `. Not ${word}: ${clip(failed.join(', '), Math.max(30, cols - 40))}` : ''}`, failed.length ? 'warn' : 'ok');
1834
+ };
1835
+ // p on a batch card: the existing one-by-one walk, for just those items.
1836
+ const walkBatch = async (b) => {
1837
+ let items;
1838
+ try { items = await api.approvals(); } catch (e) { say(`Could not read the approvals (${clip(String(e?.message || e), 80)}); nothing changed.`, 'warn'); return; }
1839
+ const flat = groupApprovals(items, { 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));
1840
+ dispatch({ type: 'inbox', items: flat }); R.current.shownIds = flat.map(x => x.id);
1841
+ const ids = new Set(b.cards.map(c => c.inboxItemId));
1842
+ for (const x of queueOf(S.current)) K.current.dismissed.add(x.callId); // the walk owns the keys, as with Tab
1843
+ K.current.confirm = false; bump(n => n + 1);
1844
+ await walkStart(flat.filter(x => ids.has(x.id)).map(approvalAsWalkItem), { fresh: true });
1845
+ };
1846
+
1275
1847
  // ---- turn runner ---------------------------------------------------------------------------
1276
1848
  // Z2H-R2B: with no AI connected a turn is answered here, whole and wrapped, without a server call.
1277
1849
  async function aiConnected() {
@@ -1432,10 +2004,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1432
2004
  say(handoffAnsweredNote(a.h), 'dim');
1433
2005
  bump(n => n + 1);
1434
2006
  }
2007
+ // QUOXON objective offer: the same path as `quox objective create <goal> --checkpoint` + start. Offered, never auto-run.
2008
+ async function runObjective(a) {
2009
+ say('Making the objective…', 'dim');
2010
+ try {
2011
+ if (typeof api.startObjective !== 'function') throw new Error('this build cannot create objectives from here');
2012
+ const o = await api.startObjective(a.o.goal, a.o.loop || null);
2013
+ say(objectiveStartedNote(o.id), 'dim');
2014
+ say(objectiveWatchLine(o.id), 'dim');
2015
+ if (a.o.loop && api.loopEvents) LC.current.watch(o.id, { title: a.o.goal }); // QLOOP-LIVE: the loop's live card appears in the chat stream
2016
+ // Report back into this conversation when a plan objective ends (the owner inbox item covers a closed window).
2017
+ if (!a.o.loop && typeof api.objectiveGet === 'function') {
2018
+ watchObjective({ get: () => api.objectiveGet(o.id), say: (t) => { say(t, 'dim'); bump(n => n + 1); }, stopped: () => R.current.closed === true }).catch(() => {});
2019
+ }
2020
+ } catch (e) { say(objectiveFailedNote(a.o.goal, e), 'dim'); }
2021
+ bump(n => n + 1);
2022
+ }
1435
2023
  async function consumeTurn(turnId, open, { keepOnDown = false } = {}) {
1436
2024
  R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
1437
2025
  R.current.durable = false; // learned from the server: v1.1 events carry a seq
1438
- dispatch({ type: 'turn.begin', turnId });
2026
+ dispatchEv({ type: 'turn.begin', turnId });
1439
2027
  const ctrl = new AbortController();
1440
2028
  R.current.abort = ctrl;
1441
2029
  let offered = false, sawEvent = false, down = false;
@@ -1446,8 +2034,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1446
2034
  sawEvent = true;
1447
2035
  if (typeof ev?.seq === 'number') R.current.durable = true;
1448
2036
  LG.current.observe(ev);
1449
- dispatch({ type: 'event', ev });
2037
+ dispatchEv({ type: 'event', ev });
1450
2038
  if (ev.type === 'tool.finished' && ev.suggest_command) { suggested = true; offerSuggestion(ev); }
2039
+ // QLOOP-LIVE: the chat's objective tool made a LOOP: open its live card in this conversation (the tool answer names the title, not the id)
2040
+ if (ev.type === 'tool.finished' && /objective/i.test(String(ev.tool || '')) && ev.status === 'ok' && /^\s*Loop "/.test(String(ev.result_summary || '')) && api.loopEvents && !(ev.call_id && SC.current.loopSeen?.has(ev.call_id))) {
2041
+ (SC.current.loopSeen ??= new Set()).add(ev.call_id);
2042
+ LC.current.discover({ title: (/^\s*Loop "(.*?)(?:"|$)/.exec(String(ev.result_summary)) || [])[1] || '' }).catch(() => {});
2043
+ }
2044
+ if (ev.type === 'turn.objective_suggested') { const ob = objectiveFromEvent(ev, SN.current.agent || (ev.objective_suggested?.mode === 'loop' && ev.objective_suggested?.from === 'quox' ? 'quox' : null)); if (ob) R.current.objectivePending = { o: ob, text: R.current.lastSent || '' }; } // QUOXON objective offer; unattached chat is Quox itself, so its keep-going offer still shows
1451
2045
  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
2046
  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
2047
  // Z2H-3B: a server default that is not connected must not linger in the status bar.
@@ -1468,7 +2062,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1468
2062
  if (S.current.turn?.id === turnId) {
1469
2063
  // stream closed without a terminal event
1470
2064
  // never claim completion the server did not confirm
1471
- dispatch({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
2065
+ dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
1472
2066
  }
1473
2067
  } catch (e) {
1474
2068
  if (keepOnDown && !sawEvent && !ctrl.signal.aborted && isServerDown(e)) { down = true; }
@@ -1479,15 +2073,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1479
2073
  say(`Blocked by policy (${e.body.block_id}). A person can override this one action: /override last <reason>`, 'warn');
1480
2074
  }
1481
2075
  // 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 } });
2076
+ if (Array.isArray(e?.body?.aocl_layers)) for (const l of e.body.aocl_layers) dispatchEv({ type: 'event', ev: { type: 'aocl.layer', ...l } });
1483
2077
  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' } });
2078
+ if (why) for (const b of S.current.blocks) if (b.kind === 'tool' && b.status === 'running') dispatchEv({ 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
2079
  const dead = !why && (e?.status === 401 || isSessionDead(e));
1486
- dispatch({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
2080
+ dispatchEv({ type: 'event', ev: { type: 'error', error: why || (dead ? SIGNED_OUT_LINE : friendlyError(e, profile)) } });
1487
2081
  if (dead) markSignedOut(e?.reason || null);
1488
2082
  if (why) say(RETRY_HINT, 'warn');
1489
2083
  // 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' } });
2084
+ if (S.current.turn?.id === turnId) dispatchEv({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
1491
2085
  }
1492
2086
  } finally {
1493
2087
  clearTimeout(R.current.fallbackTimer);
@@ -1499,6 +2093,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1499
2093
  // QUOXON handoff offer: the attached agent lacks the tools for this ask. The handoff card answers the actual question,
1500
2094
  // so it wins over the agent's own suggested command (owner live 22:42: "Run /vm list?" took the slot and the handoff
1501
2095
  // card was dropped silently). Nothing is dropped silently: a superseded or skipped card says so in one line.
2096
+ const op = R.current.objectivePending; R.current.objectivePending = null;
2097
+ const opOk = Boolean(op && !down && !ctrl.signal.aborted && !planDone);
2098
+ if (opOk) { R.current.handoffPending = null; R.current.suggestLine = null; R.current.suggestAsk = null; } // one ask, not two: the objective card replaces the handoff and the agent's own command
1502
2099
  const hp = R.current.handoffPending; R.current.handoffPending = null;
1503
2100
  const hpOk = Boolean(hp && !down && !ctrl.signal.aborted && hp.text);
1504
2101
  if (hpOk && !planDone && R.current.suggestLine) {
@@ -1506,8 +2103,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1506
2103
  R.current.suggestLine = null; R.current.suggestAsk = null;
1507
2104
  say(handoffSupersededNote(hp.h, line), 'dim');
1508
2105
  }
1509
- const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone);
2106
+ const newOffer = Boolean(R.current.suggestLine) || planDone || (hpOk && !planDone) || opOk;
1510
2107
  flushSuggestion();
2108
+ if (opOk) { R.current.objectiveAsk = { ...op, at: Date.now() }; say(objectiveAskText(op.o, cols), 'ask'); }
1511
2109
  if (hpOk && planDone) say(handoffSkippedNote(hp.h), 'dim');
1512
2110
  else if (hpOk) {
1513
2111
  const hs = (R.current.handoffStreak ??= createHandoffStreak()).offered(hp.h.from);
@@ -1524,7 +2122,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1524
2122
  }
1525
2123
 
1526
2124
  React.useEffect(() => {
1527
- if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length) wakeDragon(); // an approval always wakes it
2125
+ if (R.current.sleep && R.current.sleep.wakeAt == null && S.current.approvals.length && !AWAY.current.afk) wakeDragon(); // an approval always wakes it
1528
2126
  const s = S.current;
1529
2127
  if (!s.turn && s.autoDrain && s.pending.length) {
1530
2128
  const next = s.pending[0];
@@ -1535,7 +2133,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1535
2133
 
1536
2134
  // 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
2135
  function runQueued(text, { checked = false } = {}) {
1538
- if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
2136
+ if (isOneLineCommand(text)) { command(text); return; }
1539
2137
  const stray = checked ? null : strayCardAnswer(text, commandNames()); // TUI-INPUT: "n/help" = a card answer that leaked into the composer
1540
2138
  if (stray) { R.current.strayAsk = { text, command: stray.command, at: Date.now() }; ask(strayQuestion(stray)); return; }
1541
2139
  dispatch({ type: 'user', text, queued: true });
@@ -1549,30 +2147,91 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1549
2147
 
1550
2148
  // PRESENCE (owner 2026-10-16): /afk and /sleep tell the server, so sessions and LLMs know you are not at this
1551
2149
  // terminal (approvals still queue; nothing is approved). /back or any typed turn marks you present.
1552
- async function setPresence(state, note) {
2150
+ async function setPresence(state, note, { human = false, dnd = false } = {}) {
1553
2151
  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 }); }
2152
+ R.current.presenceSetAt = Date.now();
2153
+ const r0 = await setPresenceRaw(state, note, { human, dnd }); R.current.presenceUnsynced = !r0.ok; return r0;
2154
+ }
2155
+ async function setPresenceRaw(state, note, { human = false, dnd = false } = {}) {
2156
+ try { return await api.setPresence(state, { note: note || undefined, human, dnd, slots: where.current.slot ? [where.current.slot] : undefined }); }
1555
2157
  catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
1556
2158
  }
1557
2159
  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;
2160
+ // DO-NOT-DISTURB (owner 2026-10-28, "sleep means sleep"): while AWAY.afk is set (/dnd, /afk, /sleep) the window collects
2161
+ // instead of announcing: no notice lines, cards, bells or dragon moments. /morningreport is the grouped digest.
2162
+ const dndNote = (until) => DND_NOTE + (until ? ` until ${String(new Date(until).getHours()).padStart(2, '0')}:${String(new Date(until).getMinutes()).padStart(2, '0')}` : '');
2163
+ function holdSlotMsg(m, label) {
2164
+ if (!AWAY.current.afk) return false;
2165
+ (AWAY.current.col ||= newCollected()).slots.push({ from: label || m.from || 'slot', text: String(m.text || '') });
2166
+ bump(n => n + 1);
2167
+ return true;
2168
+ }
2169
+ async function dndOn(until = null) {
2170
+ const at = Date.now(); const untilMs = until ? untilEpoch(until, at) : null;
2171
+ const note = dndNote(untilMs);
2172
+ AWAY.current.last = at; AWAY.current.afk = { at, note, dnd: true, explicit: true, until: untilMs }; AWAY.current.col ||= newCollected();
2173
+ try { away?.write?.(profile, at); } catch { /* in-memory mark still works */ }
2174
+ say(`🌙 Do not disturb is on${untilMs ? ` until ${note.slice(-5)}` : ''}. Everything is collected for the morning: /morningreport shows it, /dnd off or /back ends it.`, 'ok');
2175
+ const pr = await setPresence('sleeping', note, { dnd: true });
2176
+ if (!pr.ok) presenceFail(pr);
2177
+ bump(n => n + 1);
2178
+ }
2179
+ // MORNINGREPORT-WINDOW (owner 2026-11-03): ending DND/away must not erase what it collected. The most recent window
2180
+ // (start, end, collected) is kept so /morningreport answers about it whether or not DND is on at the moment of asking.
2181
+ function archiveWindow() {
2182
+ const a = AWAY.current.afk, c = AWAY.current.col;
2183
+ if (!a && !c) return;
2184
+ AWAY.current.lastWindow = { since: c?.since || a?.at || Date.now(), until: Date.now(), col: c || newCollected() };
2185
+ }
2186
+ async function dndOff() {
2187
+ archiveWindow();
2188
+ AWAY.current.afk = null;
2189
+ const n = waitingCount(AWAY.current.col, 0); AWAY.current.col = null; // the archived window keeps it; the next window starts empty
2190
+ say(`Do not disturb is off.${n ? ` ${n} collected while it was on: /morningreport shows them.` : ''}`, 'ok');
2191
+ const pr = await setPresence('present', undefined, { human: true });
2192
+ if (!pr.ok) presenceFail(pr);
2193
+ bump(n2 => n2 + 1);
2194
+ }
2195
+ async function sleepNow() { // owner 2026-10-29: one step to bed: sleeping AND do-not-disturb
2196
+ const at = Date.now(); const note = dndNote(null);
2197
+ AWAY.current.last = at; AWAY.current.afk = { at, note, dnd: true, explicit: true, until: null }; AWAY.current.col ||= newCollected();
2198
+ try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2199
+ say("🌙 Asleep, do not disturb on: everything collects for the morning. /morningreport or /back when you're up.", 'ok');
2200
+ const pr = await setPresence('sleeping', note, { dnd: true });
2201
+ if (!pr.ok) presenceFail(pr);
2202
+ bump(n => n + 1);
2203
+ }
2204
+ function offerDndToo() {
2205
+ if (R.current.dndAsk) return;
2206
+ R.current.dndAsk = { at: Date.now(), off: false };
2207
+ say(askText('Turn on do not disturb too? (y/N)', { action: 'collect arrivals quietly until you are back (same as /dnd on)' }), 'ask');
2208
+ }
2209
+ function offerDnd() {
2210
+ if (AWAY.current.afk || R.current.dndAsk) return;
2211
+ R.current.dndAsk = { at: Date.now(), off: false };
2212
+ say(askText('Turn on do not disturb until morning?', { action: 'collect arrivals quietly until you are back (same as /dnd on)', title: 'Going to sleep?' }), 'ask');
2213
+ }
2214
+ function markPresent(action = 'chat', { quiet = false } = {}) {
2215
+ if (/^\/(afk|sleep|back|away|dnd|donotdisturb|morningreport)(\s|$)/.test(String(action).replace(/^slash /, ''))) return;
1560
2216
  if (AWAY.current.afk) { // this window set the flag: a typed turn here is the owner back (explicit, as before)
2217
+ archiveWindow();
1561
2218
  AWAY.current.afk = null;
1562
- setPresence('present').then((r) => { if (!r.ok) presenceFail(r); }, () => {});
2219
+ setPresence('present', undefined, { human: true }).then((r) => { if (!r.ok) presenceFail(r); }, () => {});
1563
2220
  return;
1564
2221
  }
1565
2222
  // PRESENCE (owner 2026-10-19): a window that never set the flag (restarted TUI) still clears the server's stale one,
1566
2223
  // but only from a real terminal (api.noteActivity is a no-op for --print, pipes and proof runs). Welcome shows once.
1567
2224
  if (!api.noteActivity) return;
1568
- api.noteActivity(action).then((r) => {
2225
+ api.noteActivity(action, quiet ? { quiet: true } : undefined).then((r) => {
1569
2226
  if (r?.welcome?.away_text && !AWAY.current.welcomed) { AWAY.current.welcomed = true; say(`✔ Welcome back, you were away ${r.welcome.away_text}`, 'ok'); }
1570
2227
  }, () => {});
1571
2228
  }
1572
2229
  function submit(text) {
1573
2230
  touchSeen();
2231
+ AR.current.busyAt = Date.now(); // a just-sent turn is in flight before S.turn shows it: auto-restart must not fire in that gap
1574
2232
  markPresent(/^\//.test(text.trim()) ? `slash ${text.trim().split(/\s+/)[0]}` : 'chat');
1575
2233
  dispatch({ type: 'user', text });
2234
+ if (!/^\//.test(text.trim()) && sleepIntent(text)) offerDnd(); // never silently: it asks
1576
2235
  const tip = intentHint(text); // a hint only: the turn still goes to the model
1577
2236
  if (tip) say(tip);
1578
2237
  const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
@@ -1616,6 +2275,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1616
2275
  R.current.fallbackTimer = setTimeout(local, INTERRUPT_FALLBACK_MS);
1617
2276
  }
1618
2277
 
2278
+ // Stop the turn now, locally and for good: tell the server (best effort), abort the stream, and always leave busy cleared.
2279
+ function stopTurnNow() {
2280
+ const s = S.current, turn = s.turn;
2281
+ try {
2282
+ if (R.current.waitingServer) { R.current.abort?.abort(); return; }
2283
+ if (!turn) return;
2284
+ clearTimeout(R.current.fallbackTimer);
2285
+ try { Promise.resolve(api.interrupt?.(s.ctx.conversationId, turn.id)).catch(() => {}); } catch { /* best effort */ }
2286
+ R.current.abort?.abort();
2287
+ } finally {
2288
+ if (turn && S.current.turn?.id === turn.id) dispatch({ type: 'stopped.local' });
2289
+ }
2290
+ }
2291
+
1619
2292
  // ---- L1 slash commands (models in slash/*.js) ------------------------------------------------
1620
2293
  const help = (model) => dispatch({ type: 'help', model });
1621
2294
  const leaveArmed = (kind) => { // a running turn would be disconnected: ask once, a second ask within 5 s goes ahead
@@ -1662,7 +2335,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1662
2335
  if (cmd === '/cost') return help(costModel(LG.current));
1663
2336
  if (cmd === '/status') {
1664
2337
  let hl = R.current.lastHealth || null;
1665
- if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; }
2338
+ if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; if (hl) R.current.lastGoodAt = Date.now(); }
1666
2339
  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
2340
  }
1668
2341
  if (cmd === '/context') {
@@ -1700,12 +2373,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1700
2373
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
1701
2374
  let turns;
1702
2375
  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'}` }); }
2376
+ // RESUME: the attachment and the conversation must name the same agent. After a /restart the replayed attach is what the
2377
+ // window held when it was saved; if the conversation was with someone else, warn once and start fresh rather than mix.
2378
+ const wasAgent = st.fromRestart && /^agent:/.test(restartAttach || '') ? restartAttach.slice(6) : null;
2379
+ if (st.fromRestart && wasAgent && row.agent && row.agent !== wasAgent) return say(`That conversation was with ${row.agent}, but this window was attached to ${wasAgent}. Starting a fresh conversation with ${wasAgent}; /resume brings the ${row.agent} one back.`, 'warn');
1703
2380
  const t = turnsToHistory(turns);
2381
+ const shownTitle = cleanTitle(row.title) && row.title !== '(untitled)' ? cleanTitle(row.title) : (titleFromTurns(turns) || '(untitled)'); // never rendered UI
1704
2382
  dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
1705
2383
  SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
1706
2384
  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');
2385
+ say(`Resumed "${clip(shownTitle, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
1708
2386
  dispatch({ type: 'history.add', items: t.items, answers: t.answers });
2387
+ if (!st.fromRestart && row.agent && row.agent !== SN.current.agent) { R.current.noGreeting = true; await command(`/attach agent:${row.agent}`); } // next turns go to the agent this conversation was with
1709
2388
  if (!t.total && agentLogin && row.byName) {
1710
2389
  const h = humanProfiles.find(x => x !== profile);
1711
2390
  return say(`${row.byName} was saved by another profile. Start with quox -p ${h || '<human profile>'} to resume it.`, 'warn');
@@ -1713,9 +2392,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1713
2392
  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
2393
  return reattachIfRunning(row.id);
1715
2394
  }
1716
- async function resumeCmd(q) {
2395
+ async function resumeCmd(q, { fromRestart = false } = {}) {
1717
2396
  if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
1718
- let rows; const st = {};
2397
+ let rows; const st = { fromRestart };
1719
2398
  try {
1720
2399
  const [list, index] = await reconnecting(() => Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]), st);
1721
2400
  rows = resumeRows(list, index, loadNames(namesPath));
@@ -1770,6 +2449,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1770
2449
  bump(n => n + 1); return true;
1771
2450
  }
1772
2451
  function dragonFly() { // one pass of the dragon in flight in the live region; Esc stops it, a new call restarts it
2452
+ const room = flyRoom(termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn'); // too small: say so, never silently nothing
1773
2453
  R.current.fly = { startedAt: Date.now(), still: !motionOn || mono() };
1774
2454
  bump(n => n + 1); return true;
1775
2455
  }
@@ -1882,6 +2562,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1882
2562
  }
1883
2563
 
1884
2564
  function cliSlashRun(hit) {
2565
+ if (hit.error) return say(`${hit.error}`, 'warn');
1885
2566
  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
2567
  const bang = { kind: 'quox', args: hit.args, cmd: hit.display };
1887
2568
  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.' }); }
@@ -1933,6 +2614,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1933
2614
  const cand = loginCandidates.find(x => x !== me && okProfileName(x)) || 'me';
1934
2615
  return offerLogin(cand, `This window is an agent login (${me}) and no human login is signed in on this machine.`);
1935
2616
  }
2617
+ // STARTUP OFFER (owner 2026-10-28): `quox` opened on an agent login while human logins exist asks once, y/N, to
2618
+ // restart as the first human profile (the rest are in /profile). N keeps the agent window; the intro warning stays.
2619
+ function offerStartupSwitch() {
2620
+ const { profile: me, agentLogin: isAgent } = WHO.current;
2621
+ if (!isAgent) return undefined;
2622
+ const human = humanProfiles.find(x => x !== me && okProfileName(x));
2623
+ if (!human) return undefined;
2624
+ R.current.profileAsk = { name: human, at: Date.now() };
2625
+ return say(startupSwitchAskLine(me, human, humanProfiles.filter(x => x !== me && x !== human).length), 'ask');
2626
+ }
2627
+ // Plain-language slot attach: a known slot gets the /slot offer (y runs it); anything else is ordinary chat.
2628
+ async function offerSlotAttach(nm, text) {
2629
+ let rows = null;
2630
+ try { rows = await api.slots(); } catch { rows = null; }
2631
+ const known = Array.isArray(rows) && rows.find(x => String(x.name).toLowerCase() === nm.toLowerCase());
2632
+ if (!known) { submit(text); return; }
2633
+ const line = `/slot ${known.name}`;
2634
+ dispatch({ type: 'user', text });
2635
+ R.current.suggestAsk = { line, at: Date.now() };
2636
+ ask(`Run ${line}?`, { action: line, reason: `"${clip(text, 60)}" is a request to attach to that slot` });
2637
+ }
1936
2638
  function offerLogin(name, lead) {
1937
2639
  return armRun(loginRunRequest(name, `${lead} Sign ${name} in, then /profile ${name} switches this window to it.`, runNow()), { who: 'Quox', glyph: 'Ⓠ', text: '' });
1938
2640
  }
@@ -1975,17 +2677,37 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1975
2677
  target = c.onPath || undefined;
1976
2678
  }
1977
2679
  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 });
2680
+ AR.current.lastRestartAt = Date.now(); api.saveSetting?.('lastRestartAt', AR.current.lastRestartAt); // the next window reads it: restarts coalesce
2681
+ 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' } : {}), ...(R.current.declinedSlot || restartDeclined ? { declined: R.current.declinedSlot || restartDeclined } : {}), bin: target, ...extra });
1979
2682
  return exit(30);
1980
2683
  }
1981
2684
 
2685
+ // INSTANT-FEEDBACK (owner 2026-10-26): every slash command goes through here, so any async handler shows `⠋ <verb>…`
2686
+ // within 100 ms until its result or error line. A command run from inside another one reuses the outer spinner.
1982
2687
  async function command(line) {
1983
- if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/note');
2688
+ if (R.current.spinOn || !/^\s*\/(?!\/)/.test(String(line))) return commandRun(line);
2689
+ R.current.spinOn = true;
2690
+ try {
2691
+ return await runWithSpinner(() => commandRun(line), {
2692
+ verb: spinnerVerb(line),
2693
+ show: (t) => dispatch({ type: 'notify', patch: { spinner: t } }),
2694
+ hide: () => dispatch({ type: 'notify', patch: { spinner: '' } }),
2695
+ register: (stop) => { R.current.spinStop = stop; }
2696
+ });
2697
+ } finally { R.current.spinOn = false; R.current.spinStop = null; }
2698
+ }
2699
+ // A command the TUI dispatches from inside another one (/sleep -> /dragon sleep) is not the owner typing: it never marks present.
2700
+ async function commandRun(line) {
2701
+ const nested = (R.current.cmdDepth || 0) > 0; R.current.cmdDepth = (R.current.cmdDepth || 0) + 1;
2702
+ try { return await commandBody(line, nested); } finally { R.current.cmdDepth -= 1; }
2703
+ }
2704
+ async function commandBody(line, nested) {
2705
+ if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/devnote');
1984
2706
  const [cmd0, arg] = line.trim().split(/\s+/);
1985
2707
  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
2708
+ if (!nested && !['/afk', '/sleep', '/back', '/away', '/dnd', '/donotdisturb', '/morningreport'].includes(cmd)) markPresent(`slash ${cmd}`); // PRESENCE: any slash command but /afk and /sleep is the owner being back
2709
+ if (cmd !== '/devnote' && cmd !== '/context') SCX.current.command(line.trim());
2710
+ if (cmd !== '/devnote') transcript?.command((/^\/(authenticate|authorise|authorize)$/.test(cmd) && !/^[\d,\s-]*$/.test(line.trim().replace(/^\S+\s*/, ''))) || (cmd === '/verify-email' && /(^|\D)\d{6}(\D|$)/.test(line)) ? cmd : line); // QUOXON-STEPUP: never record a stray argument that might be a PIN
1989
2711
  const restArg = line.trim().replace(/^\S+\s*/, '');
1990
2712
  touchSeen();
1991
2713
  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
@@ -1998,8 +2720,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
1998
2720
  objArg = p.name;
1999
2721
  }
2000
2722
  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);
2723
+ 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, cols });
2724
+ if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : r.lines ? r.lines.forEach(l => say(l, r.tone)) : say(r.note, r.tone); // wrap, never clip: notes carry ids and hints
2003
2725
  }
2004
2726
  switch (cmd) {
2005
2727
  case '/copy': {
@@ -2009,13 +2731,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2009
2731
  return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
2010
2732
  }
2011
2733
  case '/share': return shareCommand(arg);
2012
- case '/note': {
2734
+ case '/devnote': {
2013
2735
  if (!devObserverOn()) return say(DEV_OBSERVER_REFUSAL, 'warn');
2014
- if (!restArg) return say('Usage: /note <text> (or // text). Nothing to note.', 'warn');
2736
+ if (!restArg) return say('Usage: /devnote <text> (or // text). Nothing to note.', 'warn');
2015
2737
  if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
2016
2738
  transcript.note(restArg.slice(0, 2000));
2017
2739
  return say('noted for the observer', 'dim');
2018
2740
  }
2741
+ case '/note': { // TUI-NOTE: a NotesQ note, same backend as `quox notes new`
2742
+ if (!restArg) return say('Usage: /note <text> (saves a NotesQ note; /notes lists them).', 'warn');
2743
+ if (!api.createNote) return say('NotesQ is not available in this window. Run quox notes new from a signed-in terminal, or enable NotesQ for your org in the dashboard.', 'warn');
2744
+ const r = await api.createNote(restArg);
2745
+ if (!r.ok) return say(r.message, 'warn');
2746
+ return say(`Saved note "${r.title}" (${r.id}). /notes lists your notes (or quox notes ls).`, 'ok');
2747
+ }
2748
+ case '/notes': return cliSlashRun(resolveCliSlash('/notes', 'ls'));
2749
+ case '/contact': return cliSlashRun(resolveCliSlash('/contact', restArg)); // registered command; runs as the quox contacts leaf
2019
2750
  case '/rename': {
2020
2751
  if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
2021
2752
  const n = cleanName(restArg);
@@ -2033,17 +2764,38 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2033
2764
  const m = /^(\S+)\s+([\s\S]*\S[\s\S]*)$/.exec(restArg || '');
2034
2765
  if (!m) return say('Usage: /say <slot>[,<slot>...] <text>', 'warn');
2035
2766
  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());
2767
+ if (to.length < 2) { // SAY-LAST-TARGET: a first word that is not a slot is part of the message when a recent target exists
2768
+ const first = to[0] || m[1].toLowerCase();
2769
+ let rows = [];
2770
+ if (api.slots) { try { rows = await api.slots(); } catch { rows = []; } }
2771
+ let known = !looksLikeAddress(m[1]) && !!resolveSayTarget(first, Array.isArray(rows) ? rows : [], SC.current.senders);
2772
+ const last = R.current.lastSay;
2773
+ let lookupFailed = false;
2774
+ // Not in this org's list: a slot in another of the user's own orgs (or name@org) is still a target, not message text.
2775
+ if (!known && last && !looksLikeAddress(m[1]) && api.slotLocate) {
2776
+ try { known = !!(await api.slotLocate(first))?.found; } catch { lookupFailed = true; }
2777
+ }
2778
+ if (!known && (looksLikeAddress(m[1]) || last)) {
2779
+ if (last && Date.now() - last.at < 2 * 3600000) {
2780
+ if (lookupFailed) say(`Could not check your other orgs for "${first}", so this goes to ${last.slot}, the last target.`, 'warn');
2781
+ R.current.sayLastAsk = { slot: last.slot, text: restArg.trim(), at: Date.now() };
2782
+ return say(askText(`Send to ${last.slot}?`, { keys: 'y send · n cancel', action: `send the whole message to ${last.slot}` }), 'ask');
2783
+ }
2784
+ return say(sayWhoText(rows), 'warn');
2785
+ }
2786
+ return sendToSlot(first, m[2].trim());
2787
+ }
2037
2788
  // QUOXON-SAY-CC: one line to several slots; each copy names everyone it went to, so they know who else has it
2038
2789
  const slots = await Promise.all(to.map(t => canonSlot(t, { senders: true })));
2039
2790
  const head = `(to ${slots.join(', ')}) `;
2040
- for (const sl of slots) await sendToSlot(sl, head + m[2].trim());
2791
+ for (const sl of slots) await sendToSlot(sl, head + m[2].trim(), { draft: null });
2041
2792
  return undefined;
2042
2793
  }
2043
2794
  case '/join': { const nm = roomName(restArg); if (!nm) return say('Usage: /join <slot> (or #<slot>)', 'warn'); return joinRoom(nm); }
2044
2795
  case '/leave': { const nm = roomName(restArg); if (!nm) return say('Usage: /leave <slot>', 'warn'); return leaveRoom(nm); }
2045
2796
  case '/rooms': case '/groups': return showRooms();
2046
2797
  case '/broadcast': case '/page':
2798
+ if (cmd === '/broadcast' && sleepIntent(restArg)) queueMicrotask(offerDnd); // never silently: it asks, after the card
2047
2799
  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
2800
  case '/ack': return runAck({ api, arg: restArg, slot: where.current.slot || null, say });
2049
2801
  case '/send': { // QUOXON-MESH: a local text file into a slot (inline, 64 KB cap, secrets scrubbed on a yes)
@@ -2078,6 +2830,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2078
2830
  return takeSlot(nm, take);
2079
2831
  }
2080
2832
  case '/attach': {
2833
+ const greetThis = takeGreeting(R.current); // read and clear first: an early return below must not leave a quiet flag armed for the next typed /attach
2081
2834
  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
2835
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2083
2836
  if (!api.agents) return say('Agents are not available in this mode.', 'warn');
@@ -2094,7 +2847,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2094
2847
  const m = pt.kind === 'slot' ? { matches: [] } : matchAgent(pt.name, list);
2095
2848
  const slotLive = !!(slotRow && slotRow.holder_session);
2096
2849
  let choice = null; // 'slot' | 'agent'
2097
- if (slotRow && m.agent) choice = pt.kind === 'slot' || slotLive ? 'slot' : 'agent';
2850
+ if (slotRow && m.agent) choice = pt.kind === 'slot' ? 'slot' : 'agent'; // a bare name that is both defaults to the AGENT; slot: is the explicit override
2098
2851
  else if (slotRow) choice = 'slot';
2099
2852
  else if (m.agent) choice = 'agent';
2100
2853
  if (!choice) {
@@ -2107,7 +2860,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2107
2860
  const both = !!(slotRow && m.agent);
2108
2861
  if (choice === 'slot') {
2109
2862
  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;
2863
+ SN.current.agent = null; SN.current.slotTarget = slotRow.name; DC.current.detach();
2111
2864
  where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
2112
2865
  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
2866
  const lbl = `slot ${slotRow.name}${host ? ` (${host})` : ''}`;
@@ -2119,11 +2872,20 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2119
2872
  }
2120
2873
  SN.current.slotTarget = null;
2121
2874
  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');
2875
+ DC.current.attach(m.agent.id, { greet: greetThis }).then(() => { const n = DC.current.takeArtNotice() || DC.current.narrowNotice({ cols, rows: termRows(stdout) ?? 24 }); if (n) say(n, 'warn'); });
2876
+ say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
2877
+ if (both && pt.kind !== 'agent') say(`${pt.name} is also a slot: /attach slot:${pt.name} for that`, 'warn');
2124
2878
  if (pt.rest) { dispatch({ type: 'user', text: pt.rest }); remember(pt.rest); runTurn(pt.rest); }
2125
2879
  return undefined;
2126
2880
  }
2881
+ case '/evidence': case '/ward': case '/volt': {
2882
+ for (const l of await runEvidenceCommand({ cmd, api, conversationId: S.current.ctx?.conversationId, cols })) say(l.text, l.tone);
2883
+ return undefined;
2884
+ }
2885
+ case '/envelope': case '/envelopes': case '/aee': { // pointers to /proof (the envelope ids) and /evidence (the bundle)
2886
+ say('Envelope ids are what /proof lists; /evidence exports them all as a signed bundle.', 'dim');
2887
+ return command(`/proof${restArg ? ` ${restArg}` : ''}`);
2888
+ }
2127
2889
  case '/peek': { // PEEK: the same lookup as `quox peek <id>`, in this window's org
2128
2890
  const pid = restArg.trim().split(/\s+/)[0] || '';
2129
2891
  if (!api.peek) return say('Peek is not available in this mode. In a terminal: quox peek <id>', 'warn');
@@ -2180,6 +2942,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2180
2942
  if (!api.connections) return say('Connections are not available in this mode.', 'warn');
2181
2943
  const [vault, caps] = await Promise.all([api.connections(), api.capabilities ? api.capabilities() : null]);
2182
2944
  for (const [t, tone] of connectionLines(vault, caps)) say(clip(t, cols - 4), tone);
2945
+ // LAZY-AI-CLI: no Claude Code on this server = the Claude sign-in cannot work yet; offer it as a y/N card here
2946
+ if (api.providerCliStatus && missingTools(await api.providerCliStatus()).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
2183
2947
  return undefined;
2184
2948
  }
2185
2949
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
@@ -2229,6 +2993,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2229
2993
  return undefined;
2230
2994
  }
2231
2995
  let r = selectModel(res, restArg, caps);
2996
+ // LAZY-AI-CLI: the Claude sign-in runs through the claude binary; no binary on this server = offer to install it now
2997
+ if ((r.choice?.via === 'subscription' || /^(claude-cli|claude-subscription|subscription|claude-code)\b/i.test(String(restArg).trim())) && api.providerCliStatus) {
2998
+ const st = await api.providerCliStatus();
2999
+ if (missingTools(st).includes('claude-code')) { R.current.installQueue = []; return offerInstall('claude-code'); }
3000
+ }
2232
3001
  let installed = null;
2233
3002
  if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
2234
3003
  r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
@@ -2256,6 +3025,49 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2256
3025
  return undefined;
2257
3026
  }
2258
3027
  case '/row': return RW.current.handle(restArg);
3028
+ case '/qpe': { // TUI-QPE: how many proof engine rows show (a CLI setting)
3029
+ const r = qpeCommand(restArg, api.settings?.() || {});
3030
+ if (r.save != null) { api.saveSetting?.('qpeRows', r.save); bump(n => n + 1); }
3031
+ return say(r.text, r.tone);
3032
+ }
3033
+ case '/deck': { // provided by the quoxdeck extension when loaded; otherwise says where it comes from (the registry entry carries ext)
3034
+ if (await extDispatch(cmd, restArg)) return undefined;
3035
+ const dw = restArg.trim().split(/\s+/)[0];
3036
+ if (dw && dw !== 'on') return say('/deck comes from the quoxdeck extension, which is not loaded. /deck on loads it.', 'warn');
3037
+ const lr = await DK.current.load('quoxdeck'); // LAZY PATH: not loaded -> find out why and OFFER (y/N), never a raw build error
3038
+ if (lr.ok) { extAdd('quoxdeck'); say(lr.text, 'ok'); return undefined; }
3039
+ const offer = await deckOffer({ isInstalled: async () => !lr.notFound });
3040
+ if (!offer) return say(lr.text, 'warn');
3041
+ if (!offer.steps) return say(offer.text, 'warn');
3042
+ R.current.deckAsk = { offer, at: Date.now() };
3043
+ return ask(offer.question, { action: offer.steps.map(s => s.cmd).join(' && '), reason: 'sudo, if shown, asks for your own password in this terminal' });
3044
+ }
3045
+ case '/ext': { // EXTENSIONS: /ext list|on|off|tab|set|settings <name>
3046
+ const parts = restArg.trim().split(/\s+/).filter(Boolean);
3047
+ const [w, nm] = parts;
3048
+ const list = readExtList();
3049
+ if (!w || w === 'list') {
3050
+ if (!list.length) return say('No extensions listed. Install one (npm i -g <pkg>) and list it: quox ext add <pkg>.');
3051
+ return say(list.map(x => `${x.name}: ${DK.current.isLoaded(x.name) ? `on (found: ${DK.current.where(x.name)})` : DK.current.failure(x.name) ? `failed: ${DK.current.failure(x.name)}` : x.enabled ? 'listed, not loaded' : 'off'}`).join('\n'));
3052
+ }
3053
+ if ((w === 'on' || w === 'off') && nm) {
3054
+ if (w === 'on') { if (!list.some(x => x.name === nm)) extAdd(nm); else extSetEnabled(nm, true); await extLoad(nm, false); return undefined; }
3055
+ extSetEnabled(nm, false); const r = DK.current.unload(nm); return say(r.text, 'ok');
3056
+ }
3057
+ if (w === 'tab') { // focus a panel; off collapses; alone cycles
3058
+ if (nm === 'off') { DK.current.hidePanel(); return say('Extension panel closed.'); }
3059
+ if (!nm) { const n = DK.current.cycle(); return n ? say(`${n} panel focused. Esc returns to the composer.`, 'ok') : say('No extension has a panel. /ext list', 'warn'); }
3060
+ const r = DK.current.focusPanel(nm); return say(r.text, r.ok ? 'ok' : 'warn');
3061
+ }
3062
+ if (w === 'settings' && nm) {
3063
+ const r = DK.current.settingsInfo(nm); if (!r.ok) return say(r.text, 'warn');
3064
+ return say(r.defs.map(d => `${d.key} = ${JSON.stringify(r.values[d.key])} (${d.type}${d.choices ? `: ${d.choices.join('|')}` : ''}, default ${JSON.stringify(d.default)}) ${d.help}`).join('\n'));
3065
+ }
3066
+ if (w === 'set' && nm && parts.length >= 4) {
3067
+ const r = DK.current.setSetting(nm, parts[2], parts.slice(3).join(' ')); return say(r.text, r.ok ? 'ok' : 'warn');
3068
+ }
3069
+ return say('Usage: /ext list | on|off <name> | tab [<name>|off] | settings <name> | set <name> <key> <value>', 'warn');
3070
+ }
2259
3071
  case '/watch': return KC.current.watch(restArg);
2260
3072
  case '/automate': return KC.current.automate(restArg);
2261
3073
  case '/crystallize': return KC.current.crystallize(restArg);
@@ -2277,9 +3089,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2277
3089
  case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': case '/fleet': case '/prove': case '/replay': return undefined; // answered above (runObjectCommand)
2278
3090
  case '/agent': case '/agents': return undefined; // answered above (picker, tools, card)
2279
3091
  case '/which': return showWhich(arg);
2280
- case '/detach':
3092
+ case '/detach': {
3093
+ const hadAgent = Boolean(SN.current.agent);
2281
3094
  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.');
3095
+ DC.current.detach();
3096
+ say('Back to CommanderQ.');
3097
+ const g = detachGreeting({ settings: api.settings?.() || {}, env, rows: termRows(stdout), chromeH: chromeH.current ?? 10, hadAgent }); // dragons on: the Q dragon greets the return
3098
+ if (g?.clip) { playDragon(g.clip, undefined); bump(n => n + 1); } else if (g?.note) say(g.note, 'dim');
3099
+ return true;
3100
+ }
2283
3101
  case '/sessions': {
2284
3102
  // SESSIONS (owner 2026-10-15): name and where first; ids only with `refs`.
2285
3103
  const flags = new Set(String(restArg || '').toLowerCase().split(/\s+/).filter(Boolean));
@@ -2345,18 +3163,63 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2345
3163
  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
3164
  if (cmd === '/gripe') { // ask first: filing makes a public-ish issue
2347
3165
  R.current.pendingGripe = { text, at: Date.now() };
2348
- say(`File this as a GitHub issue via GRIPE? "${clip(text, Math.max(20, cols - 50))}"`);
3166
+ const nl = text.split('\n').length;
3167
+ say(`${gripeLead} File this as a GitHub issue via GRIPE? "${clip(text.split('\n')[0], Math.max(20, cols - 50))}"${nl > 1 ? ` (+${nl - 1} more lines, all of it is filed)` : ''}`);
2349
3168
  return say('/gripe! files it · /gripe <new text> replaces it · anything else leaves it unfiled', 'dim');
2350
3169
  }
2351
3170
  R.current.pendingGripe = null;
2352
3171
  say('Filing via GRIPE…', 'dim');
2353
3172
  const g = await api.gripe(text.slice(0, 4000), { yes: true });
2354
- if (!g.ok) return say(`Gripe not filed: ${g.message}`, 'warn');
3173
+ if (!g.ok && g.queued) return say(`${g.saved || "Saved; I'll file it when Quox answers again (/gripes shows the queue)"} (${clip(g.message, 60)})`, 'warn');
3174
+ if (!g.ok) return say(gripeFailedLine(text, g.message), 'warn');
2355
3175
  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
3176
  for (const n of g.notes || []) {
2357
- if (n.kind === 'created') say(`✔ GRIPE │ issue filed │ ${n.url}`, 'ok');
3177
+ if (n.kind === 'created') { say(gripeFiledLine(n.url, text)); say(n.url, 'dim'); }
2358
3178
  else say(clip(n.text, cols * 3), n.kind === 'warn' ? 'warn' : 'dim');
2359
3179
  }
3180
+ if (g.url) gripeSweep.current(); // Quox just answered: file anything saved earlier
3181
+ return undefined;
3182
+ }
3183
+ case '/outbox': { // SLOT-OUTBOX: messages saved while QuoxCORE did not answer
3184
+ const [sub, ...rest] = restArg.trim().split(/\s+/).filter(Boolean);
3185
+ const rows = () => OB.current.list({ profile });
3186
+ if (!sub) {
3187
+ const list = rows();
3188
+ if (!list.length) return say('Outbox is empty. A /say that fails because QuoxCORE is not answering waits here and is sent when it answers.', 'dim');
3189
+ say(`Outbox: ${list.length} message${list.length === 1 ? '' : 's'}. /outbox send retries now, /outbox drop N removes one.`, 'dim');
3190
+ list.forEach((it, i) => {
3191
+ const t = new Date(it.created_at); const hm = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
3192
+ const st = it.status === 'queued' ? '⏸ queued' : it.status === 'sending' ? '◐ sending' : it.status === 'unsure' ? '⚠ may already be delivered (/outbox send retries)' : `✖ refused (${it.error || 'refused'})`;
3193
+ say(`${i + 1}. ${it.slot} · ${hm} │ ${clip(String(it.text).replace(/\s+/g, ' '), Math.max(20, cols - 60))} │ ${st}`, it.status === 'queued' ? 'dim' : 'warn');
3194
+ });
3195
+ return undefined;
3196
+ }
3197
+ if (sub === 'drop') {
3198
+ const n = Number(rest[0]);
3199
+ if (!Number.isInteger(n) || n < 1) return say('Usage: /outbox drop N (N is the number /outbox shows)', 'warn');
3200
+ const it = OB.current.drop(n, { profile });
3201
+ return say(it ? `Dropped the message to ${it.slot} (${clip(String(it.text).replace(/\s+/g, ' '), 40)}); it will not be sent.` : `No message ${n} in the outbox. /outbox lists them.`, it ? 'ok' : 'warn');
3202
+ }
3203
+ if (sub === 'send') {
3204
+ if (!rows().length) return say('Outbox is empty.', 'dim');
3205
+ const r = await flushOutbox({ explicit: true });
3206
+ if (r.busy) return say('A send is already in progress.', 'dim');
3207
+ if (r.pending > 0) say(`QuoxCORE is still not answering (${r.pending} queued). They will be sent when it does.`, 'warn');
3208
+ else if (!r.sent.length && !r.failed.length) say('Nothing to send.', 'dim');
3209
+ return undefined;
3210
+ }
3211
+ return say('Usage: /outbox | /outbox drop N | /outbox send', 'warn');
3212
+ }
3213
+ case '/gripes': {
3214
+ if (!api.gripes) return say('/gripes is not available in this mode.', 'warn');
3215
+ const rows = api.gripes();
3216
+ if (!rows.length) return say('No queued or recent gripes. /gripe! files one.', 'dim');
3217
+ const queued = rows.filter(g => g.status === 'queued').length;
3218
+ say(`Gripes: ${queued} waiting to be filed, ${rows.length - queued} recent`, 'dim');
3219
+ for (const g of rows.slice(-12)) {
3220
+ const where = g.status === 'filed' ? g.url : g.status === 'queued' ? 'queued, will retry' : g.status === 'expired' ? 'not filed (gave up after 24 h)' : `not filed (${g.error || 'refused'})`;
3221
+ say(`${g.status === 'filed' ? '✔' : '◐'} ${clip(g.text, Math.max(20, cols - 70))} │ ${where}`, g.status === 'filed' ? 'ok' : 'warn');
3222
+ }
2360
3223
  return undefined;
2361
3224
  }
2362
3225
  case '/context':
@@ -2444,8 +3307,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2444
3307
  if (!m) return say(DRAGON_USAGE);
2445
3308
  if (m.mode === 'explain') return dragonExplain();
2446
3309
  if (m.mode === 'ask') return dragonAsk(m.question);
3310
+ if (m.mode === 'companion') {
3311
+ const r = companionCommand(m.arg, { settings: api.settings?.() || {}, plan: DC.current.plan(), artProblem: DC.current.artProblem() });
3312
+ if (r.save != null) { api.saveSetting?.('dragonCompanion', r.save); DC.current.refresh(); }
3313
+ say(r.text, r.tone);
3314
+ if (r.invoke && await DC.current.invoke()) bump(n => n + 1); // asking about the companion is also calling it
3315
+ return true;
3316
+ }
2447
3317
  if (m.mode === 'sleep') return dragonSleep();
2448
3318
  if (m.mode === 'fly') return dragonFly();
3319
+ if (m.mode === 'pick') { // plain /dragon: the ATTACHED agent's own dragon when it has one, else the classic Quox dragon
3320
+ const shown = await DC.current.invoke();
3321
+ if (shown) { bump(n => n + 1); return true; }
3322
+ const who = SN.current.agent;
3323
+ if (who && !dragonForAgent(who)) say(`${who[0].toUpperCase()}${who.slice(1)} has no dragon of its own; here's Quox.`, 'dim');
3324
+ }
3325
+ const room = clipRoom(m.mode === 'idle' ? 'classic' : m.mode, termRows(stdout), chromeH.current ?? 10); if (!room.ok) return say(room.note, 'warn');
2449
3326
  playDragon(m.mode, undefined); bump(n => n + 1); return true;
2450
3327
  }
2451
3328
  case '/dragon-fly': return arg ? say('Usage: /dragon-fly', 'warn') : dragonFly();
@@ -2484,7 +3361,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2484
3361
  case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
2485
3362
  case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
2486
3363
  case '/clear': case '/new': case '/reset': {
2487
- if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
3364
+ if (S.current.turn || R.current.waitingServer) stopTurnNow(); // cancel it exactly as Esc does, so busy and the divider reset
2488
3365
  const prev = S.current.ctx.conversationId;
2489
3366
  dispatch({ type: 'ctx', ctx: { conversationId: null } });
2490
3367
  SN.current.name = null; SN.current.saved = null;
@@ -2493,6 +3370,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2493
3370
  setEpoch(e => e + 1);
2494
3371
  return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
2495
3372
  }
3373
+ case '/stop': case '/cancel': { // same as Esc
3374
+ if (!S.current.turn && !R.current.waitingServer) return say('Nothing is running.', 'dim');
3375
+ stopTurnNow();
3376
+ return say('Stopped.', 'ok');
3377
+ }
2496
3378
  case '/aocl': return dispatch({ type: 'aocl.table' });
2497
3379
  case '/verbose':
2498
3380
  if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
@@ -2556,11 +3438,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2556
3438
  const query = (restArg.match(/^\S+\s+(.*)$/) || [])[1]?.trim() || '';
2557
3439
  let list;
2558
3440
  try { list = await api.orgs(); } catch (e) { return say(`Couldn't list your orgs: ${listFailure(e, profile)}`, 'warn'); }
3441
+ const openOrgPicker = (rowsIn, title, { confirm = false } = {}) => { // confirm: the explicit `/org switch` chooser keeps its y/N
3442
+ const orows = orgRows(rowsIn);
3443
+ const ow = Math.min(30, Math.max(4, ...orows.map(x => [...x.name].length)));
3444
+ PK.current.open({ title, 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)),
3445
+ hint: confirm ? '↑↓ select · Enter switch (you confirm first) · Esc cancel' : 'Enter: switch this window only · d: also make it the default · ↑↓ select · Esc cancel',
3446
+ onChar: confirm ? undefined : (ch, i) => { if (ch !== 'd' && ch !== 'D') return false; switchOrgNow(orows[i], { quick: true, makeDefault: true }); PK.current.close(); return true; },
3447
+ onPick: (_, i) => (confirm ? (orows[i].id === curId ? say(`Already in ${orows[i].name}.`) : command(`/org switch ${orows[i].slug || orows[i].id}`)) : switchOrgNow(orows[i], { quick: true })) });
3448
+ };
2559
3449
  if (!sub || sub === 'list' || (sub === 'switch' && !query)) { // a bare /org switch offers the chooser, never a Usage line
2560
3450
  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}`)) });
3451
+ openOrgPicker(list, 'Move this session to an org', { confirm: sub === 'switch' });
3452
+ return undefined;
3453
+ }
3454
+ if (!['yes', 'back', '-', 'switch'].includes(sub)) { // /org <name or slug fragment>: a unique match switches this window, else the picker opens filtered
3455
+ const q = restArg.trim();
3456
+ const m = matchOrg(q, list);
3457
+ if (m.org) return switchOrgNow(m.org, { quick: true });
3458
+ const part = orgCandidates(q, list); // verb stripped, best-matching token: never a dead end on a typo
3459
+ if (!part.length) return say(`No org of yours matches "${q}". /org lists them.`, 'warn');
3460
+ openOrgPicker(part, `Orgs matching "${q}"`);
2564
3461
  return undefined;
2565
3462
  }
2566
3463
  if (sub === 'yes') {
@@ -2568,16 +3465,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2568
3465
  if (!t) return say('Nothing to confirm. /org switch <name> first.', 'warn');
2569
3466
  if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
2570
3467
  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');
3468
+ return switchOrgNow(t);
2581
3469
  }
2582
3470
  if (sub === 'back' || sub === '-') { // owner 2026-10-15 "switch back": no "type /org switch <the org you were in>"
2583
3471
  const p = R0.prevOrg;
@@ -2599,6 +3487,71 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2599
3487
  if (R.current.preConfirmed === 'org') { R.current.preConfirmed = null; return command('/org yes'); }
2600
3488
  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
3489
  }
3490
+ case '/todo': case '/todos': {
3491
+ if (!api.todoAdd || !api.todoList) return say('This server has no Action Tracker.', 'warn');
3492
+ const t = parseTodo(cmd, restArg);
3493
+ try {
3494
+ if (t.kind === 'usage') return say(t.text, 'warn');
3495
+ if (t.kind === 'add') return say(todoAddedLine(await api.todoAdd(t.text)), 'ok');
3496
+ if (t.kind === 'move') {
3497
+ if (!api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
3498
+ const board = await api.todoList();
3499
+ const many = (t.refs || [t.ref]).length > 1;
3500
+ for (const ref of (t.refs || [t.ref])) {
3501
+ const r = resolveRef(ref, TODO_MAP.current, board);
3502
+ if (r.error) { say(r.error, 'warn'); continue; }
3503
+ if (r.note) say(r.note);
3504
+ const moved = await api.todoMove(r.id, t.to);
3505
+ say(`${todoMovedLine({ ...moved, title: moved.title || r.title })}${many ? '' : ' /todos shows the new numbers.'}`, 'ok');
3506
+ }
3507
+ return undefined;
3508
+ }
3509
+ const board = await api.todoList();
3510
+ const numbered = numberBoard(board);
3511
+ TODO_MAP.current = numbered.map;
3512
+ return say(todoBoardText(board, numbered));
3513
+ } catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
3514
+ }
3515
+ case '/done': {
3516
+ if (!api.todoList || !api.todoMove) return say('This server cannot move Action Tracker items.', 'warn');
3517
+ const d = parseDone(restArg);
3518
+ if (d.kind === 'usage') return say(d.text, 'warn');
3519
+ try {
3520
+ const board = await api.todoList();
3521
+ for (const ref of d.refs) {
3522
+ const r = resolveRef(ref, TODO_MAP.current, board);
3523
+ if (r.error) { say(r.error, 'warn'); continue; }
3524
+ if (r.note) say(r.note);
3525
+ const moved = await api.todoMove(r.id, 'done');
3526
+ say(todoDoneLine(moved.title || r.title), 'ok');
3527
+ }
3528
+ return undefined;
3529
+ } catch (e) { return say(`Action Tracker: ${e?.message || 'failed'}`, 'warn'); }
3530
+ }
3531
+ case '/callmeforapprovals': case '/callme': {
3532
+ if (!api.callMe) return say('This server has no call-me setting.', 'warn');
3533
+ try {
3534
+ const { data, changed } = await api.callMe(parseCallMeSlashArgs(restArg));
3535
+ const lines = callMeStatusLines(data);
3536
+ return say(`${changed ? 'Updated. ' : ''}${lines.join('\n')}`);
3537
+ } catch (e) { return say(`Call me for approvals: ${e?.headline || e?.message || 'failed'}`, 'warn'); }
3538
+ }
3539
+ case '/plugin': case '/extensions': case '/extension': case '/addons': case '/apps': case '/plugins': {
3540
+ if (!api.pluginList) return say('This server has no plugin list.', 'warn');
3541
+ const want = /^(list|ls|all)?$/i.test(restArg.trim()) ? '' : restArg.trim().split(/\s+/)[0]; // "/extension list" is just the list
3542
+ try {
3543
+ if (want) {
3544
+ const one = (api.pluginGet ? await api.pluginGet(want) : null) || (await api.pluginList()).find(r => r.plugin_id === want || r.id === want);
3545
+ if (!one) return say(`No installed plugin named "${want}". /plugins lists them.`, 'warn');
3546
+ const on = one.is_enabled !== false && one.is_enabled !== 0;
3547
+ return say([`${one.plugin_id || want} ${on ? 'enabled' : 'disabled'} v${one.version || '?'}`, ...(one.installed_by ? [`installed by ${one.installed_by}${one.installed_at ? ` on ${String(one.installed_at).slice(0, 10)}` : ''}`] : []), ...(one.upgraded_at ? [`upgraded ${String(one.upgraded_at).slice(0, 10)}`] : [])].join('\n'));
3548
+ }
3549
+ const list = await api.pluginList();
3550
+ if (!list.length) return say('No plugins installed for this org.');
3551
+ const w = Math.max(...list.map(r => String(r.plugin_id || '').length));
3552
+ return say([`${list.length} plugin${list.length === 1 ? '' : 's'} (extensions). /plugins <name> for detail:`, ...list.map(r => ` ${String(r.plugin_id || '?').padEnd(w)} ${r.is_enabled === false || r.is_enabled === 0 ? 'disabled' : 'enabled '} v${r.version || '?'}`)].join('\n'));
3553
+ } catch (e) { return say(`Couldn't list plugins: ${e?.message || 'failed'}`, 'warn'); }
3554
+ }
2602
3555
  case '/skills': {
2603
3556
  if (!api.skills) return say('This server has no skills list.', 'warn');
2604
3557
  try {
@@ -2631,6 +3584,36 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2631
3584
  }
2632
3585
  return submit(skillPrompt(arg, argRest));
2633
3586
  }
3587
+ case '/loop': { // QLOOP-LIVE: list | watch <id> | peek <id> | focus
3588
+ const [sub0, ...restW] = String(restArg || '').trim().split(/\s+/).filter(Boolean);
3589
+ const lid = restW[0];
3590
+ if (!sub0 || sub0 === 'list') {
3591
+ const rows = (R.current.roster.allRows || []).filter(r => r.kind === 'loop');
3592
+ if (!rows.length) return say('No loops running or recent. Ask Quox to run something until it is done, and say yes to the loop offer.');
3593
+ for (const r of rows) say(`∞ ${r.id} │ ${r.name} │ ${r.state}${r.activity ? `, ${r.activity}` : ''}${LC.current.get(r.id) ? ' (card on screen)' : ''}`, r.state === 'failed' ? 'error' : undefined);
3594
+ return say('/loop watch <id> puts a live card in this chat. /loop peek <id> prints one snapshot.');
3595
+ }
3596
+ if (sub0 === 'focus') { const c = LC.current.focus(lid || null); return c ? say(`Keys on the loop card: Enter expands the output, s halts (y confirms), Esc leaves.`) : say('No loop card on screen. /loop watch <id> starts one.', 'warn'); }
3597
+ if (sub0 === 'watch') {
3598
+ if (!lid) return say('Usage: /loop watch <id>', 'warn');
3599
+ if (!api.loopEvents) return say('/loop watch is not available in this mode.', 'warn');
3600
+ const known = (R.current.roster.allRows || []).find(r => r.id === lid);
3601
+ const had = LC.current.get(lid);
3602
+ LC.current.watch(lid, { title: known?.name || '' });
3603
+ LC.current.focus(lid);
3604
+ return had ? say('That loop already has a card above; it has focus. Enter expands, s halts, Esc leaves.') : undefined; // the card's own last line lists the keys
3605
+ }
3606
+ if (sub0 === 'peek') {
3607
+ if (!lid) return say('Usage: /loop peek <id>', 'warn');
3608
+ try {
3609
+ const snap = await api.loopSnapshot(lid);
3610
+ const st = applyLoopSnapshot(initLoop({ id: lid, title: snap?.title || '' }), snap);
3611
+ for (const rs of cardLines(st, { cols: Math.max(40, cols - 4) })) say(rs.map(x => x.t).join(''));
3612
+ } catch (e) { say(`loop ${lid}: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`, 'warn'); }
3613
+ return undefined;
3614
+ }
3615
+ return say('Usage: /loop list | watch <id> | peek <id> | focus', 'warn');
3616
+ }
2634
3617
  case '/roster': {
2635
3618
  const ro = R.current.roster;
2636
3619
  if (arg === 'all') {
@@ -2643,6 +3626,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2643
3626
  for (const src of ro.sources || []) {
2644
3627
  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
3628
  }
3629
+ for (const src of ro.sources || []) {
3630
+ if (src.id === 'agents' && src.state === 'connected') for (const l of attachLines(src.items, Date.now())) say(l);
3631
+ if (src.id === 'boxsessions' && src.state === 'connected') for (const l of sessionLines(src.items)) say(l, /x\d/.test(l) ? 'warn' : undefined);
3632
+ }
2646
3633
  if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
2647
3634
  return undefined;
2648
3635
  }
@@ -2669,17 +3656,65 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2669
3656
  return dispatch({ type: 'object', obj: { type: 'sitrep', data: sitrepData(r.situation, r.approvals) } });
2670
3657
  }
2671
3658
  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 || '' };
3659
+ if (afkMeansSleep(restArg)) { await sleepNow(); return command('/dragon sleep'); } // owner 2026-10-29: "/afk going to bed" is /sleep
3660
+ const at = Date.now(); AWAY.current.last = at; AWAY.current.afk = { at, note: restArg || '', dnd: true }; AWAY.current.col ||= newCollected();
2673
3661
  try { away?.write?.(profile, at); } catch { /* the in-memory mark still works for this window */ }
2674
3662
  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);
3663
+ const pr = await setPresence('away', restArg, { dnd: true });
2676
3664
  if (!pr.ok) presenceFail(pr);
2677
3665
  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');
3666
+ offerDndToo();
3667
+ return undefined;
3668
+ }
3669
+ case '/donotdisturb': case '/dnd': {
3670
+ const p = parseDndArgs(restArg);
3671
+ if (!p.ok) return say(p.error, 'warn');
3672
+ // owner 2026-10-29: a bare /dnd never turns DND off. The old toggle read /afk's away mark as "DND on" and reported "off" on the first press.
3673
+ if (p.op === 'toggle' && AWAY.current.afk?.explicit) return say('Do not disturb is already on. /dnd off turns it off, /back ends it.', 'dim');
3674
+ if (p.op === 'off') return AWAY.current.afk ? dndOff() : say('Do not disturb is off.', 'dim');
3675
+ return dndOn(p.until);
3676
+ }
3677
+ case '/morningreport': {
3678
+ // The report is about the most recent DND/away window (start to end, or now if still on), not the current on/off state.
3679
+ const live = AWAY.current.afk ? { since: AWAY.current.col?.since, col: AWAY.current.col } : null;
3680
+ const win = live || AWAY.current.lastWindow || (AWAY.current.col ? { since: AWAY.current.col.since, col: AWAY.current.col } : null);
3681
+ const held = (c) => Boolean(c && ((c.inbox?.length || 0) + (c.slots?.length || 0)));
3682
+ if (!win || (!AWAY.current.afk && !held(win.col))) { // nothing held in the last window: same since-last-seen digest as /away, honest note first
3683
+ say(win ? 'Nothing was held in your last do not disturb window. Here is what happened since you were last here.' : 'Nothing was held: do not disturb was off. Here is what happened since you were last here.', 'dim');
3684
+ return command('/away');
3685
+ }
3686
+ const col = win.col;
3687
+ const approvals = Math.max(S.current.notify?.approvals || 0, [...S.current.approvals, ...S.current.waiting].length);
3688
+ const sessions = (R.current.roster.allRows || []).filter((x) => x.state === 'waiting').map((x) => String(x.name || x.key || 'a session')).slice(0, 8);
3689
+ const hm = (ms) => new Date(ms).toISOString().slice(11, 16);
3690
+ say(`🌙 Morning report${win.since ? ` since ${hm(win.since)} UTC` : ''}${win.until ? ` until ${hm(win.until)} UTC` : ''}`, 'ok');
3691
+ for (const [t, tone] of morningLines(col || newCollected(), { approvals, sessions })) say(t, tone);
3692
+ if (api.away && win.since) { try { dispatch({ type: 'object', obj: { type: 'away', data: await api.away(new Date(win.since).toISOString()) } }); } catch { /* the local digest above stands */ } }
3693
+ if (AWAY.current.afk) {
3694
+ AWAY.current.col = newCollected();
3695
+ R.current.dndAsk = { at: Date.now(), off: true }; say(askText('Good morning. Turn off do not disturb?', { action: 'end do not disturb and show approvals again (same as /dnd off)' }), 'ask');
3696
+ }
2678
3697
  return undefined;
2679
3698
  }
2680
- case '/back': { // PRESENCE: present on the server too, then the digest
2681
- AWAY.current.afk = null;
2682
- const pr = await setPresence('present');
3699
+ case '/back': { // PRESENCE: present on the server too, then the digest (also ends do not disturb)
3700
+ // Presence is owner-level: if another window's first act already ended this away, the server's since/until win.
3701
+ let ended = null;
3702
+ if (AWAY.current.afk && api.getPresence) {
3703
+ try {
3704
+ const sp = await api.getPresence();
3705
+ const ea = sp?.ok && sp.presence && sp.presence.away !== true ? sp.presence.ended_away : null;
3706
+ if (ea && Date.parse(ea.until) >= AWAY.current.afk.at - 60000) ended = ea;
3707
+ } catch { /* unreadable: fall through to this window's own marks */ }
3708
+ }
3709
+ archiveWindow();
3710
+ AWAY.current.afk = null; AWAY.current.col = null;
3711
+ if (ended && api.away) {
3712
+ say(`✔ Welcome back, you were already marked present at ${new Date(ended.until).toISOString().slice(11, 16)} UTC (${ended.via || 'activity'} in another window).`, 'ok');
3713
+ try { dispatch({ type: 'object', obj: { type: 'away', data: { ...(await api.away(ended.since)), endedAt: ended.until } } }); }
3714
+ catch (e) { say(`away: could not read the digest (${e?.status ? `HTTP ${e.status}` : e?.message || 'failed'}). Nothing is assumed.`, 'warn'); }
3715
+ return undefined;
3716
+ }
3717
+ const pr = await setPresence('present', undefined, { human: true });
2683
3718
  if (!pr.ok) presenceFail(pr);
2684
3719
  return command('/away');
2685
3720
  }
@@ -2689,7 +3724,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2689
3724
  if (!api.slotSay) return say('Slots are not available in this mode.', 'warn');
2690
3725
  const r = await api.slotSay('quoxon', restArg.trim(), { from: myLabel(), fast: true, fromSlot: where.current.slot || null });
2691
3726
  if (!r.ok) return say(`observe: not delivered (${r.message})`, 'warn');
2692
- return say('✔ noted for the observer (quoxon)', 'ok');
3727
+ return say(`✔ noted for the observer (quoxon${r.viaOrgName ? ` in ${r.viaOrgName}` : ''})`, 'ok');
2693
3728
  }
2694
3729
  case '/poke': case '/wake': case '/nudge': case '/ping': case '/slap': case '/awaken': { // owner tried all five on 2026-10-15
2695
3730
  const m = parsePokeArgs(restArg); // FORCE-WAKE: --force is a no-op, never page text
@@ -2720,16 +3755,94 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2720
3755
  catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
2721
3756
  return undefined;
2722
3757
  }
3758
+ case '/inbox': { // HITL-INBOX-BAR: the owner's Quox inbox, in the chat
3759
+ if (!api.inboxItems) return say('The inbox is not available in this mode.', 'warn');
3760
+ const q = parseInbox(restArg);
3761
+ if (q.kind === 'usage') return say(INBOX_USAGE, 'warn');
3762
+ let items;
3763
+ try { items = await api.inboxItems({ limit: 30 }); }
3764
+ catch (e) { return dispatch({ type: 'error', text: `Could not read the inbox (${e.message}). That does not mean it is empty; try again or run: quox inbox list` }); }
3765
+ const top = newestFirst(items).slice(0, 10);
3766
+ R.current.inboxList = top; R.current.inboxSeen = arrivals(items, R.current.inboxSeen || null).ids;
3767
+ dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
3768
+ if (q.kind === 'mutes') { for (const l of mutesLines(inboxMutes())) say(l.text, l.tone); return undefined; }
3769
+ if (q.kind === 'mute' || q.kind === 'unmute') {
3770
+ const key = q.source.toLowerCase(); const next = { ...inboxMutes() };
3771
+ if (q.kind === 'mute') next[key] = Date.now() + q.ms; else if (!next[key]) return say(`${q.source} is not muted. ${mark('/inbox mutes')} lists them.`, 'warn'); else delete next[key];
3772
+ if (api.saveSetting) api.saveSetting('inboxMutes', next); else (R.current.sessionSettings ||= {}).inboxMutes = next;
3773
+ dispatch({ type: 'notify', patch: { inboxBar: inboxBarText(items, cols, barOpts()) } });
3774
+ return say(q.kind === 'mute' ? `✔ muted ${q.source} for ${Math.round(q.ms / 360000) / 10}h: no arrival lines, out of the bar count, still listed in /inbox. Approvals are never muted. ${mark(`/inbox unmute ${q.source}`)} undoes it.` : `✔ unmuted ${q.source}`, 'ok');
3775
+ }
3776
+ const pick = (n) => { const it = top[n - 1]; if (!it) say(`No item ${n}: the list has ${top.length}. ${mark('/inbox')} shows it.`, 'warn'); return it; };
3777
+ if (q.kind === 'list') { for (const l of inboxTable(items, Date.now(), cols, 10, inboxMutes())) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
3778
+ if (q.kind === 'read' || q.kind === 'unread') {
3779
+ const markRead = q.kind === 'read';
3780
+ let targets;
3781
+ if (q.all) { // "all" is ALL unread, paged through the API, not just the 10 listed
3782
+ let every; try { every = api.inboxAllItems ? await api.inboxAllItems() : await api.inboxItems({ limit: 100 }); } catch (e) { return dispatch({ type: 'error', text: `Could not read the whole inbox (${e.message}). Nothing was marked ${q.kind}.` }); }
3783
+ targets = markRead ? unreadItems(every) : readItems(every);
3784
+ } else targets = (q.ns || [q.n]).map(pick).filter(Boolean);
3785
+ if (!targets.length) return q.all ? say(markRead ? 'Nothing unread.' : 'Nothing read to mark unread.', 'dim') : undefined;
3786
+ let ok = 0; const bad = []; let next = 0;
3787
+ const worker = async () => { while (next < targets.length) { const it = targets[next++]; try { await api.inboxMarkRead(it.id, markRead); ok++; } catch (e) { bad.push(e?.message || 'failed'); } } };
3788
+ await Promise.all(Array.from({ length: Math.min(5, targets.length) }, worker));
3789
+ if (bad.length) say(`Marked ${ok} ${q.kind}; ${bad.length} failed (${clip(bad[0], 60)}).`, 'warn'); else say(`✔ marked ${ok} ${q.kind}`, 'ok');
3790
+ refreshInboxBar({ force: true });
3791
+ return undefined;
3792
+ }
3793
+ const it = pick(q.n); if (!it) return undefined;
3794
+ if (q.kind === 'open' && isApprovalItem(it)) {
3795
+ let flat = []; try { flat = groupApprovals(await api.approvals(), { conversationId: S.current.ctx.conversationId, cardIds: [] }).flatMap((g) => g.items.map((x) => x.item.id)); } catch { /* falls to /approvals below */ }
3796
+ const at = flat.indexOf(it.id);
3797
+ return command(at >= 0 ? `/approvals ${at + 1}` : '/approvals');
3798
+ }
3799
+ let full = it; try { full = (await api.inboxItem(it.id)) || it; } catch (e) { say(`Could not load the full item (${clip(e?.message || 'failed', 60)}); showing the list row.`, 'warn'); }
3800
+ for (const l of inboxDetail(full, Date.now(), q.n)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
3801
+ refreshInboxBar({ force: true });
3802
+ return undefined;
3803
+ }
3804
+ case '/mail': { // TUI-MAIL: triaged InboxQ mail, read only (same reads as `quox mail list`)
3805
+ if (!api.mailMessages) return say('Mail is not available in this mode.', 'warn');
3806
+ const q = parseMail(restArg);
3807
+ if (q.kind === 'usage') return say(MAIL_USAGE, 'warn');
3808
+ let list;
3809
+ try { list = mailNewestFirst(await api.mailMessages({ limit: 30, zone: q.zone })).slice(0, 10); }
3810
+ catch (e) { return dispatch({ type: 'error', text: `Could not read your mail (${e.message}). That does not mean it is empty; try again or run: quox mail list` }); }
3811
+ if (!list.length && !q.zone) {
3812
+ let accts = null; try { accts = await api.mailAccounts(); } catch { /* unknown: fall through to the plain empty line */ }
3813
+ if (accts && !accts.length) { for (const l of noMailboxLines()) say(l.text, l.tone); return undefined; }
3814
+ }
3815
+ if (q.kind === 'list') { for (const l of mailTable(list, Date.now(), cols, 10)) say(l.text, l.tone === 'accent' ? 'warn' : l.tone); return undefined; }
3816
+ const m = list[q.n - 1]; if (!m) return say(`No message ${q.n}: the list has ${list.length}. ${mark('/mail')} shows it.`, 'warn');
3817
+ let full = m; try { full = (await api.mailMessage(m.id)) || m; } catch (e) { say(`Could not load the full message (${clip(e?.message || 'failed', 60)}); showing the list row.`, 'warn'); }
3818
+ for (const l of mailDetail(full, Date.now(), q.n)) say(l.text, l.tone === 'accent' ? 'ok' : l.tone === 'text' ? undefined : l.tone);
3819
+ return undefined;
3820
+ }
2723
3821
  case '/approvals': {
2724
3822
  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')})`);
3823
+ 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 ${mark('/approvals auto on|off')} open new ones by themselves (privileged: ${mark('/approve N confirm')})`);
2726
3824
  return undefined;
2727
3825
  }
3826
+ if (/^auto\b/i.test(restArg.trim())) { // APPROVALS-AUTO: the real verb behind "/approvals instant-interrupt"
3827
+ const a = parseApprovalsArg(restArg);
3828
+ if (a.kind !== 'auto') return say(`Unknown option "${restArg.trim()}" for /approvals. Use ${mark('/approvals auto on')} or ${mark('/approvals auto off')}.`, 'warn');
3829
+ const st = AO.current.st;
3830
+ if (a.set === null) return say(`New approvals open by themselves: ${st.enabled ? 'on' : 'off'}. ${mark(`/approvals auto ${st.enabled ? 'off' : 'on'}`)} changes it.`);
3831
+ st.enabled = a.set;
3832
+ const saved = api.saveSetting ? api.saveSetting('approvalsAuto', a.set) : false;
3833
+ return say(a.set ? `✔ New approvals and questions now open by themselves as a card (after your draft or turn, never mid-typing). Nothing is ever answered for you.${saved ? '' : ' (Not saved to this profile: this window only.)'}` : `✔ New approvals no longer open by themselves; the ⏸ line and ${mark('/approvals')} still show them.${saved ? '' : ' (Not saved to this profile: this window only.)'}`, 'ok');
3834
+ }
2728
3835
  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
3836
  let stats = null; let statsErr = null;
2732
- if (api.approvalStats) { try { stats = await api.approvalStats(); } catch (e) { statsErr = e.message; } }
3837
+ // the list and the org-wide count are independent reads: ask together (the first decision waited on both in turn)
3838
+ const statsP = api.approvalStats ? api.approvalStats().then((v) => { stats = v; }, (e) => { statsErr = e.message; }) : Promise.resolve();
3839
+ // approvals waiting in the user's OTHER orgs: a count and the switch command, never their contents
3840
+ let moreOrgs = [];
3841
+ const moreP = api.otherOrgApprovals ? api.otherOrgApprovals().then((v) => { moreOrgs = crossOrgTuiLines(v); }, () => {}) : Promise.resolve();
3842
+ try { items = await api.approvals(); }
3843
+ catch (e) { await statsP; 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` }); }
3844
+ await Promise.all([statsP, moreP]);
3845
+ const sayMoreOrgs = () => { for (const l of moreOrgs) say(l, 'warn'); };
2733
3846
  const cardIds = [...S.current.approvals, ...S.current.waiting].map(x => x.inboxItemId).filter(Boolean);
2734
3847
  const groups = groupApprovals(items, { conversationId: S.current.ctx.conversationId, cardIds });
2735
3848
  const flat = groups.flatMap(g => g.items.map(x => x.item));
@@ -2737,9 +3850,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2737
3850
  R.current.shownIds = flat.map(x => x.id);
2738
3851
  dispatch({ type: 'notify', patch: { approvals: flat.length, error: null } });
2739
3852
  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
3853
  const fArg = restArg.trim();
2742
- if (/^walkthrough$/i.test(fArg)) return walkStart(flat, { fresh: true });
3854
+ { const pk = parseApprovalsArg(fArg, { items: flat }); if (pk.kind === 'unknown') return say(`Unknown option "${fArg}" for /approvals. Options: ${APPROVALS_OPTIONS}.${/instant|interrupt|pop|auto/i.test(fArg) ? ` To have new ones open by themselves: ${mark('/approvals auto on')}.` : ''}`, 'warn'); }
3855
+ if (!flat.length && !hidden && !statsErr) { say('Nothing waiting for you.', 'ok'); sayMoreOrgs(); return undefined; }
3856
+ if (/^walkthrough$/i.test(fArg)) { // plain approvals walk as cards too, after any session questions
3857
+ const plain = flat.filter(x => !isDecision(x));
3858
+ if (plain.length && plain.length === flat.length) say(`No session questions; ${plain.length} approval${plain.length === 1 ? '' : 's'} waiting, starting with the first.`, 'warn');
3859
+ sayMoreOrgs();
3860
+ return walkStart(flat.map(approvalAsWalkItem), { fresh: true });
3861
+ }
2743
3862
  if (/^\d+$/.test(fArg)) { // /approvals N: everything the item carries
2744
3863
  const it = flat[Number(fArg) - 1];
2745
3864
  if (!it) return say(`No such item: [${fArg}] (${flat.length} listed).`, 'warn');
@@ -2766,6 +3885,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2766
3885
  }
2767
3886
  if (flat.length && !shown.size) say(`Nothing matches "${fArg}".`, 'warn');
2768
3887
  if (flat.length) say(' ');
3888
+ if (shown.size && !(nDec && !fArg)) R.current.listShownAt = Date.now(); // a list, not a card: a bare y/n right after it has no target
2769
3889
  if (nDec && !fArg) walkStart(flat, { fresh: true, quiet: true });
2770
3890
  if (hidden) {
2771
3891
  const others = humanProfiles.filter(h => h !== profile);
@@ -2773,6 +3893,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2773
3893
  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
3894
  if (agentLogin) offerSwitch(); // one keypress instead of quit and relaunch
2775
3895
  }
3896
+ sayMoreOrgs();
2776
3897
  if (statsErr) say(`Could not check the org-wide approval count (${statsErr}); the list above is what the inbox returned.`, 'warn');
2777
3898
  return undefined;
2778
3899
  }
@@ -2786,7 +3907,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2786
3907
  }
2787
3908
  case '/mouse': {
2788
3909
  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.`);
3910
+ if (!v) return say(`Mouse is ${MO.current.on ? 'on' : 'off'}. /mouse ${MO.current.on ? 'off' : 'on'} to change it.${MO.current.on ? ` ${MOUSE_HINT}` : ''}`);
2790
3911
  if (v !== 'on' && v !== 'off') return say('Usage: /mouse on|off', 'warn');
2791
3912
  MO.current.on = v === 'on'; MO.current.drag = null;
2792
3913
  api.saveSetting?.('mouse', v);
@@ -2810,16 +3931,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2810
3931
  return say(m.text, m.tone || undefined);
2811
3932
  }
2812
3933
  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);
3934
+ await sleepNow();
2817
3935
  return command('/dragon sleep');
2818
3936
  }
3937
+ case '/broadcast-active': return command(`/broadcast --active${restArg ? ` ${restArg}` : ''}`);
2819
3938
  case '/roar': return command(`/broadcast${restArg ? ` ${restArg}` : ''}`);
2820
3939
  case '/soar': return command(`/fleet${restArg ? ` ${restArg}` : ''}`);
2821
3940
  case '/summon': return command(`/attach${restArg ? ` ${restArg}` : ''}`);
2822
3941
  case '/huh': return command(`/dragon explain${restArg ? ` ${restArg}` : ''}`); // owner 2026-10-15: shortcut for /dragon explain
3942
+ case '/devservers': { // DEVFLEET: list the dev servers. Handing the terminal to a remote session from inside the TUI is not done here: print the exact command.
3943
+ if (!api.devServers) return say('/devservers is not available in this mode.', 'warn');
3944
+ let rows; try { rows = await api.devServers(); } catch (e) { return say(`Couldn't list servers: ${e?.message || 'failed'}`, 'warn'); }
3945
+ if (!rows.length) return say('No dev servers registered. In a terminal: quox server mark <host> --purpose "main dev"');
3946
+ const pick = restArg.trim();
3947
+ if (pick) {
3948
+ const r = rows.find(x => x.name === pick);
3949
+ if (!r) return say(`No dev server "${pick}". /devservers lists them.`, 'warn');
3950
+ return say(`To open a Quox window on ${r.name}, run this in a terminal (the TUI does not hand over the terminal yet):\nquox server go ${r.name}`);
3951
+ }
3952
+ const lines = rows.map(r => `${r.current ? '*' : ' '} ${r.name} ${r.purpose || '-'} ${r.project || '-'} ${r.reachable ? 'reachable' : 'unreachable'}`);
3953
+ return say(`${lines.join('\n')}\n* this server. /devservers <name> shows the command to open a Quox window there (quox server go <name>).`);
3954
+ }
2823
3955
  case '/slots': return listSlots(/^all$/i.test(restArg.trim()));
2824
3956
  case '/mirror': {
2825
3957
  // The Mirror itself: this org's server-built Mirror, what CommanderQ is told every turn (not /mind).
@@ -2836,6 +3968,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2836
3968
  if (/\d{4,}/.test(restArg)) return say('Never type your PIN after a command. It is asked for in a private card.', 'warn');
2837
3969
  await SUC.current.authenticate(); bump(n => n + 1); return undefined;
2838
3970
  }
3971
+ case '/verify-email': return SUC.current.verifyEmail(restArg); // the TUI front of `quox admin users verify-email`: preview, y/N, hidden PIN card, same endpoint
2839
3972
  case '/override': {
2840
3973
  const res = await SUC.current.override(restArg);
2841
3974
  if (res?.override_id && res.enforced) R.current.override = { id: res.override_id, until: Date.parse(res.expires_at) || Date.now() + 300000 };
@@ -2849,7 +3982,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2849
3982
  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
3983
  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
3984
  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');
3985
+ else if (needs.length) return say(`${needs.length} items need your PIN, so say which: ${needs.map(k => mark(`/authorise ${k}`)).join(' ')}`, 'warn');
3986
+ else if (li.length === 1) authArg = '1'; // DS-010: one thing is waiting; the server decides if it needs the PIN, and /approve asks for it
3987
+ else if (li.length) return say(`${li.length} items are waiting and none is marked as needing a PIN up front, so say which to approve with your PIN: ${li.slice(0, 5).map((_, k) => mark(`/authorise ${k + 1}`)).join(' ')}${li.length > 5 ? ' ...' : ''} (the server asks for the PIN on anything an agent raised)`, 'warn');
3988
+ else return say(`Nothing is waiting for your approval, so there is nothing to authorise. ${mark('/approvals')} lists what is open. /authenticate opens a human-present window ahead of time.`, 'warn');
2853
3989
  }
2854
3990
  R.current.authorising = true; // /approve's answer() asks for the PIN first when there is no live step-up
2855
3991
  try { await command(`/approve ${authArg}`); } finally { R.current.authorising = false; }
@@ -2898,12 +4034,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2898
4034
  const okIds = []; const failedK = [];
2899
4035
  for (const k of pa.ns) {
2900
4036
  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 });
4037
+ const go = () => { ownerActed('answer'); return api.answerDecision(x.id, keyOf(k), { note: pa.note || null, stepUp: SU.current.headers('approve'), bulk: bulkAns }); };
2902
4038
  try {
2903
4039
  try { await go(); }
2904
4040
  catch (e) { if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e; SU.current.drop('approve'); await askPin(); await go(); }
2905
4041
  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');
4042
+ say(`✔ answered [${k}] ${x.payload.session || 'session'}: ${clip(decodeEntities(x.title || x.id), Math.max(20, cols - 40))} -> (${keyOf(k)})`, 'ok');
2907
4043
  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
4044
  } catch (e) { failedK.push(`[${k}] ${e?.status === 404 || e?.status === 400 ? 'already handled or expired' : e.message}`); if (e?.stepUpDeclined) break; }
2909
4045
  }
@@ -2921,7 +4057,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2921
4057
  }
2922
4058
  const it = list[pa.n - 1];
2923
4059
  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');
4060
+ if (!isDecision(it)) { // DS-010 / #1191: a yes or no on an approval goes to /approve or /deny, which asks for the PIN itself
4061
+ if (pa.key === 'y') return command(`/approve ${pa.n}`);
4062
+ if (pa.key === 'n') return command(`/deny ${pa.n}${pa.note ? ` ${pa.note}` : ''}`);
4063
+ return say(`[${pa.n}] is an approval, not a question. Use ${mark(`/approve ${pa.n}`)} or ${mark(`/deny ${pa.n} <reason>`)}.`, 'warn');
4064
+ }
2925
4065
  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
4066
  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
4067
  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
@@ -2930,7 +4070,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2930
4070
  const subject = { line1: `answer ${String(it.title || "")}`, raisedBy: it.payload.session || null, risk: 'high' };
2931
4071
  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
4072
  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') });
4073
+ const go = () => { ownerActed('answer'); return api.answerDecision(it.id, pa.key, { note: pa.note || null, stepUp: SU.current.headers('approve') }); };
2934
4074
  try { await go(); }
2935
4075
  catch (e) {
2936
4076
  if (!(e?.status === 403 && e?.body?.error === 'step_up_required')) throw e;
@@ -2943,7 +4083,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2943
4083
  return dispatch({ type: 'error', text: `answer: ${e.message}` });
2944
4084
  }
2945
4085
  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');
4086
+ say(`✔ answered [${pa.n}] ${it.payload.session || 'session'}: ${clip(decodeEntities(it.title || it.id), Math.max(20, cols - 40))} -> (${pa.key}) ${decodeEntities(it.payload.options.find(o => o.key === pa.key).label)}${pa.note ? ` (note: ${clip(pa.note, 40)})` : ''}`, 'ok');
2947
4087
  say(' ');
2948
4088
  R.current.lastAnswered = it.id; walkAnsweredIds().add(it.id);
2949
4089
  if (it.payload.slot && api.slotSay) { // WALK-FIXES: the answer is already recorded; the slot post never holds up the next card
@@ -2995,12 +4135,19 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
2995
4135
  if (!targets.length) return say(`Nothing waiting from "${agent}". ${mark('/approvals agents')} shows who is asking.`, 'warn');
2996
4136
  }
2997
4137
  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 = [];
4138
+ const bulkish = Boolean(all || agent || targets.length > 1); const heldRisky = []; const heldDel = [];
2999
4139
  const heldDecisions = []; const done = []; const heldPriv = []; const failed = []; const missing = []; const gone = [];
3000
4140
  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
4141
  for (const k of targets) {
3002
4142
  const it = list[k - 1];
3003
4143
  if (!it) { missing.push(k); continue; }
4144
+ if (verb === 'approve' && isDestructive(it)) { // a delete is never part of a bulk yes, and a single one needs its name typed
4145
+ if (bulkish) { heldDel.push(k); continue; }
4146
+ if (R.current.delBypass !== it.id) {
4147
+ startDelConfirm(destructiveObject(it), async () => { R.current.delBypass = it.id; try { const n2 = S.current.inbox.findIndex(x => x.id === it.id) + 1; await command(`${cmd} ${n2 || k}`); } finally { R.current.delBypass = null; } });
4148
+ continue;
4149
+ }
4150
+ }
3004
4151
  if (isDecision(it)) {
3005
4152
  if (all || agent) { heldDecisions.push(k); continue; } // a bulk approve never answers a human question for you
3006
4153
  if (verb === 'approve' && bulkish && isHeldFromBulk(it)) { heldRisky.push(k); continue; } // risky asks are never part of a bulk yes
@@ -3011,8 +4158,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3011
4158
  try {
3012
4159
  const dsc = describeInboxItem(it); // APPROVAL-WHAT: record the real action and target, not a clipped title
3013
4160
  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}`); }
4161
+ if (r?.gone) gone.push(k); else if (r?.ok) { done.push(k); R.current.lastAnswered = it.id; }
4162
+ } catch (e) {
4163
+ failed.push(`[${k}] ${e.message}`);
4164
+ if (e?.stepUpDeclined) { const rest = targets.slice(targets.indexOf(k) + 1).filter(j => list[j - 1]); if (rest.length) failed.push(`${rest.map(j => `[${j}]`).join(' ')} not tried: the PIN was not given, so nothing more was sent`); break; } // DS-010: one PIN ask per batch, a "no" ends it
4165
+ }
3016
4166
  }
3017
4167
  if (done.length || gone.length || failed.length || missing.length) say(' ');
3018
4168
  for (const k of done) say(`${verb === 'approve' ? '✔ approved' : '✔ denied'} ${label(k)}${reason ? ` (reason: ${clip(reason, 40)})` : ''}`, 'ok');
@@ -3033,13 +4183,14 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3033
4183
  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
4184
  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
4185
  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 };
4186
+ 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 => approvalAsWalkItem(list[k - 1])) : null };
3037
4187
  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
4188
  }
3039
4189
  if (walkKs.length) {
3040
4190
  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
4191
+ if (!ynHeld.length) walkStart(walkKs.map(k => approvalAsWalkItem(list[k - 1]))); // with a bulk card up, the walkthrough starts once the card is answered
3042
4192
  }
4193
+ if (heldDel.length) say(`${heldDel.length} delete${heldDel.length === 1 ? '' : 's'} left for you to confirm one by one: ${heldDel.map(k => `${cmd} ${k}`).join(', ')}`, 'warn');
3043
4194
  if (heldPriv.length && (all || agent)) say(`${heldPriv.length} privileged left: ${heldPriv.map(k => `${cmd} ${k} confirm`).join(', ')}`, 'warn');
3044
4195
  else if (heldPriv.length) { // ask inline instead of sending the user to a second command
3045
4196
  R.current.pendingGrant = { cmd, verb, items: heldPriv.map(k => list[k - 1]), at: Date.now() };
@@ -3053,7 +4204,9 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3053
4204
  return undefined;
3054
4205
  }
3055
4206
  default: {
4207
+ if (await extDispatch(cmd, restArg)) return undefined;
3056
4208
  const e = findEntry(cmd);
4209
+ if (e?.ext) return say(`${e.usage.split(/\s/)[0]} comes from the ${e.ext} extension, which is not loaded. npm i -g ${e.ext}, then quox ext add ${e.ext}, or /ext on ${e.ext}.`, 'warn');
3057
4210
  if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
3058
4211
  if (!e && await slotNameCommand(cmd, restArg)) return undefined;
3059
4212
  const cliHit = resolveCliSlash(cmd, restArg); // QUOXON-FW: slash words that name a real quox CLI leaf
@@ -3066,7 +4219,8 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3066
4219
  if (!near.length) return say(`Unknown command ${cmd}. Try /help`, 'warn');
3067
4220
  // spelling-only hints keep the plain "/a or /b" line; keyword hints say what each command does
3068
4221
  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');
4222
+ const viaAlias = (x) => { const en = findEntry(x.name); return en && en.id !== x.name.slice(1) ? `${x.name} (${en.id})` : x.name; }; // an alias names the command it runs
4223
+ say(`Unknown command ${cmd}. Did you mean ${byWords ? near.map(x => (x.desc ? `${x.name} (${x.desc})` : x.name)).join(', ') : near.map(viaAlias).join(' or ')}? (/help lists them)`, 'warn');
3070
4224
  if (near.length === 1) { // one strong match: offer it as a y/N card (vetted, never a human-typed command)
3071
4225
  const v = vetSuggestion(near[0].name);
3072
4226
  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.' }); }
@@ -3098,10 +4252,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3098
4252
  function openAskStates() {
3099
4253
  const fresh = (x) => (x && Date.now() - (x.at ?? 0) < ASK_TTL ? x : null);
3100
4254
  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);
4255
+ return [walkCardSt, ...[R.current.wakeAsk, R.current.pendingGrant, R.current.feedbackAsk, R.current.slotAsk, R.current.slotCreateAsk, R.current.sayLastAsk, R.current.profileAsk, R.current.loginAsk, R.current.neverAsk, R.current.dndAsk, R.current.secretAsk, R.current.limitedAsk, R.current.cliAsk, R.current.deckAsk, R.current.installAsk, 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.objectiveAsk, 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
4256
  }
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?.(); }
4257
+ const askOpen = () => openAskStates().length > 0 || KC.current.pending?.() || SUC.current.pending() || UW.current.pending?.() || runAskOpen();
4258
+ function touchAsks() { for (const x of openAskStates()) x.at = Date.now(); KC.current.touch?.(); SUC.current.touch(); UW.current.touch?.(); }
3105
4259
  // ASK-SETTLE (owner 2026-10-17: "questions should go once answered, or change to answer"): each live ask card is
3106
4260
  // bound to the open state it asks about; once that state is no longer open the card goes and ONE line stays:
3107
4261
  // "✔ Wake it now? yes (00:44)", "⌛ ... expired, not answered", "↷ ... no longer open". Run requests likewise.
@@ -3131,6 +4285,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3131
4285
  if (askStateOpen(st)) continue;
3132
4286
  bind.delete(cd.id);
3133
4287
  const expired = st && typeof st === 'object' ? now - (st.at ?? 0) >= ASK_TTL : false;
4288
+ if (expired && st === SC.current.reattach && st.slot) R.current.declinedSlot = st.slot; // an unanswered reattach counts as a decline across the auto-restart of this window
3134
4289
  if (st && typeof st === 'object' && st.walkWhy) { // a walkthrough card: a normal end says nothing (the summary does), an interruption says so
3135
4290
  takeAnswer();
3136
4291
  if (st.walkWhy === 'stop') dispatch({ type: 'ask.settle', id: cd.id, text: 'The question closed before you answered.', tone: 'plain' });
@@ -3168,6 +4323,12 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3168
4323
  if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
3169
4324
  if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
3170
4325
  let buf = B.current;
4326
+ if (R.current.delConfirm) { // DESTRUCTIVE-CONFIRM: this line is the typed name (a slash command leaves the step)
4327
+ const t = buf.text.trim();
4328
+ if (!t.startsWith('/')) { B.current = initialBuffer(); bump(n => n + 1); delConfirmLine(t); return; }
4329
+ R.current.delConfirm = null;
4330
+ }
4331
+ if (!buf.text.trim() && R.current.secretAsk) { B.current = { ...initialBuffer(), text: 'y', cursor: 1 }; buf = B.current; } // SECRET-GUARD: Enter alone keeps the credentials out (never "send anyway")
3171
4332
  // y/N/c: Enter alone answers an open ask "no" (the default), as typing n does.
3172
4333
  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
4334
  if (!buf.text.trim() && !R.current.denyReason && !S.current.turn && askOpen()) { B.current = { ...initialBuffer(), text: 'n', cursor: 1 }; buf = B.current; }
@@ -3183,7 +4344,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3183
4344
  if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
3184
4345
  let text = chk.text.trim();
3185
4346
  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'); }
4347
+ if (wrapped && !GRIPE_LINE.test(text)) { text = wrapped.text; say(`joined ${wrapped.lines} lines`, 'dim'); }
4348
+ if (SUC.current.pending() && !text.startsWith('/')) { // the /verify-email y/N preview owns the next plain line: never chat, never a turn, never history (a PIN typed early must go nowhere)
4349
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.histIdx = -1;
4350
+ if (/^(y(es)?|no?)$/i.test(text)) SUC.current.answer(text);
4351
+ else { SUC.current.touch(); say('Answer y or n for /verify-email (n cancels). Nothing was sent. The PIN is asked for in a private card after y, never here.', 'warn'); }
4352
+ bump(n => n + 1); return;
4353
+ }
3187
4354
  const keyHit = looksLikeKey(text);
3188
4355
  if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
3189
4356
  if (text.startsWith('!') && text.includes('\n')) { // owner 2026-10-15: a wrapped `!git ... push origin\nHEAD:main` ran as two commands
@@ -3192,6 +4359,29 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3192
4359
  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
4360
  bump(n => n + 1); return;
3194
4361
  }
4362
+ // QUOXON-SECRET-GUARD: credential-shaped text is never sent, saved or remembered without an explicit "send anyway"
4363
+ const sa = R.current.secretAsk;
4364
+ if (sa) {
4365
+ const ans = askAnswer(text);
4366
+ if (ans === 'yes' || ans === 'no') {
4367
+ R.current.secretAsk = null; R.current.askAnswer = { kind: ans, at: Date.now() }; queueMicrotask(() => reconcileAsks());
4368
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.histIdx = -1;
4369
+ if (ans === 'yes' && Date.now() - sa.at < 120000) { say('Not sent. Nothing was saved anywhere.', 'ok'); bump(n => n + 1); return; }
4370
+ if (ans === 'no') { R.current.secretOk = sa.text; if (sa.text.startsWith('/') && sa.text.includes('\n')) R.current.confirmMulti = true; B.current = { ...initialBuffer(), text: sa.text, cursor: sa.text.length }; bump(n => n + 1); onEnter(now); return; }
4371
+ say('That question expired, so nothing was sent. Paste it again if you still mean to.', 'dim'); bump(n => n + 1); return;
4372
+ }
4373
+ R.current.secretAsk = null; // anything else moves on; the new line is checked on its own below
4374
+ }
4375
+ if (R.current.secretOk === text) R.current.secretOk = null;
4376
+ else {
4377
+ R.current.secretOk = null;
4378
+ const kinds = secretKinds(text);
4379
+ if (kinds.length) {
4380
+ B.current = initialBuffer(); R.current.confirmMulti = null; R.current.secretAsk = { at: Date.now(), text };
4381
+ say(askText(`This looks like credentials (${kinds.join(', ')}). Don't send it?`, { keys: 'y keeps it out (recommended) · n send anyway', action: 'discard it, nothing is sent or saved', title: 'Credentials in your message' }), 'ask');
4382
+ bump(n => n + 1); return;
4383
+ }
4384
+ }
3195
4385
  const bang = parseBang(text);
3196
4386
  if (bang) { // QUOXON-FW8: the user's own command on their own machine; never a turn, never the server
3197
4387
  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; }
@@ -3199,16 +4389,61 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3199
4389
  echoCmd(text); bangCommand(bang, { echoed: true }); bump(n => n + 1); return;
3200
4390
  }
3201
4391
  const s = S.current;
3202
- const isCommand = text.startsWith('/') && !text.includes('\n');
4392
+ const isCommand = isOneLineCommand(text);
3203
4393
  const ws = R.current.wokeSwallowed; R.current.wokeSwallowed = null;
3204
4394
  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
4395
  B.current = { ...initialBuffer(), text, cursor: text.length };
3206
4396
  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
4397
  }
4398
+ if (R.current.dndAsk) { // DND offers: "Turn on do not disturb until morning?" and "Good morning. Turn off do not disturb?"
4399
+ const a = R.current.dndAsk;
4400
+ const ans = askAnswer(text);
4401
+ if (ans === 'yes' || ans === 'no') {
4402
+ R.current.dndAsk = null; R.current.askAnswer = { kind: ans, at: Date.now() }; queueMicrotask(() => reconcileAsks());
4403
+ B.current = initialBuffer(); R.current.histIdx = -1;
4404
+ if (ans === 'yes' && Date.now() - a.at < 600000) { (a.off ? dndOff() : dndOn()).catch(() => {}); return; }
4405
+ say(ans === 'yes' ? 'That offer expired. /dnd on turns it on.' : (a.off ? 'Staying on do not disturb. /dnd off ends it.' : 'Not now. /dnd on whenever you like.'), 'dim'); bump(n => n + 1); return;
4406
+ }
4407
+ if (isCommand) R.current.dndAsk = null;
4408
+ }
4409
+ if (R.current.neverAsk) { // ONBOARD-P2: "Quox at <url> has never answered. Connect to a different server? y/N", then the address
4410
+ const a = R.current.neverAsk;
4411
+ if (a.stage === 'offer') {
4412
+ const ans = askAnswer(text);
4413
+ if (ans === 'yes') {
4414
+ R.current.neverAsk = { ...a, stage: 'url', at: Date.now() };
4415
+ say(askText('Quox server address (for example https://192.0.2.10:3000)?', { keys: 'Enter or n cancels', action: 'check it is a Quox, then sign you in (the certificate is shown first)' }), 'ask'); return;
4416
+ }
4417
+ if (ans === 'no' || ans === 'chat') {
4418
+ R.current.neverAsk = null;
4419
+ say('Still trying quietly. To connect elsewhere any time, run: quox connect <url>', 'dim');
4420
+ if (healthRecheck) startupRecheck.current?.(0);
4421
+ return;
4422
+ }
4423
+ if (ans === 'command') R.current.neverAsk = null;
4424
+ } else {
4425
+ const t = text.trim();
4426
+ R.current.neverAsk = null;
4427
+ if (!t || /^no?$/i.test(t)) { say('Not connecting. To connect elsewhere any time, run: quox connect <url>', 'dim'); if (healthRecheck) startupRecheck.current?.(0); return; }
4428
+ if (!/^https?:\/\//i.test(t)) { say('That is not a web address. Run: quox connect <url> when you have it.', 'warn'); return; }
4429
+ say(`Connecting to ${t}…`, 'dim');
4430
+ onConnect(t); exit(); return;
4431
+ }
4432
+ }
4433
+ if (R.current.loginAsk) { // "Signed out of X. Sign in now? y/N": y is exactly /login; n or Enter alone dismisses; other chat falls to the signed-out refusal below
4434
+ const a = R.current.loginAsk;
4435
+ const ans = askAnswer(text);
4436
+ if (ans === 'yes' || ans === 'no') {
4437
+ R.current.loginAsk = null; R.current.askAnswer = { kind: ans === 'yes' ? 'yes' : 'no', at: Date.now() }; queueMicrotask(() => reconcileAsks());
4438
+ if (ans === 'yes' && Date.now() - a.at < 120000) { echoCmd('/login'); command('/login'); return; }
4439
+ say('Not now. /login signs in whenever you like.', 'dim'); return;
4440
+ }
4441
+ if (isCommand) R.current.loginAsk = null;
4442
+ }
3208
4443
  if (blocked && !isCommand) { // Z2H-R4: clear the box (else the next /login is appended and refused); Up recalls the message
3209
4444
  remember(text); R.current.histIdx = -1; B.current = initialBuffer(); say(blocked, 'warn'); bump(n => n + 1); return;
3210
4445
  }
3211
- if (text.startsWith('/') && text.includes('\n') && !R.current.confirmMulti) {
4446
+ if (text.startsWith('/') && text.includes('\n') && !isOneLineCommand(text) && !R.current.confirmMulti) {
3212
4447
  // commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter.
3213
4448
  // The second Enter sends it even after edits (R3 D3: requiring a byte-identical draft swallowed every later Enter).
3214
4449
  R.current.confirmMulti = true;
@@ -3236,13 +4471,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3236
4471
  let askDismissed = false; // a chat line that is not a question about the offer answers it by moving on: the card closes itself
3237
4472
  if (askOpen() && !R.current.noteAsk) {
3238
4473
  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'];
4474
+ const OWN = ['wakeAsk', 'pendingGrant', 'feedbackAsk', 'slotAsk', 'slotCreateAsk', 'sayLastAsk', 'profileAsk', 'loginAsk', 'neverAsk', 'dndAsk', 'secretAsk', 'limitedAsk', 'cliAsk', 'deckAsk', 'installAsk', 'updateAsk', 'orgPending', 'hatPending', 'suggestAsk', 'askQuox', 'planAsk'];
3240
4475
  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
4476
  if (o?.kind === 'dismissed' && foreign) { /* a card owned by another module keeps its own rules: the line is about it, as before */ }
3242
4477
  else if (o) {
3243
4478
  askDismissed = o.kind === 'dismissed';
3244
4479
  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
4480
+ if (askDismissed) { if (R.current.limitedAsk) { R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } }); } 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
4481
  }
3247
4482
  }
3248
4483
  // y/N/c: c or any other free text keeps the open ask and asks Quox about it first (never a silent cancel).
@@ -3254,8 +4489,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3254
4489
  say('Not sent. Your answer and note are back in the composer; press Enter to send it, or edit it first.', 'warn');
3255
4490
  return;
3256
4491
  }
4492
+ if (!R.current.pendingWalk && !askOpen() && !headCard(S.current) && strayAnswer(text, { queued: AO.current.st.queue.length, walkOpen: false, noticeAt: AO.current.noticeAt, now: Date.now() })) { autoStray(text); return; } // APPROVALS-AUTO: the stray y
3257
4493
  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
4494
  if (!askDismissed && !S.current.turn && !noAiNow() && askAnswer(text) === 'chat' && askOpen() && !(runAskOpen() && /^run$/i.test(text.trim()))) { chatAboutAsk(text); return; }
4495
+ if (SUC.current.answer(text)) return; // y/N after a /verify-email preview
3259
4496
  if (KC.current.answer(text)) return; // y/n after a /watch, /automate or /crystallize preview card
3260
4497
  if (UW.current.answer(text)) return; // y/n after a /undo or /wish card
3261
4498
  if (HW.current.active() && HW.current.answer(text)) return; // CUSTOM-HATS: the next line of a /hat new|edit|delete walk
@@ -3269,7 +4506,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3269
4506
  if (Date.now() - g.at < 120000 && !text.startsWith('/')) {
3270
4507
  if (/^y(es)?$/i.test(text.trim())) {
3271
4508
  (async () => {
3272
- if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it)); // defence: a risky ask never rides the bulk card
4509
+ if (g.decisions) g.items = g.items.filter(it => !isHeldFromBulk(it) && !isDestructive(it)); // defence: a risky ask or a delete never rides the bulk card
3273
4510
  if (g.decisions && g.hi) { // one PIN for the whole batch; the token then rides on every item
3274
4511
  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
4512
  if (!r.ok) return say(`Not answered: ${declinedText(r.reason)}. Nothing was sent.`, 'warn');
@@ -3316,6 +4553,17 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3316
4553
  if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { switchTo(a.name); return; }
3317
4554
  if (!text.startsWith('/')) { say(`Not switched. This window stays ${WHO.current.profile}.`, 'dim'); return; }
3318
4555
  }
4556
+ if (R.current.limitedAsk) { // "Switch to <model>? y/N" under the limited banner: y is exactly /model <name>; anything else dismisses it
4557
+ const a = R.current.limitedAsk; R.current.limitedAsk = null;
4558
+ if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { echoCmd(`/model ${a.id}`); command(`/model ${a.id}`); return; }
4559
+ R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } });
4560
+ if (!text.startsWith('/')) { say(`Staying on the current model. /model ${a.id} switches whenever you like.`, 'dim'); return; }
4561
+ }
4562
+ if (R.current.sayLastAsk) { // SAY-LAST-TARGET "Send to <slot>? y send · n cancel": y sends the whole text
4563
+ const a = R.current.sayLastAsk; R.current.sayLastAsk = null;
4564
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { sendToSlot(a.slot, a.text); return; }
4565
+ if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
4566
+ }
3319
4567
  if (R.current.slotCreateAsk) { // "No slot X. Create it? y/N/c" from a /slot near miss: y runs /slot X --create
3320
4568
  const a = R.current.slotCreateAsk; R.current.slotCreateAsk = null;
3321
4569
  if (askAnswer(text) === 'yes' && Date.now() - a.at < 120000) { const l = `/slot ${a.nm} --create`; echoCmd(l); command(l); return; }
@@ -3336,6 +4584,27 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3336
4584
  }
3337
4585
  if (!text.startsWith('/')) { say('Not updated. Nothing was changed.', 'dim'); return; }
3338
4586
  }
4587
+ if (R.current.deckAsk) { // LAZY PATH: "quoxdeck needs: ... Install now? y/N" (typing y is the authorization; sudo is the user's own)
4588
+ const a = R.current.deckAsk; R.current.deckAsk = null;
4589
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) {
4590
+ const run = (st) => new Promise((resolve) => {
4591
+ const hold = { deck: true }; R.current.bang = hold;
4592
+ setTimeout(() => {
4593
+ if (R.current.bang === hold) R.current.bang = null;
4594
+ const r = handOverRun({ cmd: st.cmd, env, cwd: process.cwd(), stdout, setRawMode: isRawModeSupported ? setRawMode : null, spawn: runSpawn, resume: mouseResume() });
4595
+ bump(n => n + 1); resolve({ code: r.error ? 1 : r.code, error: r.error });
4596
+ }, runHandOverDelayMs);
4597
+ });
4598
+ runDeckOffer(a.offer, { run: (st) => st.cmd === `quox ext add quoxdeck` ? Promise.resolve(extAdd('quoxdeck').ok ? { code: 0 } : { code: 1 }) : run(st), say: (t, tone) => say(t, tone || 'dim'), rerun: async () => { echoCmd('/deck on'); command('/deck on'); } }).catch(() => {});
4599
+ return;
4600
+ }
4601
+ if (!text.startsWith('/')) { say(declineText(a.offer), 'dim'); return; }
4602
+ }
4603
+ if (R.current.installAsk) { // LAZY-AI-CLI: "Claude Code isn't installed on this server. Install it now? y/N" (typing y is the authorization)
4604
+ const a = R.current.installAsk; R.current.installAsk = null;
4605
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { installNow(a); return; }
4606
+ if (!text.startsWith('/')) { say(`Not installed. Nothing was downloaded.`, 'dim'); nextInstallOffer(); return; }
4607
+ }
3339
4608
  if (R.current.cliAsk) { // QUOXON-FW: a slash word named a WRITE quox command: "Run it? y/N" (typing y is the authorization)
3340
4609
  const a = R.current.cliAsk; R.current.cliAsk = null;
3341
4610
  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; }
@@ -3356,6 +4625,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3356
4625
  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
4626
  if (!text.startsWith('/')) { say('Not run.', 'dim'); return; }
3358
4627
  }
4628
+ if (R.current.objectiveAsk) { // "Make this an objective that runs until done?" (yes or no): yes creates + starts it, no does nothing
4629
+ const a = R.current.objectiveAsk; R.current.objectiveAsk = null;
4630
+ if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runObjective(a); return; }
4631
+ if (!text.startsWith('/')) { say('No objective made.', 'dim'); return; }
4632
+ }
3359
4633
  if (R.current.handoffAsk) { // "Hand this question to CommanderQ?" (yes or no): yes asks it once, the attach stays
3360
4634
  const a = R.current.handoffAsk; R.current.handoffAsk = null;
3361
4635
  if (/^y(es)?$/i.test(text.trim()) && Date.now() - a.at < 120000) { runHandoff(a); return; }
@@ -3378,13 +4652,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3378
4652
  }
3379
4653
  if (SC.current.pendingFile) { // /send found secret-shaped values: "y/N" (send redacted)
3380
4654
  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; }
4655
+ 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) }), { draft: null }); return; }
3382
4656
  if (!text.startsWith('/')) { say('Not sent.', 'dim'); return; }
3383
4657
  }
3384
4658
  if (SC.current.reattach) { // startup "Reattach to slot X? y/N"
3385
4659
  const ra = SC.current.reattach; SC.current.reattach = null;
3386
4660
  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; }
4661
+ if (/^no?$/i.test(text.trim())) { R.current.declinedSlot = ra.slot; say('Left it.', 'dim'); return; }
3388
4662
  }
3389
4663
  if (SC.current.runAsk) { // SLOT-RUN: "Run it? y/N/c" (or "run" for a dangerous one) on a slot sender's command
3390
4664
  const ra = SC.current.runAsk;
@@ -3396,7 +4670,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3396
4670
  else if (v === 'needs-run') { say(`Not run yet. ${ra.danger}, so type run to run it, or n to skip.`, 'warn'); return; }
3397
4671
  else if (v === 'no') { SC.current.runAsk = null; transcript?.bang?.(ra.cmd, 'declined'); say('Not run.', 'dim'); return; }
3398
4672
  }
3399
- if (text.startsWith('/') && !text.includes('\n')) { remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4673
+ { const ls = R.current.listShownAt; R.current.listShownAt = null; // APPROVALS-UX: y/n right after a list (not a card) is not chat to the agent
4674
+ if (ls && Date.now() - ls < 600000 && /^(y(es)?|no?)$/i.test(text.trim())) { remember(text); R.current.histIdx = -1; say('Which one? /approve 1 or /approvals 1 to see it.', 'warn'); R.current.listShownAt = ls; return; } }
4675
+ if (isOneLineCommand(text)) { PL.current.submitted(buf.text); remember(text); R.current.histIdx = -1; echoCmd(text); command(text); return; } // Up recalls commands too
4676
+ if (!SC.current.active && BARE_COMMAND_WORDS.has(text.toLowerCase()) && findEntry('/' + text.toLowerCase())?.kind === 'command') { // owner 2026-10-26 18:22: "clear" went to the model as chat. Offer the command BEFORE anything is sent; n sends it as chat.
4677
+ const line = '/' + text.toLowerCase();
4678
+ remember(text); R.current.histIdx = -1;
4679
+ R.current.strayAsk = { text, command: line, at: Date.now() };
4680
+ ask(`Run ${line}?`, { action: line, reason: `"${text}" is a command word. n sends it to Quox as chat instead.` });
4681
+ return;
4682
+ }
4683
+ const attachSlot = !SC.current.active && !s.turn && api.slots ? parseSlotAttach(text) : null;
4684
+ if (attachSlot) { remember(text); R.current.histIdx = -1; offerSlotAttach(attachSlot, text); return; } // "attach to hitl slot": offer /slot hitl, no model turn
3400
4685
  const roomSend = api.slotSay ? parseRoomSend(text) : null;
3401
4686
  if (roomSend) { // QUOXON-ROOMS: #room text posts, bare #room makes it the default target
3402
4687
  remember(text); R.current.histIdx = -1;
@@ -3485,9 +4770,11 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3485
4770
  const now = Date.now();
3486
4771
  const idx = hit.inFrame ? composerIndexAt(hit.frameLines, hit.frameRow, e.x, B.current) : null;
3487
4772
  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; }
4773
+ const same = M.last && now - M.last.at < DOUBLE_MS && M.last.x === e.x && M.last.y === e.y;
4774
+ const n = same ? M.last.n + 1 : 1;
4775
+ M.last = n >= 3 ? null : { at: now, x: e.x, y: e.y, n };
4776
+ if (n === 2) { const w = wordAt(B.current.text, idx); edit({ type: 'select', anchor: w.from, cursor: w.to }); copySelection(); return; }
4777
+ if (n >= 3) { const l = lineAt(B.current.text, idx); edit({ type: 'select', anchor: l.from, cursor: l.to }); copySelection(); return; }
3491
4778
  edit({ type: 'place', index: idx }); K.current.composerClicked = true;
3492
4779
  M.drag = { anchor: B.current.cursor, cursorRow: hit.cursorRow ?? null };
3493
4780
  return;
@@ -3527,7 +4814,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3527
4814
  k.askEchoTimer = setTimeout(() => { endAskEcho(); bump(n => n + 1); }, ASK_ECHO_MS + 50); k.askEchoTimer.unref?.();
3528
4815
  bump(n => n + 1); return true;
3529
4816
  }
3530
- if (!askOpen() || R.current.pendingWalk || R.current.denyReason || HW.current.active?.()) return false;
4817
+ if (!askOpen() || R.current.pendingWalk || R.current.denyReason || R.current.delConfirm || HW.current.active?.()) return false;
3531
4818
  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
4819
  B.current = { ...initialBuffer(), text: ch, cursor: 1 };
3533
4820
  onEnter(now);
@@ -3554,6 +4841,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3554
4841
  const k = K.current;
3555
4842
  const g = k.guard;
3556
4843
  if (R.current.denyReason && Date.now() - R.current.denyReason.at < 120000) return false; // typing the deny reason: y/n are text
4844
+ if (R.current.delConfirm) return false; // typing the name to delete: every key is text
3557
4845
  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
4846
  const armedAt = Math.max(card.displayedAt ?? card.shownAt ?? 0, g?.since ?? 0);
3559
4847
  const ok = () => approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: k.lastKeyAt, heldKey: g?.heldKey, ch });
@@ -3564,6 +4852,13 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3564
4852
  return true; // any other key cancels
3565
4853
  }
3566
4854
  const key = ch.toLowerCase();
4855
+ if (signedOutRef.current) { if (key === 'y' && ok()) { echoCmd('/login'); command('/login'); } return key === 'y' || key === 'n' || key === 'c'; } // signed out: the card is read-only, y signs in
4856
+ const burst = batchFor(queueOf(S.current), card); // APPROVAL-BURST: y and n apply to the whole batch, p walks it
4857
+ if (burst && (key === 'y' || key === 'n' || key === 'p')) {
4858
+ if (!ok()) return true;
4859
+ if (key === 'p') walkBatch(burst); else answerBatch(burst, key === 'y' ? 'approve' : 'reject');
4860
+ return true;
4861
+ }
3567
4862
  if (key === 'c' && !S.current.turn && !noAiNow()) { // y/N/c: chat about the approval first; the card stays
3568
4863
  if (!ok()) return true;
3569
4864
  const what = card.what || [card.tool, card.target].filter(Boolean).join(' ') || 'this approval';
@@ -3573,9 +4868,10 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3573
4868
  if (key !== 'y' && key !== 'n') return false; // ordinary typing: the composer keeps it
3574
4869
  if (!ok()) return true; // too early / key repeat: swallowed, never answered
3575
4870
  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.
4871
+ if (key === 'n') { // ask why in one line; Enter skips, Esc backs out. The card stays until the answer is sent.
3577
4872
  R.current.denyReason = { card, at: Date.now() }; say('Reason? (Enter to skip)', 'warn'); bump(n => n + 1); return true;
3578
4873
  }
4874
+ if (card.destructive) { startDelConfirm(card.destructive, () => answer(card, 'approve')); return true; } // a delete is never one key
3579
4875
  if (card.privileged || card.undescribable) { k.confirm = true; bump(n => n + 1); return true; }
3580
4876
  answer(card, 'approve');
3581
4877
  return true;
@@ -3613,6 +4909,25 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3613
4909
  }
3614
4910
 
3615
4911
  /** COMPOSER-EDIT: cells the draft has per row (the composer box is `cols` wide, less the "› " prompt). */
4912
+ /** QUOXON-HISTORY-UP: still on the entry Up recalled (unedited): Up/Down jump whole entries, not rows of a long recalled message. */
4913
+ function browsingHistory() { return R.current.histIdx >= 0 && B.current.text === R.current.histText; }
4914
+ /** QUOXON-HISTORY-UP: Ctrl+R reverse search. The composer shows "(reverse-i-search) q: match"; Enter accepts, Esc cancels, Ctrl+R again = next older. */
4915
+ function rsShow() {
4916
+ const rs = R.current.rs; const hist = R.current.history; const q = rs.q.toLowerCase(); let n = rs.skip; rs.hit = null;
4917
+ for (let i = hist.length - 1; i >= 0; i--) if (hist[i].toLowerCase().includes(q) && n-- === 0) { rs.hit = hist[i]; break; }
4918
+ B.current = bufferReducer(initialBuffer(), { type: 'set', text: `(reverse-i-search) ${rs.q}: ${rs.hit == null ? '' : rs.hit.replace(/\s*\n\s*/g, ' ')}` }); bump(n2 => n2 + 1);
4919
+ }
4920
+ function rsEvent(e) {
4921
+ const rs = R.current.rs;
4922
+ if (e.type === 'key') {
4923
+ if (e.name === 'escape' || e.name === 'ctrl-c') { B.current = rs.saved; R.current.rs = null; bump(n => n + 1); return; }
4924
+ if (e.name === 'enter') { const hit = rs.hit; B.current = rs.saved; R.current.rs = null; if (hit != null) { R.current.histIdx = -1; edit({ type: 'set', text: hit }); } else bump(n => n + 1); return; }
4925
+ if (e.name === 'ctrl-r') { rs.skip++; return rsShow(); }
4926
+ if (e.name === 'backspace') { rs.q = rs.q.slice(0, -1); rs.skip = 0; return rsShow(); }
4927
+ return;
4928
+ }
4929
+ if (e.type === 'text' || e.type === 'paste') { rs.q += e.text.replace(/\s+/g, ' '); rs.skip = 0; rsShow(); }
4930
+ }
3616
4931
  function composerWidth() { return Math.max(1, cols - 2); }
3617
4932
  /** COMPOSER-EDIT: copy the composer selection to the clipboard with OSC 52 (works over SSH; needs a terminal that allows it). */
3618
4933
  function copySelection() {
@@ -3635,6 +4950,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3635
4950
  return;
3636
4951
  }
3637
4952
  if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
4953
+ if (LC.current.focused() && !s.approvals.length && !B.current.text && LC.current.key(name, e.text)) return; // QLOOP-LIVE: keys of a focused loop card
3638
4954
  if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
3639
4955
  // quick reply (owner 2026-10-15): Tab in an EMPTY composer pre-fills "/say <last sender> " (before the palette, which owns Tab otherwise)
3640
4956
  if (name === 'tab' && !B.current.text && R.current.lastSender && !headCard(s)) { edit({ type: 'set', text: `/say ${R.current.lastSender.toLowerCase()} ` }); return; }
@@ -3646,6 +4962,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3646
4962
  if (R.current.askArmed) { R.current.askArmed = false; R.current.dragonTalk = null; say('Dragon ask cancelled.', 'dim'); return; }
3647
4963
  if (HW.current.cancel()) return; // CUSTOM-HATS: Esc ends the hat walk, nothing sent
3648
4964
  if (RW.current.walk.cancel()) return; // ROWS: Esc ends the row walk, nothing saved
4965
+ if (R.current.limitedAsk && !B.current.text) { R.current.limitedAsk = null; R.current.limitedDismissed = true; dispatch({ type: 'notify', patch: { providerLines: R.current.limitedBase || [] } }); return; } // Esc says no to the switch offer
3649
4966
  if (R.current.pendingWalk) { walkEnd('Esc'); K.current.lastEscAt = now; return; } // WALK-FIXES: Esc leaves the walkthrough with a summary; the draft stays
3650
4967
  if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
3651
4968
  if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
@@ -3663,7 +4980,15 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3663
4980
  case 'delete': return edit({ type: 'delete' });
3664
4981
  case 'delete-word': return edit({ type: 'deleteWordBack' });
3665
4982
  case 'kill-line': return edit({ type: 'killLineBack' });
3666
- case 'left': case 'right': case 'home': case 'end': return edit({ type: name });
4983
+ case 'home': { // Ctrl+A stays line-start; a second Ctrl+A within 600 ms selects the whole draft (and copies it)
4984
+ if (e.via === 'ctrl-a') {
4985
+ const k = K.current;
4986
+ if (k.ctrlA && now - k.ctrlA.at < 600 && k.ctrlA.text === B.current.text && B.current.text) { k.ctrlA = 0; edit({ type: 'selectAll' }); copySelection(); return; }
4987
+ k.ctrlA = { at: now, text: B.current.text };
4988
+ }
4989
+ return edit({ type: 'home' });
4990
+ }
4991
+ case 'left': case 'right': case 'end': return edit({ type: name });
3667
4992
  case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'wordLeft' : 'wordRight' });
3668
4993
  case 'kill-line-forward': return edit({ type: 'killLineForward' }); // Ctrl+K
3669
4994
  case 'delete-word-forward': return edit({ type: 'deleteWordForward' }); // Alt+D, Ctrl+Delete
@@ -3678,21 +5003,22 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3678
5003
  case 'redo': return edit({ type: 'redo' });
3679
5004
  case 'up': {
3680
5005
  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
5006
+ if (browsingHistory() || atVisualEdge(B.current, 'up', composerWidth())) { // COMPOSER-EDIT: Up walks wrapped rows first, history from the top row
3682
5007
  const hist = R.current.history;
3683
5008
  if (B.current.text === '' || R.current.histIdx >= 0) {
3684
5009
  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] }); }
5010
+ if (hist[i] !== undefined) { R.current.histIdx = i; R.current.histText = hist[i]; edit({ type: 'set', text: hist[i] }); }
3686
5011
  }
3687
5012
  return;
3688
5013
  }
3689
5014
  return edit({ type: 'up', width: composerWidth() });
3690
5015
  }
3691
5016
  case 'down': {
5017
+ if (B.current.text === '' && R.current.histIdx < 0 && !s.approvals.length && LC.current.anyRunning() && !LC.current.focused()) { LC.current.focus(); return; } // QLOOP-LIVE: Down on an empty composer reaches the newest running card (its hint line says so)
3692
5018
  if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
3693
- if (atVisualEdge(B.current, 'down', composerWidth()) && R.current.histIdx >= 0) {
5019
+ if ((browsingHistory() || atVisualEdge(B.current, 'down', composerWidth())) && R.current.histIdx >= 0) {
3694
5020
  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] }); }
5021
+ if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; R.current.histText = R.current.history[i]; edit({ type: 'set', text: R.current.history[i] }); }
3696
5022
  return;
3697
5023
  }
3698
5024
  return edit({ type: 'down', width: composerWidth() });
@@ -3732,6 +5058,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3732
5058
  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
5059
  if (e.name === 'enter' && autoIdle()) { stopCountdown(); cdLog('restart-now'); doRestart(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3734
5060
  }
5061
+ if (e.type === 'key' && e.name === 'enter' && !AR.current.timer && !AR.current.waiting && AR.current.quietId && AR.current.quietId === AR.current.newId && autoIdle()) { cdLog('restart-now'); autoFire(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the quiet line's Enter: waits for collector /health like the countdown does
3735
5062
  if (R.current.sleep) { // the sleep screen eats the waking key (nothing is lost), except "/": slash commands always work asleep
3736
5063
  if (e.type === 'key' && e.name === 'ctrl-c') { exit(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
3737
5064
  if (R.current.sleep.wakeAt != null) { /* already waking: later keys are typed as normal, so "/back" typed in one go is not cut short */ }
@@ -3746,6 +5073,18 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3746
5073
  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
5074
  if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
3748
5075
  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)
5076
+ if (R.current.rs && (e.type === 'key' || e.type === 'text' || e.type === 'paste')) { rsEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5077
+ if (e.type === 'key' && e.name === 'ctrl-r' && !headCard(S.current) && R.current.history.length) { R.current.rs = { saved: B.current, q: '', skip: 0, hit: null }; rsShow(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5078
+ if (e.type === 'key' && e.name === 'ctrl-]') { // EXT panels: Ctrl+] cycles focus through them
5079
+ if (!DK.current.cycle()) say(DK.current.armed() ? 'No extension has a panel. /ext list' : 'No extension is loaded. /ext list', 'dim');
5080
+ K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue;
5081
+ }
5082
+ if (DK.current.focused() && (e.type === 'text' || (e.type === 'key' && e.name !== 'ctrl-c' && e.name !== 'ctrl-d'))) { // a focused panel has the keys; Esc gives them back
5083
+ let took = false;
5084
+ for (const k of e.type === 'text' ? [...e.text].map(panelKeyName) : [panelKeyName(e)]) took = DK.current.key(k) || took;
5085
+ if (took) { K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
5086
+ }
5087
+ if (e.type === 'key' && e.name === 'escape' && R.current.delConfirm) { cancelDelConfirm(); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // Esc backs out of the type-the-name step to the card
3749
5088
  const card = headCard(S.current);
3750
5089
  const g = K.current.guard;
3751
5090
  // any different key ends the "this key was already down" suppression
@@ -3753,6 +5092,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3753
5092
  // Tab (composer empty) puts the card away into the status line until a NEW approval arrives
3754
5093
  // RESTORE-TUI: Tab with a draft focuses the card (the draft is kept, never queued or sent); Esc returns to the draft
3755
5094
  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; }
5095
+ if (card && e.type === 'key' && e.name === 'escape' && R.current.denyReason) { R.current.denyReason = null; B.current = initialBuffer(); say('Deny cancelled. The card is still open.', 'dim'); bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // Esc backs out of the Reason? prompt to y/n
3756
5096
  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
5097
  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
5098
  if (card && K.current.confirm && e.type === 'key') { K.current.confirm = false; bump(n => n + 1); } // any other key cancels a pending privileged confirm
@@ -3761,6 +5101,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3761
5101
  edit({ type: 'paste', text: e.text }); // a card never discards a paste
3762
5102
  } else if (e.type === 'text') {
3763
5103
  if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
5104
+ else if (!card && !B.current.text && LC.current.focused() && LC.current.key(null, e.text)) { /* consumed by the focused loop card (s halts, y confirms) */ }
3764
5105
  else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
3765
5106
  else if (!card && askKey(e.text, now)) { /* consumed by the open ask */ }
3766
5107
  else edit({ type: 'insert', text: e.text });
@@ -3774,6 +5115,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3774
5115
  };
3775
5116
  onData.current = (chunk) => {
3776
5117
  const now = Date.now();
5118
+ DC.current.keypress(); // a keypress sends the companion dragon away, but not in its first moments
3777
5119
  finishIntroRef.current();
3778
5120
  // Enter is a newline only inside a real paste (bracketed, or several lines in one chunk: keys.js).
3779
5121
  // Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
@@ -3812,6 +5154,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3812
5154
  }, (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
5155
  return () => { live = false; };
3814
5156
  }, []);
5157
+ const startupRecheck = React.useRef(null);
3815
5158
  React.useEffect(() => {
3816
5159
  if (!startup) return undefined;
3817
5160
  let live = true;
@@ -3824,18 +5167,90 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3824
5167
  timer = null;
3825
5168
  let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
3826
5169
  if (!live) return;
3827
- if (hl?.ok) { say('✔ QuoxCORE is answering again', 'accent'); return; }
5170
+ if (hl?.ok) { noteAnswering(); flushOutbox(); return; }
3828
5171
  recheck(Math.min(i + 1, delays.length - 1));
3829
5172
  }, delays[Math.min(i, delays.length - 1)]);
3830
5173
  };
5174
+ // A fresh window that cannot reach QuoxCORE is most likely racing a collector restart: say so (spinner) for the grace
5175
+ // window and only then fall back to "No QuoxCORE running". Connecting inside it says nothing.
5176
+ const graceThenSay = (note) => {
5177
+ const t0 = Date.now(); let tm = null;
5178
+ const spin = (t) => dispatch({ type: 'notify', patch: { spinner: t } });
5179
+ spin('QuoxCORE is restarting, retrying…');
5180
+ const step = async () => {
5181
+ tm = null;
5182
+ let hl = null; try { hl = await healthRecheck(); } catch { hl = null; }
5183
+ if (!live) return;
5184
+ if (hl?.ok) { spin(''); return; }
5185
+ if (Date.now() - t0 >= startupGraceMs) { spin(''); say(note, 'warn'); return; }
5186
+ tm = setTimeout(step, startupRetryMs);
5187
+ };
5188
+ tm = setTimeout(step, startupRetryMs);
5189
+ stopGrace = () => { clearTimeout(tm); spin(''); };
5190
+ };
5191
+ let stopGrace = null;
5192
+ startupRecheck.current = recheck;
3831
5193
  Promise.resolve().then(startup).then((note) => {
3832
5194
  if (!live || !note) return;
5195
+ if (neverAnswered && onConnect && /^No QuoxCORE running/.test(note) && !R.current.neverAsk && !R.current.neverAsked) { // ONBOARD-P2: never answered + dead URL = wrong address, not an outage
5196
+ const url = (/ at (\S+)/.exec(note) || [])[1] || 'the configured server';
5197
+ R.current.neverAsked = true; R.current.neverAsk = { stage: 'offer', url, at: Date.now() };
5198
+ say(askText(`Quox at ${url} has never answered from this machine. Connect to a different server?`, { action: 'ask for its address, check it is a Quox, then sign you in' }), 'ask');
5199
+ return;
5200
+ }
5201
+ if (/^No QuoxCORE running/.test(note) && healthRecheck && startupGraceMs > 0) { graceThenSay(note); return; }
3833
5202
  if (slow(note)) { if (startup.said) return; startup.said = true; } // once per session, even if the probe is mounted twice
3834
5203
  say(note, 'warn');
3835
5204
  if (slow(note)) recheck(0);
3836
5205
  }, () => {});
5206
+ return () => { live = false; clearTimeout(timer); stopGrace?.(); };
5207
+ }, []);
5208
+ // The server holds presence per user: any window's /afk, /dnd, /sleep, /back lands there. A window that is already open
5209
+ // re-reads it (mount + every presencePollMs) so they all agree. An unreadable read changes nothing.
5210
+ function applyServerAway(p) {
5211
+ if (!p || AWAY.current.afk || !(p.away === true || p.state === 'away' || p.state === 'sleeping')) return;
5212
+ const note = p.note || (p.state === 'sleeping' ? 'sleeping' : '');
5213
+ const explicit = p.dnd === true || note.toLowerCase().includes(DND_NOTE); const um = /until (\d\d):(\d\d)/.exec(note); // DND restored from the server note
5214
+ const sMs = Date.parse(p.since); const sOk = Number.isFinite(sMs) && sMs <= Date.now(); // the server knows when this away began: carry it, not this window's start
5215
+ if (sOk) AWAY.current.last = sMs;
5216
+ AWAY.current.afk = { at: sOk ? sMs : Date.now(), ...(sOk ? {} : { sinceUnknown: true }), note, dnd: true, ...(explicit ? { explicit: true, until: um ? untilEpoch({ h: Number(um[1]), m: Number(um[2]) }) : null } : {}) }; AWAY.current.col ||= newCollected(); if (sOk) AWAY.current.col.since = sMs;
5217
+ bump(n => n + 1);
5218
+ }
5219
+ React.useEffect(() => { // PRESENCE-SYNC: /back (or /afk, /dnd) in ANOTHER window of this user reaches this one
5220
+ if (!api.getPresence || !(presencePollMs > 0)) return undefined;
5221
+ let live = true;
5222
+ const t = setInterval(() => {
5223
+ api.getPresence().then((r) => {
5224
+ if (!live || !r?.ok || !r.presence) return; // unreadable: never a verdict
5225
+ const p = r.presence; const isAway = p.away === true || p.state === 'away' || p.state === 'sleeping';
5226
+ if (isAway) { applyServerAway(p); return; }
5227
+ const a = AWAY.current.afk;
5228
+ // present on the server: a window still showing away was ended elsewhere. Not while this window's own write is in flight / failed.
5229
+ if (a && !R.current.presenceUnsynced && Date.now() - (R.current.presenceSetAt || 0) > 15000) { archiveWindow(); AWAY.current.afk = null; bump(n => n + 1); }
5230
+ }, () => {});
5231
+ }, presencePollMs);
5232
+ return () => { live = false; clearInterval(t); };
5233
+ }, []);
5234
+ React.useEffect(() => { // AFK-SURVIVES-RESTART (owner 2026-10-28): a restarted window forgets AWAY; the server still knows. Read-only, never marks present; unreadable = as today.
5235
+ if (!api.getPresence) return undefined;
5236
+ let live = true; let timer = null;
5237
+ // owner live 2026-10-30 10:16: a restart onto a slow collector read nothing and defaulted DND off. An UNREADABLE presence
5238
+ // (error, timeout) is retried with backoff and never means "present"; only a successful read can say so.
5239
+ const attempt = (i) => {
5240
+ const retry = () => { if (live && i < presenceRetryDelays.length) timer = setTimeout(() => attempt(i + 1), presenceRetryDelays[i]); };
5241
+ api.getPresence().then((r) => {
5242
+ if (!r?.ok) { retry(); return; }
5243
+ if (!live) return;
5244
+ applyServerAway(r.presence);
5245
+ }, retry);
5246
+ };
5247
+ attempt(0);
3837
5248
  return () => { live = false; clearTimeout(timer); };
3838
5249
  }, []);
5250
+ React.useEffect(() => { // DND "until HH:MM": the deadline ends it (the owner is back at that time; collected items stay for /morningreport)
5251
+ const t = setInterval(() => { const a = AWAY.current.afk; if (a?.explicit && a.until && Date.now() >= a.until) dndOff(); }, 15000);
5252
+ return () => clearInterval(t);
5253
+ }, []);
3839
5254
  React.useEffect(() => { // E5: "While you were away" under the intro, after more than 2 h away
3840
5255
  if (!away) return undefined;
3841
5256
  let live = true;
@@ -3888,6 +5303,7 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3888
5303
  stdin.on('data', fn);
3889
5304
  return () => {
3890
5305
  R.current.closed = true;
5306
+ LC.current.dispose();
3891
5307
  R.current.abort?.abort(); // closing quoxon cancels the active stream (and its HTTP body)
3892
5308
  for (const k of ['fallbackTimer', 'ctrlCTimer']) clearTimeout(R.current[k]);
3893
5309
  clearTimeout(K.current.danglingTimer);
@@ -3930,16 +5346,28 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3930
5346
  }
3931
5347
  pendingHist = s.history.slice(committedRef.current);
3932
5348
  }
5349
+ const staticCols = columns != null ? cols : liveCols(stdout, cols); // history prints at the terminal's width NOW
5350
+ const staticItems = (() => { // QLOOP-LIVE: the committed history for <Static>; append-only, a settled loop card joins at the end
5351
+ let st = staticRef.current;
5352
+ const upto = Math.min(committedRef.current, s.history.length);
5353
+ if (st.seen > s.history.length || st.epoch !== epoch) st = staticRef.current = { list: [], seen: 0, deferred: [], epoch }; // /clear bumps the epoch: a reset that leaves history no shorter than before (one welcome, then one note) must still start the list over
5354
+ let list = st.list;
5355
+ for (; st.seen < upto; st.seen++) { const it = s.history[st.seen]; if (LC.current.held(it)) st.deferred.push(it); else list = [...list, it]; }
5356
+ if (st.deferred.length) { const done = st.deferred.filter((it) => !LC.current.held(it)); if (done.length) { st.deferred = st.deferred.filter((it) => !done.includes(it)); list = [...list, ...done]; } }
5357
+ st.list = list;
5358
+ return list;
5359
+ })();
3933
5360
  lastAppRender.current = nowR;
3934
5361
  // Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
3935
5362
  const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
3936
5363
  const queue = queueOf(s);
5364
+ const burst0 = queue[0] && !signedOut ? batchFor(queue, queue[0]) : null; // APPROVAL-BURST: the head card's batch, if it has one
3937
5365
  const card = queue[0] || null;
3938
5366
  const armed = card ? Date.now() - Math.max(card.displayedAt ?? card.shownAt ?? 0, K.current.guard?.since ?? 0) >= 300 : false;
3939
5367
  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
5368
  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
5369
  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
5370
+ 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, SV.current.chip ? { name: SV.current.chip.name, role: SV.current.chip.role } : null, SV.current.chip?.remote || null]; // [6],[7]: server chip, remote instance; [5]: auto-restart, context row of the status
3943
5371
  const caps = R.current.caps;
3944
5372
  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
5373
  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…';
@@ -3963,13 +5391,26 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3963
5391
  function approvalRowShown() {
3964
5392
  const ro = R.current.roster;
3965
5393
  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() });
5394
+ 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(), staleAt: signedOut ? ro.at || Date.now() : null });
3967
5395
  return v.mode === 'full' && v.lines.some(l => l.kind === 'approval');
3968
5396
  }
3969
5397
 
5398
+ /** EXT panels: the host-owned tab strip (only with 1+ panels) and, when one is open, its clipped lines above the composer. */
5399
+ function extPanelRows() {
5400
+ const ps = DK.current.panels(); if (!ps.length) return [];
5401
+ const open = DK.current.shown();
5402
+ const rowsN = DK.current.regionHeight(termRows(stdout));
5403
+ const theme = { name: mono() ? 'mono' : isLight() ? 'light' : 'dark', color: !mono(), unicode: /utf-?8/i.test(String(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '')) };
5404
+ const out = [h(Box, { key: 'ext-tabs', paddingLeft: 1 }, ...ps.map(p => h(Text, { key: p.name, color: c(p.focused ? 'accent' : 'dim'), bold: p.focused, inverse: p.focused }, ` ${p.title} `)), h(Text, { key: 'ext-hint', color: c('dim') }, DK.current.focused() ? ' Esc: composer Ctrl+]: next' : ' Ctrl+]: focus'))];
5405
+ if (open) {
5406
+ const lines = DK.current.renderPanel(open, { width: Math.max(1, cols - 2), height: rowsN, theme });
5407
+ out.push(h(Box, { key: 'ext-body', flexDirection: 'column', paddingLeft: 1 }, ...lines.map((l, i) => h(Text, { key: i, wrap: 'truncate-end' }, l || ' '))));
5408
+ }
5409
+ return out;
5410
+ }
3970
5411
  function rosterNode() {
3971
5412
  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() });
5413
+ 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(), staleAt: signedOut ? ro.at || Date.now() : null });
3973
5414
  const row = ro.rows[ro.sel];
3974
5415
  return h(Roster, {
3975
5416
  view, cols,
@@ -3983,39 +5424,44 @@ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTim
3983
5424
  if (sleeping) { // the screensaver replaces the live region only; Static (scrollback) is frozen, the transcript untouched
3984
5425
  const rows = Math.max(8, (termRows(stdout) ?? 24) - 1), sl = R.current.sleep;
3985
5426
  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)) }))),
5427
+ h(Static, { key: `static-${epoch}`, items: staticItems }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols: staticCols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
3987
5428
  h(SleepScreen, { lines: sourceLines(S.current, cols, rows), cols, rows, startedAt: sl.startedAt, wakeAt: sl.wakeAt, still: sl.still })));
3988
5429
  }
3989
5430
  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
5431
  return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
3991
5432
  // Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
3992
5433
  // 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)) }))),
5434
+ h(Static, { key: `static-${epoch}`, items: introDone.current ? staticItems : [] }, (it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols: staticCols, gap: historyGap(staticItems[i - 1], it, termRows(stdout)) }))),
3994
5435
  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
5436
  liveRegion([
3996
5437
  ...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
5438
  ...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
5439
  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,
5440
+ flyActive(nowR) ? h(FlyLive, { key: 'dragon-fly', cols, rows: termRows(stdout), startedAt: R.current.fly.startedAt, still: R.current.fly.still, chromeH: chromeH.current }) : null,
4000
5441
  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,
5442
+ h(DragonCompanionLive, { key: 'dragon-companion', ctl: DC.current, cols, rows: termRows(stdout) ?? 24 }),
4001
5443
  h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
4002
5444
  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,
5445
+ ...LC.current.pinned().map((lc) => h(Box, { key: `loopcard-${lc.id}`, marginTop: 1 }, h(LoopCardView, { item: { card: lc }, cols, pinned: true }))), // QLOOP-LIVE: running loops are pinned above the composer
5446
+ // PIN-GLITCH: while the PIN pad is open the pad is the whole question (its subject names the action). Keeping the card body above it
5447
+ // pushed the live region to the terminal height, and Ink then wipes and rewrites the whole screen on every keystroke and poll.
5448
+ SU.current.isOpen() ? null : card && burst0 ? h(BatchCard, { model: batchModel(burst0), cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null }) : card ? h(ApprovalCard, { card, org: orgShown() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, signedOut: Boolean(signedOut), draftHint: R.current.denyReason ? 'reason' : K.current.cardFocus ? 'focus' : (B.current.text ? 'draft' : null), confirm: K.current.confirm, position: { i: 1, n: queue.length, others: otherSessionsCount(queue) } }) : null,
4004
5449
  ...(s.asks || []).map(a => h(Box, { key: a.id, flexDirection: 'column' }, h(AskCard, { text: a.text, cols }))), // ASK-SETTLE: open asks are live
4005
5450
  // TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
4006
5451
  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
5452
  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,
5453
+ 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, { waiting: Boolean(AR.current.waiting) }))) : null,
5454
+ ...extPanelRows(),
4009
5455
  ruleAbove ? null : h(Box, { marginTop: 1 }),
4010
5456
  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 }),
5457
+ : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, slow: s.notify.slow, signedOut: Boolean(blocked), outstanding: s.notify.outstanding, blind: s.notify.blind, others: s.notify.others }, ring, divider: s.divider ? { d: s.divider, slow: dividerSlowRef.current } : null })
5458
+ : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : MD.current, busy, placeholder, name: SN.current.name, model: ctxParts[2], topRule: !ruleAbove, approvals: { count: s.notify.approvals, error: s.notify.error, slow: s.notify.slow, signedOut: Boolean(blocked), outstanding: s.notify.outstanding, blind: s.notify.blind, others: s.notify.others } }),
4013
5459
  !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 }),
5460
+ h(RowsBar, { rows: RW.current.view(), cols, limit: { grid: QPE_GRID, n: qpeRowsSetting(api.settings?.()) } }),
4015
5461
  h(PresentBar, { stepUp: SU.current, cols }),
4016
5462
  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
5463
  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)] }),
5464
+ h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), hideCount: approvalRowShown(), away: Boolean(AWAY.current.afk), awaySince: AWAY.current.afk?.sinceUnknown ? 0 : AWAY.current.afk?.at || 0, awayUnknown: Boolean(AWAY.current.afk?.sinceUnknown), dndUntil: AWAY.current.afk?.until || 0, dnd: Boolean(AWAY.current.afk?.explicit), awayCount: Math.max(s.notify.approvals || 0, [...s.approvals, ...s.waiting].length), dndCount: waitingCount(AWAY.current.col, Math.max(s.notify.approvals || 0, [...s.approvals, ...s.waiting].length)), inbox: AWAY.current.afk ? '' : (s.notify.inboxBar || ''), spinner: s.notify.spinner || '', alerts: [...(s.notify.providerLines || []), ...(R.current.roster.all ? [] : R.current.roster.alerts)] }),
4019
5465
  rosterNode())));
4020
5466
  }
4021
5467